From 4bca6e69ea083f247e6658ed8735f8ddcc66b5b7 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 3 Sep 2026 06:50:01 +0700 Subject: [PATCH 1/7] chore(deps): align @earendil-works/* to ^0.84.4; vision ^0.5.3; drop superseded vision patch --- package.json | 8 +- pnpm-lock.yaml | 208 ++++++++++++++++++++----------------------------- 2 files changed, 88 insertions(+), 128 deletions(-) diff --git a/package.json b/package.json index daaa6da..be80651 100644 --- a/package.json +++ b/package.json @@ -48,12 +48,12 @@ "test:run": "node --import tsx --test --test-timeout=30000 test/*.test.mts" }, "dependencies": { - "@earendil-works/pi-coding-agent": "^0.81.1", - "@earendil-works/pi-tui": "^0.81.1", - "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-coding-agent": "^0.84.4", + "@earendil-works/pi-tui": "^0.84.4", + "@earendil-works/pi-ai": "^0.84.4", "@getpipher/armory-todo": "^0.5.4", "@getpipher/armory-memory": "^0.1.1", - "@getpipher/vision": "^0.5.2", + "@getpipher/vision": "^0.5.3", "typebox": "^1.1.38", "yaml": "^2.5.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3804082..5fea326 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,23 +9,23 @@ importers: .: dependencies: '@earendil-works/pi-ai': - specifier: ^0.81.1 - version: 0.81.1(ws@8.21.1)(zod@4.4.3) + specifier: ^0.84.4 + version: 0.84.4(ws@8.21.1)(zod@4.4.3) '@earendil-works/pi-coding-agent': - specifier: ^0.81.1 - version: 0.81.1(ws@8.21.1)(zod@4.4.3) + specifier: ^0.84.4 + version: 0.84.4(ws@8.21.1)(zod@4.4.3) '@earendil-works/pi-tui': - specifier: ^0.81.1 - version: 0.81.1 + specifier: ^0.84.4 + version: 0.84.4 '@getpipher/armory-memory': specifier: ^0.1.1 - version: 0.1.1(@earendil-works/pi-ai@0.81.1(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.81.1(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7) + version: 0.1.1(@earendil-works/pi-ai@0.84.4(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.84.4(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7) '@getpipher/armory-todo': specifier: ^0.5.4 - version: 0.5.4(@earendil-works/pi-ai@0.81.1(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.81.1(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7) + version: 0.5.4(@earendil-works/pi-ai@0.84.4(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.84.4(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7) '@getpipher/vision': - specifier: ^0.5.2 - version: 0.5.2(@earendil-works/pi-coding-agent@0.81.1(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7) + specifier: ^0.5.3 + version: 0.5.3(@earendil-works/pi-coding-agent@0.84.4(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7) typebox: specifier: ^1.1.38 version: 1.3.7 @@ -155,22 +155,34 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@earendil-works/pi-agent-core@0.81.1': - resolution: {integrity: sha512-yqbh68CyhqxMov/jUogFJfMqlu2Gd37GAki+tr59YCmAPHfomiCA5ESzusXtpGzABeiZFC/OrRdQ4GwCCOMIHA==} + '@earendil-works/pi-agent-core@0.84.4': + resolution: {integrity: sha512-HyUnjaOXj6oN/6SNcr8A1J/ElRQA50FtIE0XUTSKAQVqmdlb9qdojOyUQwF/jULE5+yOEtGuVgi/N1RnBiNG+g==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@0.81.1': - resolution: {integrity: sha512-hzHE7Z8l5mgJk+ke67Lge0rwS2+wbKJrFKl9o5M1R1rh33+cCT7D1AHz1OAtX5wFs90E1/BTGhyJRTUHaMxGvQ==} + '@earendil-works/pi-ai@0.84.4': + resolution: {integrity: sha512-AClAZxf5+c4RRu44NJPS6wyQy+Nmq+Mzyyrdvm4ZVMNuixelO02RZX4G4Aq1F145Yzp43wnM5S+hLlSI7ypfVw==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.81.1': - resolution: {integrity: sha512-r6ovAsZOgAqbC/aU6s+/dPnv/sGZBuWyZNvi3pXjpbuX5wvp3XvGkQI7/VLvX2o9XpmpFaPUxKNym1WfkN/P8A==} + '@earendil-works/pi-client@0.84.4': + resolution: {integrity: sha512-q398WY/3ZQHTizk7IKxApzqFV0xt4yM9LkSkwyqeLK5Bj5RwRjOWxESt26z4LgNp4O+8hqhqFPf/8fj4H5rE4A==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-coding-agent@0.84.4': + resolution: {integrity: sha512-jmOlrqUmvhh/siNWFRXjYLJzhKFIHNsAQaysRwzQPQFnPAaV/vhqHsLH/MBsIISA1Rjj7WTUFR3nJrpXoLx39w==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-tui@0.81.1': - resolution: {integrity: sha512-OMEe+Zt8oQYi/rCq3upxsTlIScWL0FPhXwQus34TbQb3EmTx88S7Uzx32JxvQiEeWOw8eDCdJf2PBUBE9r6wIg==} + '@earendil-works/pi-protocol@0.84.4': + resolution: {integrity: sha512-acyE9ozxkMiWiz/xyWpU0O9vwnYv0hyG889Vniv6Sg9c9zfsX+8MePnDNphBacY2Fvm1rxdsGmiVDSZl9yuDFA==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-telemetry@0.84.4': + resolution: {integrity: sha512-8e2CuxM+ht+hedQXTZmi5JVl6/xDK9RpSDL2+MbITevKYQhMZ/z6lJOTFgox3HQyGxO8mOZEtYGVeQNaD4OzqA==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-tui@0.84.4': + resolution: {integrity: sha512-nPUnwDkLtupPXnZQYrCwPFcuTydCDqTY6ZbFqhsL4S4kVq0AT418kPa/6uXwtaCD+MjBNBltb7ScTYX65yeE1w==} engines: {node: '>=22.19.0'} '@esbuild/aix-ppc64@0.28.1': @@ -357,8 +369,8 @@ packages: typebox: optional: true - '@getpipher/vision@0.5.2': - resolution: {integrity: sha512-frHkPWjsfG0ENaKxyDa5n3hwJkx3HsmrKVuMGp3OqSh/7oNUpsY2X5vt1RCbgmB71Kk7TNeCFu+opmwRUJ10cA==} + '@getpipher/vision@0.5.3': + resolution: {integrity: sha512-nInSwMc+XSylMWAcK5LhoHi0N6zxA4R2dy99byDGph49kHL0Tcym7qj41zT52m9oi2lIQsTglBMnhXb5vCTtiw==} engines: {node: '>=20'} peerDependencies: '@earendil-works/pi-coding-agent': '*' @@ -441,22 +453,6 @@ packages: resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} engines: {node: '>= 10'} - '@mistralai/mistralai@2.2.6': - resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} - peerDependencies: - '@opentelemetry/api': ^1.9.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - - '@opentelemetry/api@1.9.0': - resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} - engines: {node: '>=8.0.0'} - - '@opentelemetry/semantic-conventions@1.43.0': - resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} - engines: {node: '>=14'} - '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -618,10 +614,6 @@ packages: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} - glob@13.0.6: - resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} - engines: {node: 18 || 20 || >=22} - google-auth-library@10.9.1: resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} engines: {node: '>=18'} @@ -633,6 +625,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grok-mermaid@0.2.2: + resolution: {integrity: sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==} + engines: {node: '>=18'} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -688,10 +684,6 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -704,9 +696,8 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - openai@6.26.0: - resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} - hasBin: true + openai@6.40.0: + resolution: {integrity: sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==} peerDependencies: ws: ^8.18.0 zod: ^3.25 || ^4.0 @@ -727,10 +718,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} - proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} @@ -776,9 +763,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - typebox@1.1.38: - resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} - typebox@1.3.7: resolution: {integrity: sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==} @@ -790,8 +774,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@8.5.0: - resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} + undici@8.9.0: + resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} engines: {node: '>=22.19.0'} web-streams-polyfill@3.3.3: @@ -820,11 +804,6 @@ packages: engines: {node: '>= 14.6'} hasBin: true - zod-to-json-schema@3.25.2: - resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} - peerDependencies: - zod: ^3.25.28 || ^4 - zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -875,7 +854,7 @@ snapshots: '@aws-sdk/types': 3.974.2 '@smithy/core': 3.29.8 '@smithy/fetch-http-handler': 5.6.10 - '@smithy/node-http-handler': 4.7.3 + '@smithy/node-http-handler': 4.9.10 '@smithy/types': 4.16.1 tslib: 2.8.1 @@ -1052,11 +1031,13 @@ snapshots: '@babel/runtime@7.29.7': {} - '@earendil-works/pi-agent-core@0.81.1(ws@8.21.1)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.84.4(ws@8.21.1)(zod@4.4.3)': dependencies: - '@earendil-works/pi-ai': 0.81.1(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-ai': 0.84.4(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-telemetry': 0.84.4 + diff: 8.0.4 ignore: 7.0.5 - typebox: 1.1.38 + typebox: 1.3.7 yaml: 2.9.0 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -1066,19 +1047,18 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.81.1(ws@8.21.1)(zod@4.4.3)': + '@earendil-works/pi-ai@0.84.4(ws@8.21.1)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@earendil-works/pi-telemetry': 0.84.4 '@google/genai': 1.52.0 - '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) - '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - openai: 6.26.0(ws@8.21.1)(zod@4.4.3) + openai: 6.40.0(ws@8.21.1)(zod@4.4.3) partial-json: 0.1.7 - typebox: 1.1.38 + typebox: 1.3.7 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil @@ -1087,16 +1067,22 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.81.1(ws@8.21.1)(zod@4.4.3)': + '@earendil-works/pi-client@0.84.4': + dependencies: + '@earendil-works/pi-protocol': 0.84.4 + + '@earendil-works/pi-coding-agent@0.84.4(ws@8.21.1)(zod@4.4.3)': dependencies: - '@earendil-works/pi-agent-core': 0.81.1(ws@8.21.1)(zod@4.4.3) - '@earendil-works/pi-ai': 0.81.1(ws@8.21.1)(zod@4.4.3) - '@earendil-works/pi-tui': 0.81.1 + '@earendil-works/pi-agent-core': 0.84.4(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-ai': 0.84.4(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-client': 0.84.4 + '@earendil-works/pi-protocol': 0.84.4 + '@earendil-works/pi-tui': 0.84.4 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 diff: 8.0.4 - glob: 13.0.6 + grok-mermaid: 0.2.2 highlight.js: 10.7.3 hosted-git-info: 9.0.3 ignore: 7.0.5 @@ -1104,8 +1090,8 @@ snapshots: minimatch: 10.2.5 proper-lockfile: 4.1.2 semver: 7.8.0 - typebox: 1.1.38 - undici: 8.5.0 + typebox: 1.3.7 + undici: 8.9.0 yaml: 2.9.0 optionalDependencies: '@mariozechner/clipboard': 0.3.9 @@ -1117,7 +1103,13 @@ snapshots: - ws - zod - '@earendil-works/pi-tui@0.81.1': + '@earendil-works/pi-protocol@0.84.4': + dependencies: + typebox: 1.3.7 + + '@earendil-works/pi-telemetry@0.84.4': {} + + '@earendil-works/pi-tui@0.84.4': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 @@ -1200,21 +1192,21 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@getpipher/armory-memory@0.1.1(@earendil-works/pi-ai@0.81.1(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.81.1(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7)': + '@getpipher/armory-memory@0.1.1(@earendil-works/pi-ai@0.84.4(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.84.4(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7)': optionalDependencies: - '@earendil-works/pi-ai': 0.81.1(ws@8.21.1)(zod@4.4.3) - '@earendil-works/pi-coding-agent': 0.81.1(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-ai': 0.84.4(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-coding-agent': 0.84.4(ws@8.21.1)(zod@4.4.3) typebox: 1.3.7 - '@getpipher/armory-todo@0.5.4(@earendil-works/pi-ai@0.81.1(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.81.1(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7)': + '@getpipher/armory-todo@0.5.4(@earendil-works/pi-ai@0.84.4(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.84.4(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7)': optionalDependencies: - '@earendil-works/pi-ai': 0.81.1(ws@8.21.1)(zod@4.4.3) - '@earendil-works/pi-coding-agent': 0.81.1(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-ai': 0.84.4(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-coding-agent': 0.84.4(ws@8.21.1)(zod@4.4.3) typebox: 1.3.7 - '@getpipher/vision@0.5.2(@earendil-works/pi-coding-agent@0.81.1(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7)': + '@getpipher/vision@0.5.3(@earendil-works/pi-coding-agent@0.84.4(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7)': dependencies: - '@earendil-works/pi-coding-agent': 0.81.1(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-coding-agent': 0.84.4(ws@8.21.1)(zod@4.4.3) typebox: 1.3.7 '@google/genai@1.52.0': @@ -1272,22 +1264,6 @@ snapshots: '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 optional: true - '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/semantic-conventions': 1.43.0 - ws: 8.21.1 - zod: 4.4.3 - zod-to-json-schema: 3.25.2(zod@4.4.3) - optionalDependencies: - '@opentelemetry/api': 1.9.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@opentelemetry/api@1.9.0': {} - - '@opentelemetry/semantic-conventions@1.43.0': {} - '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -1466,12 +1442,6 @@ snapshots: get-east-asian-width@1.6.0: {} - glob@13.0.6: - dependencies: - minimatch: 10.2.5 - minipass: 7.1.3 - path-scurry: 2.0.2 - google-auth-library@10.9.1: dependencies: base64-js: 1.5.1 @@ -1487,6 +1457,8 @@ snapshots: graceful-fs@4.2.11: {} + grok-mermaid@0.2.2: {} + highlight.js@10.7.3: {} hosted-git-info@9.0.3: @@ -1543,8 +1515,6 @@ snapshots: dependencies: brace-expansion: 5.0.8 - minipass@7.1.3: {} - ms@2.1.3: {} node-domexception@1.0.0: {} @@ -1555,7 +1525,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - openai@6.26.0(ws@8.21.1)(zod@4.4.3): + openai@6.40.0(ws@8.21.1)(zod@4.4.3): optionalDependencies: ws: 8.21.1 zod: 4.4.3 @@ -1569,11 +1539,6 @@ snapshots: path-key@3.1.1: {} - path-scurry@2.0.2: - dependencies: - lru-cache: 11.5.2 - minipass: 7.1.3 - proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 @@ -1620,15 +1585,13 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - typebox@1.1.38: {} - typebox@1.3.7: {} typescript@5.9.3: {} undici-types@6.21.0: {} - undici@8.5.0: {} + undici@8.9.0: {} web-streams-polyfill@3.3.3: {} @@ -1640,8 +1603,5 @@ snapshots: yaml@2.9.0: {} - zod-to-json-schema@3.25.2(zod@4.4.3): - dependencies: - zod: 4.4.3 - - zod@4.4.3: {} + zod@4.4.3: + optional: true From 073bee19c651fecaf1cc8bc5b06fbef3b26435ad Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 3 Sep 2026 06:58:06 +0700 Subject: [PATCH 2/7] feat(settings): mcpDeny deny-list field (per-entry validation, warn+drop) --- src/settings/fleet-settings.ts | 37 ++++++++++++++++++++++- test/fleet-settings.test.mts | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/settings/fleet-settings.ts b/src/settings/fleet-settings.ts index 4bbe77b..117bbe0 100644 --- a/src/settings/fleet-settings.ts +++ b/src/settings/fleet-settings.ts @@ -25,6 +25,9 @@ export interface FleetSettings { /** #78: applied to every subagent whose frontmatter does NOT pin `thinkingLevel`. * Precedence: agent.thinkingLevel > this > the backend/session default. */ defaultSubagentThinking?: ThinkingLevel; + /** SPEC-1b-2: MCP governance deny-list. Entries are bare server names (deny the whole + * server) or `server__tool` (deny one exact tool). Invalid entries warn + drop. */ + mcpDeny?: string[]; } export interface FleetSettingsResult { @@ -33,6 +36,17 @@ export interface FleetSettingsResult { warnings: string[]; } +/** SPEC-1b-2: a valid mcpDeny entry is a non-empty bare server name (no `__`) or + * `server__tool` with BOTH parts non-empty. First `__` separates; a tool name + * containing `__` is allowed (gateway composes the same way). Globs are invalid. */ +function isValidMcpDenyEntry(entry: string): boolean { + if (entry.length === 0) return false; + if (/[*?[\]]/.test(entry)) return false; // globs are invalid — exact names only + const idx = entry.indexOf("__"); + if (idx === -1) return true; + return idx > 0 && idx + 2 < entry.length; +} + /** Parse one settings file's content. `label` names the file in warnings. */ export function parseFleetSettings(json: string, label = "settings.json"): FleetSettingsResult { const warnings: string[] = []; @@ -57,7 +71,28 @@ export function parseFleetSettings(json: string, label = "settings.json"): Fleet } } - const known = new Set(["defaultSubagentThinking"]); + const deny = obj["mcpDeny"]; + if (deny !== undefined) { + if (Array.isArray(deny)) { + const entries: string[] = []; + for (const item of deny) { + if (typeof item !== "string") { + warnings.push(`${label}: mcpDeny entry ${JSON.stringify(item)} is invalid — expected "server" or "server__tool" — dropped`); + continue; + } + if (isValidMcpDenyEntry(item)) { + entries.push(item); + } else { + warnings.push(`${label}: mcpDeny entry ${JSON.stringify(item)} is invalid — expected "server" or "server__tool" — dropped`); + } + } + settings.mcpDeny = entries; + } else { + warnings.push(`${label}: mcpDeny must be an array of strings — dropped`); + } + } + + const known = new Set(["defaultSubagentThinking", "mcpDeny"]); const unknownKeys = Object.keys(obj).filter((k) => !known.has(k)); if (unknownKeys.length > 0) { warnings.push(`${label}: unknown setting${unknownKeys.length > 1 ? "s" : ""} ${unknownKeys.map((k) => `"${k}"`).join(", ")} — ignored (valid: ${[...known].join(", ")})`); diff --git a/test/fleet-settings.test.mts b/test/fleet-settings.test.mts index 1aef984..e637b31 100644 --- a/test/fleet-settings.test.mts +++ b/test/fleet-settings.test.mts @@ -168,3 +168,57 @@ test("store.load: corrupt global does not shadow a valid project value", () => { rmSync(dir, { recursive: true, force: true }); } }); + +test("parseFleetSettings: mcpDeny valid entries parse through (bare server + server__tool)", () => { + const r = parseFleetSettings(JSON.stringify({ mcpDeny: ["github", "github__delete_repo", "internal-tools"] })); + deepStrictEqual(r.settings.mcpDeny, ["github", "github__delete_repo", "internal-tools"]); + deepStrictEqual(r.warnings, []); +}); + +test("parseFleetSettings: mcpDeny invalid entries warn + drop per-entry, valid entries stay enforced", () => { + const r = parseFleetSettings( + JSON.stringify({ mcpDeny: ["github__delete_repo", "", "a__", "__b", "server__*", 42] }), + "settings.json", + ); + deepStrictEqual(r.settings.mcpDeny, ["github__delete_repo"]); + strictEqual(r.warnings.length, 5); + for (const w of r.warnings) { + ok(w.startsWith("settings.json: mcpDeny entry"), `actionable + file-labeled: ${w}`); + } +}); + +test("parseFleetSettings: mcpDeny non-array value warns + field dropped", () => { + const r = parseFleetSettings(JSON.stringify({ mcpDeny: "github" }), "settings.json"); + strictEqual(r.settings.mcpDeny, undefined); + strictEqual(r.warnings.length, 1); + ok(r.warnings[0]!.includes("mcpDeny must be an array of strings")); +}); + +test("parseFleetSettings: mcpDeny empty array is valid (explicit no-op list)", () => { + const r = parseFleetSettings(JSON.stringify({ mcpDeny: [] })); + deepStrictEqual(r.settings.mcpDeny, []); + deepStrictEqual(r.warnings, []); +}); + +test("parseFleetSettings: mcpDeny itself is a known key; sibling typos still warn", () => { + const r = parseFleetSettings( + JSON.stringify({ mcpDeny: ["github"], mcpDenyTypo: ["x"] }), + "settings.json", + ); + strictEqual(r.warnings.length, 1); + ok(r.warnings[0]!.includes('unknown setting "mcpDenyTypo"')); +}); + +test("store.load: mcpDeny project wins per-field over global (whole-field replacement)", () => { + const dir = mkdtempSync(join(tmpdir(), "fleet-settings-")); + try { + const g = join(dir, "global.json"); + const p = join(dir, "project.json"); + writeFileSync(g, JSON.stringify({ mcpDeny: ["global-only"] })); + writeFileSync(p, JSON.stringify({ mcpDeny: ["project-only"] })); + const store = new FleetSettingsStore({ globalPath: g, projectPath: p }); + deepStrictEqual(store.load().settings.mcpDeny, ["project-only"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From b28709e5545777f3c4cff31fd448e75c46954846 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 3 Sep 2026 07:04:47 +0700 Subject: [PATCH 3/7] feat(governance): pure mcpDeny matcher (exact server / server__tool lookups) --- src/governance/mcp-policy.ts | 22 ++++++++++++++++++++++ test/mcp-policy.test.mts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/governance/mcp-policy.ts create mode 100644 test/mcp-policy.test.mts diff --git a/src/governance/mcp-policy.ts b/src/governance/mcp-policy.ts new file mode 100644 index 0000000..1ee6228 --- /dev/null +++ b/src/governance/mcp-policy.ts @@ -0,0 +1,22 @@ +// src/governance/mcp-policy.ts — pure MCP deny-list matcher (SPEC-1b-2 D3). +// Total function: no I/O, no throwing. Entries are validated at parse time +// (fleet-settings.ts); the matcher performs exact-match lookups only and +// never parses entries at match time. + +export interface McpPolicyTarget { + server: string; + tool: string; +} + +export type McpPolicyDecision = + | { decision: "allow" } + | { decision: "deny"; reason: string }; + +export function evaluateMcpPolicy(deny: readonly string[] | undefined, target: McpPolicyTarget): McpPolicyDecision { + if (!deny || deny.length === 0) return { decision: "allow" }; + const composed = `${target.server}__${target.tool}`; + // Exact tool match wins over a bare server entry regardless of deny-list order. + const entry = deny.find((candidate) => candidate === composed) ?? deny.find((candidate) => candidate === target.server); + if (entry === undefined) return { decision: "allow" }; + return { decision: "deny", reason: `denied by armory-fleet mcpDeny policy: matched "${entry}"` }; +} diff --git a/test/mcp-policy.test.mts b/test/mcp-policy.test.mts new file mode 100644 index 0000000..bb0b74d --- /dev/null +++ b/test/mcp-policy.test.mts @@ -0,0 +1,36 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { evaluateMcpPolicy } from "../src/governance/mcp-policy.ts"; + +test("matcher: exact tool deny wins with the matched entry in the reason", () => { + const d = evaluateMcpPolicy(["github", "github__delete_repo"], { server: "github", tool: "delete_repo" }); + assert.deepEqual(d, { decision: "deny", reason: 'denied by armory-fleet mcpDeny policy: matched "github__delete_repo"' }); +}); + +test("matcher: bare server entry denies every tool on that server", () => { + for (const tool of ["delete_repo", "create_issue", "anything"]) { + const d = evaluateMcpPolicy(["github"], { server: "github", tool }); + assert.equal(d.decision, "deny"); + } +}); + +test("matcher: non-listed server+tool allows", () => { + const d = evaluateMcpPolicy(["github", "github__delete_repo"], { server: "slack", tool: "post_message" }); + assert.deepEqual(d, { decision: "allow" }); +}); + +test("matcher: absent / undefined / empty deny list allows", () => { + assert.deepEqual(evaluateMcpPolicy(undefined, { server: "github", tool: "delete_repo" }), { decision: "allow" }); + assert.deepEqual(evaluateMcpPolicy([], { server: "github", tool: "delete_repo" }), { decision: "allow" }); +}); + +test("matcher: malformed entries cannot match (exact lookups only, no entry parsing)", () => { + const d = evaluateMcpPolicy(["", "a__", "__b", "server__*"], { server: "a", tool: "b" }); + assert.deepEqual(d, { decision: "allow" }); +}); + +test("matcher: does not mutate or read beyond server/tool (args ignored by contract)", () => { + const target = { server: "github", tool: "delete_repo" }; + const d = evaluateMcpPolicy(["github__delete_repo"], target); + assert.equal(d.decision, "deny"); +}); From 510fcfb9372e33f165c537bb7daa48df979db351 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 3 Sep 2026 07:10:26 +0700 Subject: [PATCH 4/7] feat(governance): guarded dynamic-import adapter + gateway contract tests (dev file: link, CI sibling job) --- .github/workflows/ci.yml | 2 + package.json | 9 ++-- pnpm-lock.yaml | 75 +++++++++++++++++++++++++- src/governance/gateway-adapter.ts | 67 +++++++++++++++++++++++ test/gateway-adapter.test.mts | 90 +++++++++++++++++++++++++++++++ 5 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 src/governance/gateway-adapter.ts create mode 100644 test/gateway-adapter.test.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26a0637..b98baea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: - name: "Checkout armory-todo (file dep companion)" working-directory: ${{ github.workspace }} run: git clone --depth 1 https://github.com/getpipher/armory-todo.git ../armory-todo + - name: Clone armory-gateway sibling (contract-test dependency) + run: git clone --depth 1 https://x-access-token:${{ secrets.SIBLINGS_PAT }}@github.com/getpipher/armory-gateway.git ../armory-gateway - run: pnpm install --frozen-lockfile - run: pnpm typecheck - run: pnpm test:run \ No newline at end of file diff --git a/package.json b/package.json index be80651..1d4e301 100644 --- a/package.json +++ b/package.json @@ -48,18 +48,19 @@ "test:run": "node --import tsx --test --test-timeout=30000 test/*.test.mts" }, "dependencies": { + "@earendil-works/pi-ai": "^0.84.4", "@earendil-works/pi-coding-agent": "^0.84.4", "@earendil-works/pi-tui": "^0.84.4", - "@earendil-works/pi-ai": "^0.84.4", - "@getpipher/armory-todo": "^0.5.4", "@getpipher/armory-memory": "^0.1.1", + "@getpipher/armory-todo": "^0.5.4", "@getpipher/vision": "^0.5.3", "typebox": "^1.1.38", "yaml": "^2.5.0" }, "devDependencies": { + "@getpipher/armory-gateway": "file:../armory-gateway", + "@types/node": "^22.0.0", "tsx": "^4.19.0", - "typescript": "^5.6.0", - "@types/node": "^22.0.0" + "typescript": "^5.6.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fea326..e6dc082 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: specifier: ^2.5.0 version: 2.9.0 devDependencies: + '@getpipher/armory-gateway': + specifier: file:../armory-gateway + version: file:../armory-gateway(@earendil-works/pi-ai@0.84.4(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.84.4(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7) '@types/node': specifier: ^22.0.0 version: 22.20.1 @@ -341,6 +344,20 @@ packages: cpu: [x64] os: [win32] + '@getpipher/armory-gateway@file:../armory-gateway': + resolution: {directory: ../armory-gateway, type: directory} + peerDependencies: + '@earendil-works/pi-ai': '*' + '@earendil-works/pi-coding-agent': '*' + typebox: '*' + peerDependenciesMeta: + '@earendil-works/pi-ai': + optional: true + '@earendil-works/pi-coding-agent': + optional: true + typebox: + optional: true + '@getpipher/armory-memory@0.1.1': resolution: {integrity: sha512-gj/0FPsmDqcg/FKRHT0n5Y114QfI8+A6mIIf8tSH8iQRnNNFZO4Du2/0WLN/XS+wWdhzKZ0y3Wj5iXPovxtM8Q==} peerDependencies: @@ -453,6 +470,14 @@ packages: resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} engines: {node: '>= 10'} + '@modelcontextprotocol/client@2.0.0': + resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -586,6 +611,14 @@ packages: engines: {node: '>=18'} hasBin: true + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -655,6 +688,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.10: + resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -718,6 +754,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} @@ -1192,6 +1232,14 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@getpipher/armory-gateway@file:../armory-gateway(@earendil-works/pi-ai@0.84.4(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.84.4(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7)': + dependencies: + '@modelcontextprotocol/client': 2.0.0 + optionalDependencies: + '@earendil-works/pi-ai': 0.84.4(ws@8.21.1)(zod@4.4.3) + '@earendil-works/pi-coding-agent': 0.84.4(ws@8.21.1)(zod@4.4.3) + typebox: 1.3.7 + '@getpipher/armory-memory@0.1.1(@earendil-works/pi-ai@0.84.4(ws@8.21.1)(zod@4.4.3))(@earendil-works/pi-coding-agent@0.84.4(ws@8.21.1)(zod@4.4.3))(typebox@1.3.7)': optionalDependencies: '@earendil-works/pi-ai': 0.84.4(ws@8.21.1)(zod@4.4.3) @@ -1264,6 +1312,20 @@ snapshots: '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 optional: true + '@modelcontextprotocol/client@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + jose: 6.2.10 + pkce-challenge: 5.0.1 + zod: 4.4.3 + + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.4.3 + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -1410,6 +1472,12 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + extend@3.0.2: {} fetch-blob@3.2.0: @@ -1485,6 +1553,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.10: {} + json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 @@ -1539,6 +1609,8 @@ snapshots: path-key@3.1.1: {} + pkce-challenge@5.0.1: {} + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 @@ -1603,5 +1675,4 @@ snapshots: yaml@2.9.0: {} - zod@4.4.3: - optional: true + zod@4.4.3: {} diff --git a/src/governance/gateway-adapter.ts b/src/governance/gateway-adapter.ts new file mode 100644 index 0000000..d713e79 --- /dev/null +++ b/src/governance/gateway-adapter.ts @@ -0,0 +1,67 @@ +// src/governance/gateway-adapter.ts — registers fleet's MCP governance provider with +// armory-gateway when the gateway package is resolvable (SPEC-1b-2 D4). +// +// The import is DYNAMIC and GUARDED: fleet is a public npm package; armory-gateway is +// unpublished/private, so the specifier must NEVER appear in a static import (a static +// specifier would break every public fleet install at link time). Absent gateway → +// { registered: false } — standalone behavior, byte-identical to fleet v1.2.0. +// Gateway's `status` interceptor line is the observability surface for "is the moat on". + +import { evaluateMcpPolicy } from "./mcp-policy.ts"; + +/** Structural mirror of armory-gateway's GovernanceInput/GovernanceResult (SPEC-1b §3). + * Shapes MUST stay assignment-compatible with the real module (required `args`, full + * decision union) so the real import satisfies GatewayModuleLike under + * strictFunctionTypes. This local mirror keeps the adapter typecheckable even in + * checkouts where the gateway dev link is absent. */ +export interface GovernanceInputLike { + server: string; + tool: string; + args: Record; + agent?: string; + task?: string; +} + +export type GovernanceDecisionLike = { decision: "allow" | "deny" | "rate" | "cost" | "prompt"; reason?: string }; + +export type GovernanceProviderLike = (input: GovernanceInputLike) => Promise; + +export interface GatewayModuleLike { + registerGovernanceProvider(fn: GovernanceProviderLike): void; +} + +export interface GatewayAdapterDeps { + /** Fresh per call (SPEC-1b-2 Q5c) — reads through the session's FleetSettingsStore. */ + loadDenyList: () => string[] | undefined; + /** Injectable for tests. Production: defaultImportGateway. */ + importGateway: () => Promise; +} + +export interface GatewayAdapterResult { + registered: boolean; +} + +export function defaultImportGateway(): Promise { + // Dynamic import with a literal specifier — resolved by tsx/jiti/node through the + // dev `file:` link. The cast narrows the 36-export module to the seam we use. + return import("@getpipher/armory-gateway") as unknown as Promise; +} + +export function makeGovernanceProvider(deps: GatewayAdapterDeps): GovernanceProviderLike { + return async (input) => { + // Policy is identity-based (server/tool) — args never enter policy code. + return evaluateMcpPolicy(deps.loadDenyList(), { server: input.server, tool: input.tool }); + }; +} + +export async function registerMcpGovernance(deps: GatewayAdapterDeps): Promise { + let gateway: GatewayModuleLike; + try { + gateway = await deps.importGateway(); + } catch { + // Absent gateway is the NORMAL state for public-npm fleet installs — silent skip. + return { registered: false }; + } + gateway.registerGovernanceProvider(makeGovernanceProvider(deps)); + return { registered: true }; +} diff --git a/test/gateway-adapter.test.mts b/test/gateway-adapter.test.mts new file mode 100644 index 0000000..8314628 --- /dev/null +++ b/test/gateway-adapter.test.mts @@ -0,0 +1,90 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { registerMcpGovernance, defaultImportGateway, makeGovernanceProvider, type GatewayModuleLike } from "../src/governance/gateway-adapter.ts"; + +// The gateway is an unpublished PRIVATE package, linked as a dev dependency +// (file:../armory-gateway). Where it is not resolvable (fresh clone without the +// sibling, CI without the sibling job), the contract tests SKIP LOUDLY — the +// skip message names the exact link command. +let gateway: typeof import("@getpipher/armory-gateway") | null = null; +let gatewayErr: string | null = null; +try { + gateway = await import("@getpipher/armory-gateway"); +} catch (e) { + gatewayErr = (e as Error).message; +} + +const STORE_SYMBOL = Symbol.for("@getpipher/armory-gateway:registry"); +function governanceSlot(): unknown { + const host = globalThis as Record; + const store = host[STORE_SYMBOL] as { governance?: unknown } | undefined; + return store?.governance; +} + +test("contract: registration fires against the REAL gateway module (symbol store reflects it)", async (t) => { + if (!gateway) return t.skip(`gateway not linked — run: pnpm add -D file:../armory-gateway (${gatewayErr})`); + const before = governanceSlot(); + const res = await registerMcpGovernance({ loadDenyList: () => undefined, importGateway: defaultImportGateway }); + assert.equal(res.registered, true); + assert.ok(governanceSlot(), "governance slot must be set after registration"); + assert.notEqual(governanceSlot(), before ?? null); +}); + +test("contract: registered provider denies a deny-listed call with the exact reason", async (t) => { + if (!gateway) return t.skip(`gateway not linked — run: pnpm add -D file:../armory-gateway (${gatewayErr})`); + let denyList: string[] | undefined = ["github__delete_repo"]; + await registerMcpGovernance({ + loadDenyList: () => denyList, + importGateway: defaultImportGateway, + }); + const provider = governanceSlot() as (input: { server: string; tool: string }) => Promise<{ decision: string; reason?: string }>; + const denied = await provider({ server: "github", tool: "delete_repo" }); + assert.equal(denied.decision, "deny"); + assert.equal(denied.reason, 'denied by armory-fleet mcpDeny policy: matched "github__delete_repo"'); +}); + +test("contract: per-call freshness — policy edits flip decisions without re-registration", async (t) => { + if (!gateway) return t.skip(`gateway not linked — run: pnpm add -D file:../armory-gateway (${gatewayErr})`); + let denyList: string[] | undefined = undefined; + await registerMcpGovernance({ loadDenyList: () => denyList, importGateway: defaultImportGateway }); + const provider = governanceSlot() as (input: { server: string; tool: string }) => Promise<{ decision: string }>; + assert.equal((await provider({ server: "github", tool: "push" })).decision, "allow"); + denyList = ["github"]; + assert.equal((await provider({ server: "github", tool: "push" })).decision, "deny"); + denyList = []; + assert.equal((await provider({ server: "github", tool: "push" })).decision, "allow"); +}); + +test("contract: two gateway module instances converge on one symbol store (Symbol.for global)", async (t) => { + if (!gateway) return t.skip(`gateway not linked — run: pnpm add -D file:../armory-gateway (${gatewayErr})`); + // Cache-busting query → DISTINCT resolved URL → a second module INSTANCE. The store + // lives on globalThis[Symbol.for(...)], so a registration made THROUGH the dup + // instance must be visible via the shared symbol key the gateway interceptors read. + const dupSpec = "@getpipher/armory-gateway?dup=1"; + const dup = (await import(dupSpec)) as GatewayModuleLike; + const res = await registerMcpGovernance({ loadDenyList: () => undefined, importGateway: () => Promise.resolve(dup) }); + assert.equal(res.registered, true); + const store = (globalThis as Record)[STORE_SYMBOL] as { governance?: unknown } | undefined; + assert.ok(store?.governance, "registration through a second module instance is visible via the shared symbol store"); +}); + +test("contract: import failure → { registered: false }, no throw, no registration", async () => { + const res = await registerMcpGovernance({ + loadDenyList: () => undefined, + importGateway: () => Promise.reject(new Error("Cannot find package '@getpipher/armory-gateway'")), + }); + assert.deepEqual(res, { registered: false }); +}); + +test("adapter: makeGovernanceProvider passes only server/tool into the matcher (args never enter policy)", async () => { + const seen: Array<{ server: string; tool: string }> = []; + const provider = makeGovernanceProvider({ + loadDenyList: () => undefined, + importGateway: () => Promise.reject(new Error("unused")), + }); + // Local shape check — the provider must not depend on extra input fields. + const decision = await provider({ server: "s", tool: "t", args: { big: "payload" } } as Parameters[0]); + seen.push({ server: "s", tool: "t" }); + assert.equal(decision.decision, "allow"); + assert.equal(seen.length, 1); +}); From 98d855783283d27cc742d11fdda3ce27dcf431eb Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 3 Sep 2026 07:14:23 +0700 Subject: [PATCH 5/7] feat(governance): register MCP governance provider in session_start (reuses session settings store) --- src/index.ts | 12 +++++++++++- test/gateway-adapter.test.mts | 9 +++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index d43eeac..513859b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,7 @@ import { TierRegistry, mergeTiers } from "./tiers/tier-registry.ts"; import { BUILTIN_TIERS } from "./tiers/builtin.ts"; import { TierStore } from "./tiers/tier-store.ts"; import { FleetSettingsStore } from "./settings/fleet-settings.ts"; +import { registerMcpGovernance, defaultImportGateway } from "./governance/gateway-adapter.ts"; import { splitModel } from "./tiers/resolve.ts"; import { WorkflowJournal } from "./workflows/journal.ts"; import { discoverWorkflows, WorkflowRegistry, type WorkflowDef } from "./workflows/registry.ts"; @@ -348,7 +349,7 @@ export default async function (pi: ExtensionAPI): Promise { for (const [name, def] of r.lifecycles) deps.lifecycleRegistry.set(name, def); }; - pi.on("session_start", (_event, ctx) => { + pi.on("session_start", async (_event, ctx) => { refresh(ctx); refreshLifecycles(ctx); const m = ctx.model; @@ -612,6 +613,15 @@ export default async function (pi: ExtensionAPI): Promise { for (const w of fleetSettings.warnings) ctx.ui.notify(w, "warning"); deps.defaultSubagentThinking = fleetSettings.settings.defaultSubagentThinking; + // SPEC-1b-2: register fleet's MCP governance provider with armory-gateway (when the + // unpublished private gateway package is resolvable). Reuses THIS session's settings + // store — per-call fresh reads, cwd-correct project path, idempotent under the + // gateway's replace-semantics. Absent gateway → silent skip (public-npm normal state). + await registerMcpGovernance({ + loadDenyList: () => fleetSettingsStore.load().settings.mcpDeny, + importGateway: defaultImportGateway, + }); + // SPEC-6-3: workflow journal + registry + runner + fleet tool wiring. const workflowJournal = new WorkflowJournal(join(dir, "workflows")); wfRegistry = new WorkflowRegistry(discoverWorkflows({ diff --git a/test/gateway-adapter.test.mts b/test/gateway-adapter.test.mts index 8314628..c5505da 100644 --- a/test/gateway-adapter.test.mts +++ b/test/gateway-adapter.test.mts @@ -88,3 +88,12 @@ test("adapter: makeGovernanceProvider passes only server/tool into the matcher ( assert.equal(decision.decision, "allow"); assert.equal(seen.length, 1); }); + +test("wiring: index.ts session_start registers through the adapter (import smoke + store probe)", async (t) => { + if (!gateway) return t.skip(`gateway not linked — run: pnpm add -D file:../armory-gateway (${gatewayErr})`); + // index.ts must import the ADAPTER statically (never the gateway specifier). + const indexSrc = (await import("node:fs")).readFileSync(new URL("../src/index.ts", import.meta.url), "utf8"); + assert.ok(indexSrc.includes("registerMcpGovernance"), "session_start must call registerMcpGovernance"); + assert.ok(!/import\s+[^;]*from\s+["']@getpipher\/armory-gateway["']/.test(indexSrc), "static gateway import is forbidden"); + assert.ok(indexSrc.includes("loadDenyList"), "provider must close over the settings store via loadDenyList"); +}); From f16269a55e57029dc529a8a6305b4b34b268c063 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 3 Sep 2026 07:20:14 +0700 Subject: [PATCH 6/7] docs: MCP governance section + SPEC/PLAN-1b-2 relocation --- README.md | 25 + docs/PLAN-1b-2-fleet-governance-adapter.md | 703 +++++++++++++++++++++ docs/SPEC-1b-2-fleet-governance-adapter.md | 179 ++++++ 3 files changed, 907 insertions(+) create mode 100644 docs/PLAN-1b-2-fleet-governance-adapter.md create mode 100644 docs/SPEC-1b-2-fleet-governance-adapter.md diff --git a/README.md b/README.md index 1536c13..0f08216 100644 --- a/README.md +++ b/README.md @@ -444,6 +444,31 @@ Small-backlog batch (issues #57/#63/#64/#65): - **Panel Escape semantics documented + dead code removed (#63)** — Escape always cancels the active panel flow; defaults are accepted via Enter-on-blank. (Also fixed: ctrl+c could trigger the never-documented "escape accepts default" callbacks.) - **README example tier names fixed (#65)** — the `ship-feature` example now uses real tier names (`economy`/`standard`). +## MCP governance (armory-gateway integration) + +When [armory-gateway](https://github.com/getpipher/armory-gateway) is resolvable, fleet +registers an MCP governance provider at session start: every MCP call made through the +gateway passes fleet's `mcpDeny` policy before it executes. + +`~/.pi/agent/fleet/settings.json` (global) and `/.pi/fleet/settings.json` (project, +wins per-field): + +```json +{ + "mcpDeny": [ + "github__delete_repo", + "internal-tools" + ] +} +``` + +- Entries: bare `server` (deny the whole server) or `server__tool` (deny one exact tool). +- Invalid entries produce an actionable warning and are dropped; valid entries stay enforced. +- Policy is re-read per call — edits take effect immediately. +- Gateway absent (the default for public fleet installs)? Nothing changes: registration + is skipped silently and fleet behaves exactly as before. Check the gateway's `status` + output — `interceptors governance=✗` means standalone. + ## Dogfood reliability (v0.14.0) Four fixes from dogfooding the fleet on itself (issues #58–#61): diff --git a/docs/PLAN-1b-2-fleet-governance-adapter.md b/docs/PLAN-1b-2-fleet-governance-adapter.md new file mode 100644 index 0000000..aba6482 --- /dev/null +++ b/docs/PLAN-1b-2-fleet-governance-adapter.md @@ -0,0 +1,703 @@ +# armory-fleet 1b-2 — Fleet Governance Adapter — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Register armory-fleet as armory-gateway's first live governance provider (settings-driven `mcpDeny` deny-list) and align fleet's pi dependency line to `^0.84.4`. + +**Architecture:** Pure matcher (`src/governance/mcp-policy.ts`) + thin adapter (`src/governance/gateway-adapter.ts`) that dynamically imports the unpublished gateway package (guarded, never a static specifier) and registers a provider closing over fleet's existing per-session `FleetSettingsStore`. Absent gateway → silent skip, fleet behavior byte-identical to v1.2.0. + +**Tech Stack:** TypeScript raw `.ts` (tsx runtime, jiti in pi's loader), `node:test`, pnpm 10. Consumes `@getpipher/armory-gateway` main @ `410a22c` via dev `file:` link. + +**Spec:** `~/Documents/secret/strategy/getpipher/armory-gateway/SPEC-1b-2-fleet-governance-adapter.md` (RECTOR-approved 2026-08-30). **Plan-phase verification: V1 ✅ V2 ✅ (via vision@0.5.3 rider, merged PR #26) V3 ✅ vacuous V4 ✅ — results baked into the tasks below.** + +**Working repo:** `~/local-dev/getpipher/armory-fleet/` — branch `feat/1b-2-fleet-governance-adapter` off `main` @ `aa0dbef`. + +## Global Constraints + +- Node 24 strip mode: **NO TS parameter properties** (use explicit field declarations + constructor assignment). +- 2-space indent. No AI attribution anywhere. No TODO/FIXME left behind. English only. +- Tests: flat `test/*.test.mts` (the `test:run` glob `test/*.test.mts` is NON-recursive — never create `test/` subdirectories). Tests import source with explicit `.ts` extensions: `import { … } from "../src/governance/mcp-policy.ts"`. +- Gate (every implementer/fixer dispatch, every pre-commit): `pnpm typecheck && pnpm test:run`. +- **This is a fleet-only PR** — zero changes to the armory-gateway repo. +- The string `"@getpipher/armory-gateway"` must never appear in a fleet **static** import (top-level `import … from`); only inside dynamic `import()` calls in `src/governance/gateway-adapter.ts` and the test file. +- Baseline at plan time: main @ `aa0dbef`, `pnpm typecheck` green, `pnpm test:run` 837/837. + +## Verification results (plan-phase, empirical — 2026-08-30/09-02) + +| V | Result | +|---|---| +| V1 link resolvability | ✅ `pnpm add -D file:../armory-gateway` creates a pnpm symlink (virtual store pulls gateway's own deps — `pi-ai@0.84.4` visible in store path). Named exports resolve under plain tsx AND under pi's actual jiti loader (36 exports, `registerGovernanceProvider` function). | +| V2 pi bump | ✅ AFTER the vision rider: pi-ai 0.84 widened `ProviderHeaders` to `Record` and vision@0.5.2 (raw `.ts`, latest) failed fleet typecheck with exactly 2 errors. Fixed upstream in vision PR #26 → published 0.5.3 → fleet range `^0.5.3` → typecheck + 844/844 green. **Fleet also carried a stale pnpm patch for vision@0.5.2 (same null-header fix, superseded by 0.5.3) — `pnpm-workspace.yaml` + `patches/` get deleted in Task 1.** | +| V3 typebox skew | ✅ vacuous — no typebox symptoms surfaced under V2; contract passes plain objects; typebox stays `^1.1.38`. | +| V4 settings wiring | ✅ `FleetSettingsStore` lives at `src/index.ts:607` INSIDE `session_start` (`dir = fleetDir(ctx.cwd)`). Registration wires there; `loadDenyList` reuses the existing `fleetSettingsStore` instance — no second store. | + +## RECTOR gate before merge (Task 4 dependency) + +Gateway repo is **private** — fleet CI cannot clone it token-less (unlike public armory-todo). The committed `file:` devDep makes CI install REQUIRE the sibling. One-time secret setup, RECTOR runs: + +``` +gh secret set SIBLINGS_PAT -R getpipher/armory-fleet --body "$(gh auth token)" +``` + +Until the secret exists, the CI `armory-gate-contracts` job (Task 4) is expected red on install — merge only after it's green. If RECTOR declines the secret, fallback: drop the devDep + CI job from Task 4 and keep the link as an uncommitted local dev step (contract tests then skip in CI — weaker evidence, spec §13 acceptance degrades; RECTOR's call at the plan gate). + +--- + +### Task 1: Dependency alignment + vision patch removal + +**Files:** +- Modify: `package.json` (dependencies block) +- Delete: `pnpm-workspace.yaml`, `patches/@getpipher__vision@0.5.2.patch` + +**Interfaces:** +- Produces: installed `@earendil-works/*` at 0.84.4, `@getpipher/vision` at ^0.5.3, no pnpm patches. Every later task builds on this tree. + +- [ ] **Step 1: Create the branch** + +```bash +cd ~/local-dev/getpipher/armory-fleet +git checkout main && git pull --ff-only +git checkout -b feat/1b-2-fleet-governance-adapter +``` + +- [ ] **Step 2: Edit `package.json` dependencies** — exactly these four lines change: + +```json +"@earendil-works/pi-coding-agent": "^0.84.4", +"@earendil-works/pi-tui": "^0.84.4", +"@earendil-works/pi-ai": "^0.84.4", +"@getpipher/vision": "^0.5.3", +``` + +(All other deps, placement in `dependencies`, peer map `{}` — unchanged. No `version` bump.) + +- [ ] **Step 3: Delete the superseded vision patch** + +```bash +rm pnpm-workspace.yaml +rm patches/@getpipher__vision@0.5.2.patch +rmdir patches +``` + +`pnpm-workspace.yaml` contains ONLY the `patchedDependencies` block (verified — deleting the file is the clean removal; fleet is not a pnpm workspace). + +- [ ] **Step 4: Install and run the gate** + +```bash +pnpm install +pnpm typecheck && pnpm test:run +``` + +Expected: install clean (no `ERR_PNPM_UNUSED_PATCH`), typecheck green, 837+/837+ tests pass (count may differ ± a few from baseline 837 due to vision 0.5.3 internals — zero FAILURES is the criterion; the V2 verification run showed 844/844). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "chore(deps): align @earendil-works/* to ^0.84.4; vision ^0.5.3; drop superseded vision patch" +``` + +--- + +### Task 2: `mcpDeny` settings field + +**Files:** +- Modify: `src/settings/fleet-settings.ts` (interface + `parseFleetSettings` + known-set) +- Test: `test/fleet-settings.test.mts` (extend existing) + +**Interfaces:** +- Produces: `FleetSettings.mcpDeny?: string[]` on the parsed-settings type; parse semantics (per-entry warn+drop) that Task 3's matcher and Task 4's provider rely on. Test fixtures in this task reuse `parseFleetSettings(json: string, label?: string): FleetSettingsResult` (existing export). + +- [ ] **Step 1: Write the failing tests** — append to `test/fleet-settings.test.mts` (the file already imports `test`, `strictEqual`, `deepStrictEqual`, `ok`, `mkdtempSync`, `mkdirSync`, `rmSync`, `writeFileSync`, `tmpdir`, `join`, `parseFleetSettings`, `FleetSettingsStore` — use those; add NO new imports): + +```ts + test("parseFleetSettings: mcpDeny valid entries parse through (bare server + server__tool)", () => { + const r = parseFleetSettings(JSON.stringify({ mcpDeny: ["github", "github__delete_repo", "internal-tools"] })); + deepStrictEqual(r.settings.mcpDeny, ["github", "github__delete_repo", "internal-tools"]); + deepStrictEqual(r.warnings, []); +}); + +test("parseFleetSettings: mcpDeny invalid entries warn + drop per-entry, valid entries stay enforced", () => { + const r = parseFleetSettings( + JSON.stringify({ mcpDeny: ["github__delete_repo", "", "a__", "__b", "server__*", 42] }), + "settings.json", + ); + deepStrictEqual(r.settings.mcpDeny, ["github__delete_repo"]); + strictEqual(r.warnings.length, 5); + for (const w of r.warnings) { + ok(w.startsWith("settings.json: mcpDeny entry"), `actionable + file-labeled: ${w}`); + } +}); + +test("parseFleetSettings: mcpDeny non-array value warns + field dropped", () => { + const r = parseFleetSettings(JSON.stringify({ mcpDeny: "github" }), "settings.json"); + strictEqual(r.settings.mcpDeny, undefined); + strictEqual(r.warnings.length, 1); + ok(r.warnings[0]!.includes("mcpDeny must be an array of strings")); +}); + +test("parseFleetSettings: mcpDeny empty array is valid (explicit no-op list)", () => { + const r = parseFleetSettings(JSON.stringify({ mcpDeny: [] })); + deepStrictEqual(r.settings.mcpDeny, []); + deepStrictEqual(r.warnings, []); +}); + +test("parseFleetSettings: mcpDeny itself is a known key; sibling typos still warn", () => { + const r = parseFleetSettings( + JSON.stringify({ mcpDeny: ["github"], mcpDenyTypo: ["x"] }), + "settings.json", + ); + strictEqual(r.warnings.length, 1); + ok(r.warnings[0]!.includes('unknown setting "mcpDenyTypo"')); +}); + +test("store.load: mcpDeny project wins per-field over global (whole-field replacement)", () => { + const dir = mkdtempSync(join(tmpdir(), "fleet-settings-")); + try { + const g = join(dir, "global.json"); + const p = join(dir, "project.json"); + writeFileSync(g, JSON.stringify({ mcpDeny: ["global-only"] })); + writeFileSync(p, JSON.stringify({ mcpDeny: ["project-only"] })); + const store = new FleetSettingsStore({ globalPath: g, projectPath: p }); + deepStrictEqual(store.load().settings.mcpDeny, ["project-only"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 2: Run to verify RED** + +```bash +pnpm test:run 2>&1 | grep -E "mcpDeny|ℹ (pass|fail)" +``` + +Expected: the new tests FAIL (`settings.mcpDeny` undefined / TS errors on the fixture literals). Note the failure lines as RED evidence. + +- [ ] **Step 3: Implement** — in `src/settings/fleet-settings.ts`: + +(a) Extend the interface (keep the existing field + comment untouched): + +```ts +/** SPEC-1b-2: MCP governance deny-list. Entries are bare server names (deny the whole + * server) or `server__tool` (deny one exact tool). Invalid entries warn + drop. */ +mcpDeny?: string[]; +``` + +(b) Add the entry validator above `parseFleetSettings`: + +```ts +/** SPEC-1b-2: a valid mcpDeny entry is a non-empty bare server name (no `__`) or + * `server__tool` with BOTH parts non-empty. First `__` separates; a tool name + * containing `__` is allowed (gateway composes the same way). Globs are invalid — + * glob metacharacters (`*?[`) are rejected wherever they appear. */ +function isValidMcpDenyEntry(entry: string): boolean { + if (entry.length === 0) return false; + if (/[*?\[]/.test(entry)) return false; + const idx = entry.indexOf("__"); + if (idx === -1) return true; + return idx > 0 && idx + 2 < entry.length; +} +``` + +(c) Inside `parseFleetSettings`, after the `defaultSubagentThinking` block: + +```ts +const deny = obj["mcpDeny"]; +if (deny !== undefined) { + if (Array.isArray(deny)) { + const entries: string[] = []; + for (const item of deny) { + if (typeof item !== "string") { + warnings.push(`${label}: mcpDeny entry ${JSON.stringify(item)} is invalid — expected "server" or "server__tool" — dropped`); + continue; + } + if (isValidMcpDenyEntry(item)) { + entries.push(item); + } else { + warnings.push(`${label}: mcpDeny entry ${JSON.stringify(item)} is invalid — expected "server" or "server__tool" — dropped`); + } + } + settings.mcpDeny = entries; + } else { + warnings.push(`${label}: mcpDeny must be an array of strings — dropped`); + } +} +``` + +(d) Update the known-set line: + +```ts +const known = new Set(["defaultSubagentThinking", "mcpDeny"]); +``` + +- [ ] **Step 4: Run to verify GREEN** + +```bash +pnpm typecheck && pnpm test:run 2>&1 | grep -E "ℹ (pass|fail)" +``` + +Expected: typecheck green, 0 failures. + +- [ ] **Step 5: Commit** + +```bash +git add src/settings/fleet-settings.ts test/fleet-settings.test.mts +git commit -m "feat(settings): mcpDeny deny-list field (per-entry validation, warn+drop)" +``` + +--- + +### Task 3: Pure policy matcher + +**Files:** +- Create: `src/governance/mcp-policy.ts` +- Test: `test/mcp-policy.test.mts` + +**Interfaces:** +- Consumes: nothing (pure, leaf). +- Produces: `evaluateMcpPolicy(deny: readonly string[] | undefined, target: { server: string; tool: string }): { decision: "allow" } | { decision: "deny"; reason: string }` — Task 4's provider calls exactly this. + +- [ ] **Step 1: Write the failing tests** — create `test/mcp-policy.test.mts`: + +```ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { evaluateMcpPolicy } from "../src/governance/mcp-policy.ts"; + +test("matcher: exact tool deny wins with the matched entry in the reason", () => { + const d = evaluateMcpPolicy(["github", "github__delete_repo"], { server: "github", tool: "delete_repo" }); + assert.deepEqual(d, { decision: "deny", reason: 'denied by armory-fleet mcpDeny policy: matched "github__delete_repo"' }); +}); + +test("matcher: bare server entry denies every tool on that server", () => { + for (const tool of ["delete_repo", "create_issue", "anything"]) { + const d = evaluateMcpPolicy(["github"], { server: "github", tool }); + assert.equal(d.decision, "deny"); + } +}); + +test("matcher: non-listed server+tool allows", () => { + const d = evaluateMcpPolicy(["github", "github__delete_repo"], { server: "slack", tool: "post_message" }); + assert.deepEqual(d, { decision: "allow" }); +}); + +test("matcher: absent / undefined / empty deny list allows", () => { + assert.deepEqual(evaluateMcpPolicy(undefined, { server: "github", tool: "delete_repo" }), { decision: "allow" }); + assert.deepEqual(evaluateMcpPolicy([], { server: "github", tool: "delete_repo" }), { decision: "allow" }); +}); + +test("matcher: malformed entries cannot match (exact lookups only, no entry parsing)", () => { + const d = evaluateMcpPolicy(["", "a__", "__b", "server__*"], { server: "a", tool: "b" }); + assert.deepEqual(d, { decision: "allow" }); +}); + +test("matcher: does not mutate or read beyond server/tool (args ignored by contract)", () => { + const target = { server: "github", tool: "delete_repo" }; + const d = evaluateMcpPolicy(["github__delete_repo"], target); + assert.equal(d.decision, "deny"); +}); +``` + +- [ ] **Step 2: Run to verify RED** + +```bash +pnpm test:run 2>&1 | grep -E "mcp-policy|ℹ fail" +``` + +Expected: FAIL — `Cannot find module '../src/governance/mcp-policy.ts'`. + +- [ ] **Step 3: Implement** — create `src/governance/mcp-policy.ts`: + +```ts +// src/governance/mcp-policy.ts — pure MCP deny-list matcher (SPEC-1b-2 D3). +// Total function: no I/O, no throwing. Entries are validated at parse time +// (fleet-settings.ts); the matcher performs exact-match lookups only and +// never parses entries at match time. + +export interface McpPolicyTarget { + server: string; + tool: string; +} + +export type McpPolicyDecision = + | { decision: "allow" } + | { decision: "deny"; reason: string }; + +export function evaluateMcpPolicy(deny: readonly string[] | undefined, target: McpPolicyTarget): McpPolicyDecision { + if (!deny || deny.length === 0) return { decision: "allow" }; + const composed = `${target.server}__${target.tool}`; + // Exact-tool entry takes precedence when both forms are listed — the more specific + // reason is strictly more informative; the decision is deny either way. + const entry = deny.find((candidate) => candidate === composed) ?? deny.find((candidate) => candidate === target.server); + if (entry === undefined) return { decision: "allow" }; + return { decision: "deny", reason: `denied by armory-fleet mcpDeny policy: matched "${entry}"` }; +} +``` + +- [ ] **Step 4: Run to verify GREEN** + +```bash +pnpm typecheck && pnpm test:run 2>&1 | grep -E "ℹ (pass|fail)" +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/governance/mcp-policy.ts test/mcp-policy.test.mts +git commit -m "feat(governance): pure mcpDeny matcher (exact server / server__tool lookups)" +``` + +--- + +### Task 4: Gateway adapter + contract tests + dev link + CI sibling job + +**Files:** +- Create: `src/governance/gateway-adapter.ts` +- Test: `test/gateway-adapter.test.mts` +- Modify: `package.json` (devDependencies), `.github/workflows/ci.yml` + +**Interfaces:** +- Consumes: `evaluateMcpPolicy` (Task 3, exact signature above); real `@getpipher/armory-gateway` module via dev link (`registerGovernanceProvider(fn)` where `fn({server, tool, args, agent?, task?}) → Promise<{decision, reason?}>`). +- Produces: `registerMcpGovernance(deps: { loadDenyList: () => string[] | undefined; importGateway: () => Promise }): Promise<{ registered: boolean }>` and `defaultImportGateway(): Promise` — Task 5 wires exactly these. + +- [ ] **Step 1: Add the dev link** (verified in V1 — pnpm symlinks the sibling and resolves gateway's own deps): + +```bash +pnpm add -D file:../armory-gateway +``` + +Expected: `"@getpipher/armory-gateway": "file:../armory-gateway"` appears in devDependencies. The sibling must exist (it does locally at `~/local-dev/getpipher/armory-gateway/`). + +- [ ] **Step 2: Write the failing contract tests** — create `test/gateway-adapter.test.mts`: + +```ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { registerMcpGovernance, defaultImportGateway, makeGovernanceProvider, type GatewayModuleLike } from "../src/governance/gateway-adapter.ts"; + +// The gateway is an unpublished PRIVATE package, linked as a dev dependency +// (file:../armory-gateway). Where it is not resolvable (fresh clone without the +// sibling, CI without the sibling job), the contract tests SKIP LOUDLY — the +// skip message names the exact link command. +let gateway: typeof import("@getpipher/armory-gateway") | null = null; +let gatewayErr: string | null = null; +try { + gateway = await import("@getpipher/armory-gateway"); +} catch (e) { + gatewayErr = (e as Error).message; +} + +const STORE_SYMBOL = Symbol.for("@getpipher/armory-gateway:registry"); +function governanceSlot(): unknown { + const host = globalThis as Record; + const store = host[STORE_SYMBOL] as { governance?: unknown } | undefined; + return store?.governance; +} + +test("contract: registration fires against the REAL gateway module (symbol store reflects it)", async (t) => { + if (!gateway) return t.skip(`gateway not linked — run: pnpm add -D file:../armory-gateway (${gatewayErr})`); + const before = governanceSlot(); + const res = await registerMcpGovernance({ loadDenyList: () => undefined, importGateway: defaultImportGateway }); + assert.equal(res.registered, true); + assert.ok(governanceSlot(), "governance slot must be set after registration"); + assert.notEqual(governanceSlot(), before ?? null); +}); + +test("contract: registered provider denies a deny-listed call with the exact reason", async (t) => { + if (!gateway) return t.skip(`gateway not linked — run: pnpm add -D file:../armory-gateway (${gatewayErr})`); + let denyList: string[] | undefined = ["github__delete_repo"]; + await registerMcpGovernance({ + loadDenyList: () => denyList, + importGateway: defaultImportGateway, + }); + const provider = governanceSlot() as (input: { server: string; tool: string }) => Promise<{ decision: string; reason?: string }>; + const denied = await provider({ server: "github", tool: "delete_repo" }); + assert.equal(denied.decision, "deny"); + assert.equal(denied.reason, 'denied by armory-fleet mcpDeny policy: matched "github__delete_repo"'); +}); + +test("contract: per-call freshness — policy edits flip decisions without re-registration", async (t) => { + if (!gateway) return t.skip(`gateway not linked — run: pnpm add -D file:../armory-gateway (${gatewayErr})`); + let denyList: string[] | undefined = undefined; + await registerMcpGovernance({ loadDenyList: () => denyList, importGateway: defaultImportGateway }); + const provider = governanceSlot() as (input: { server: string; tool: string }) => Promise<{ decision: string }>; + assert.equal((await provider({ server: "github", tool: "push" })).decision, "allow"); + denyList = ["github"]; + assert.equal((await provider({ server: "github", tool: "push" })).decision, "deny"); + denyList = []; + assert.equal((await provider({ server: "github", tool: "push" })).decision, "allow"); +}); + +test("contract: two gateway module instances converge on one symbol store (Symbol.for global)", async (t) => { + if (!gateway) return t.skip(`gateway not linked — run: pnpm add -D file:../armory-gateway (${gatewayErr})`); + // Cache-busting query → DISTINCT resolved URL → a second module INSTANCE. The store + // lives on globalThis[Symbol.for(...)], so a registration made THROUGH the dup + // instance must be visible via the shared symbol key the gateway interceptors read. + const dupSpec = "@getpipher/armory-gateway?dup=1"; + const dup = (await import(dupSpec)) as GatewayModuleLike; + const res = await registerMcpGovernance({ loadDenyList: () => undefined, importGateway: () => Promise.resolve(dup) }); + assert.equal(res.registered, true); + const store = (globalThis as Record)[STORE_SYMBOL] as { governance?: unknown } | undefined; + assert.ok(store?.governance, "registration through a second module instance is visible via the shared symbol store"); +}); + +test("contract: import failure → { registered: false }, no throw, no registration", async () => { + const res = await registerMcpGovernance({ + loadDenyList: () => undefined, + importGateway: () => Promise.reject(new Error("Cannot find package '@getpipher/armory-gateway'")), + }); + assert.deepEqual(res, { registered: false }); +}); + +test("adapter: makeGovernanceProvider passes only server/tool into the matcher (args never enter policy)", async () => { + const seen: Array<{ server: string; tool: string }> = []; + const provider = makeGovernanceProvider({ + loadDenyList: () => undefined, + importGateway: () => Promise.reject(new Error("unused")), + }); + // Local shape check — the provider must not depend on extra input fields. + const decision = await provider({ server: "s", tool: "t", args: { big: "payload" } } as Parameters[0]); + seen.push({ server: "s", tool: "t" }); + assert.equal(decision.decision, "allow"); + assert.equal(seen.length, 1); +}); +``` + +- [ ] **Step 3: Run to verify RED** + +```bash +pnpm test:run 2>&1 | grep -E "gateway-adapter|ℹ fail" +``` + +Expected: FAIL — `Cannot find module '../src/governance/gateway-adapter.ts'`. + +- [ ] **Step 4: Implement** — create `src/governance/gateway-adapter.ts`: + +```ts +// src/governance/gateway-adapter.ts — registers fleet's MCP governance provider with +// armory-gateway when the gateway package is resolvable (SPEC-1b-2 D4). +// +// The import is DYNAMIC and GUARDED: fleet is a public npm package; armory-gateway is +// unpublished/private, so the specifier must NEVER appear in a static import (a static +// specifier would break every public fleet install at link time). Absent gateway → +// { registered: false } — standalone behavior, byte-identical to fleet v1.2.0. +// Gateway's `status` interceptor line is the observability surface for "is the moat on". + +import { evaluateMcpPolicy } from "./mcp-policy.ts"; + +/** Structural mirror of armory-gateway's GovernanceInput/GovernanceResult (SPEC-1b §3). + * Shapes MUST stay assignment-compatible with the real module (required `args`, full + * decision union) so the real import satisfies GatewayModuleLike under + * strictFunctionTypes. This local mirror keeps the adapter typecheckable even in + * checkouts where the gateway dev link is absent. */ +export interface GovernanceInputLike { + server: string; + tool: string; + args: Record; + agent?: string; + task?: string; +} + +export type GovernanceDecisionLike = { decision: "allow" | "deny" | "rate" | "cost" | "prompt"; reason?: string }; + +export type GovernanceProviderLike = (input: GovernanceInputLike) => Promise; + +export interface GatewayModuleLike { + registerGovernanceProvider(fn: GovernanceProviderLike): void; +} + +export interface GatewayAdapterDeps { + /** Fresh per call (SPEC-1b-2 Q5c) — reads through the session's FleetSettingsStore. */ + loadDenyList: () => string[] | undefined; + /** Injectable for tests. Production: defaultImportGateway. */ + importGateway: () => Promise; +} + +export interface GatewayAdapterResult { + registered: boolean; +} + +export function defaultImportGateway(): Promise { + // Dynamic import with a literal specifier — resolved by tsx/jiti/node through the + // dev `file:` link. The cast narrows the 36-export module to the seam we use. + return import("@getpipher/armory-gateway") as unknown as Promise; +} + +export function makeGovernanceProvider(deps: GatewayAdapterDeps): GovernanceProviderLike { + return async (input) => { + // Policy is identity-based (server/tool) — args never enter policy code. + return evaluateMcpPolicy(deps.loadDenyList(), { server: input.server, tool: input.tool }); + }; +} + +export async function registerMcpGovernance(deps: GatewayAdapterDeps): Promise { + let gateway: GatewayModuleLike; + try { + gateway = await deps.importGateway(); + } catch { + // Absent gateway is the NORMAL state for public-npm fleet installs — silent skip. + return { registered: false }; + } + gateway.registerGovernanceProvider(makeGovernanceProvider(deps)); + return { registered: true }; +} +``` + +- [ ] **Step 5: Run to verify GREEN** + +```bash +pnpm typecheck && pnpm test:run 2>&1 | grep -E "ℹ (pass|fail)" +``` + +Expected: typecheck green; contract tests PASS (gateway linked locally); 0 failures. + +- [ ] **Step 6: CI sibling job** — in `.github/workflows/ci.yml`, directly AFTER the armory-todo clone step (same pattern; private repo needs the token): + +```yaml + - name: Clone armory-gateway sibling (contract-test dependency) + run: git clone --depth 1 https://x-access-token:${{ secrets.SIBLINGS_PAT }}@github.com/getpipher/armory-gateway.git ../armory-gateway +``` + +(The committed `file:` devDep makes `pnpm install --frozen-lockfile` REQUIRE the sibling in CI — this step satisfies it. RECTOR sets the secret per the plan-header gate; CI is red until then. No `continue-on-error` — a silent red-by-design job violates the no-silent-failure rule.) + +- [ ] **Step 7: Gate + commit** + +```bash +pnpm typecheck && pnpm test:run 2>&1 | grep -E "ℹ (pass|fail)" +git add package.json pnpm-lock.yaml src/governance/gateway-adapter.ts test/gateway-adapter.test.mts .github/workflows/ci.yml +git commit -m "feat(governance): guarded dynamic-import adapter + gateway contract tests (dev file: link, CI sibling job)" +``` + +--- + +### Task 5: session_start wiring + +**Files:** +- Modify: `src/index.ts` (imports + the `session_start` handler, after the `fleetSettingsStore` block at ~line 607) +- Test: `test/gateway-adapter.test.mts` (extend with a wiring-shape test) + +**Interfaces:** +- Consumes: `registerMcpGovernance` / `defaultImportGateway` (Task 4 exact signatures); `fleetSettingsStore` (existing local at the wiring site). + +- [ ] **Step 1: Write the failing test** — append to `test/gateway-adapter.test.mts`: + +```ts +test("wiring: index.ts session_start registers through the adapter (import smoke + store probe)", async (t) => { + if (!gateway) return t.skip(`gateway not linked — run: pnpm add -D file:../armory-gateway (${gatewayErr})`); + // index.ts must import the ADAPTER statically (never the gateway specifier). + const indexSrc = (await import("node:fs")).readFileSync(new URL("../src/index.ts", import.meta.url), "utf8"); + assert.ok(indexSrc.includes("registerMcpGovernance"), "session_start must call registerMcpGovernance"); + assert.ok(!/import\s+[^;]*from\s+["']@getpipher\/armory-gateway["']/.test(indexSrc), "static gateway import is forbidden"); + assert.ok(indexSrc.includes("loadDenyList"), "provider must close over the settings store via loadDenyList"); +}); +``` + +- [ ] **Step 2: Run to verify RED** + +```bash +pnpm test:run 2>&1 | grep -E "wiring:|ℹ fail" +``` + +- [ ] **Step 3: Implement** — in `src/index.ts`: + +(a) Add to the import block (static import of the ADAPTER is fine — the gateway specifier lives only inside its dynamic import): + +```ts +import { registerMcpGovernance, defaultImportGateway } from "./governance/gateway-adapter.ts"; +``` + +(b) Make the `session_start` handler async — change `pi.on("session_start", (_event, ctx) => {` to: + +```ts +pi.on("session_start", async (_event, ctx) => { +``` + +(Extension handlers returning a Promise are awaited by the extension runtime; if typecheck rejects the async handler signature, fall back to `.then()`/`.catch()` chaining on the `registerMcpGovernance` promise — document the choice in the commit body.) + +(c) Directly after `deps.defaultSubagentThinking = fleetSettings.settings.defaultSubagentThinking;` (~line 613): + +```ts +// SPEC-1b-2: register fleet's MCP governance provider with armory-gateway (when the +// unpublished private gateway package is resolvable). Reuses THIS session's settings +// store — per-call fresh reads, cwd-correct project path, idempotent under the +// gateway's replace-semantics. Absent gateway → silent skip (public-npm normal state). +await registerMcpGovernance({ + loadDenyList: () => fleetSettingsStore.load().settings.mcpDeny, + importGateway: defaultImportGateway, +}); +``` + +- [ ] **Step 4: Run to verify GREEN + full gate** + +```bash +pnpm typecheck && pnpm test:run 2>&1 | grep -E "ℹ (pass|fail)" +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/index.ts test/gateway-adapter.test.mts +git commit -m "feat(governance): register MCP governance provider in session_start (reuses session settings store)" +``` + +--- + +### Task 6: README + docs relocation + PR + +**Files:** +- Modify: `README.md` (MCP governance section) +- Create (untracked, staged into the PR): `docs/SPEC-1b-2-fleet-governance-adapter.md`, `docs/PLAN-1b-2-fleet-governance-adapter.md` + +**Interfaces:** +- Consumes: everything shipped in Tasks 1–5. No code changes. + +- [ ] **Step 1: README section** — add after the settings/tiers section (match the existing heading style): + +````markdown +## MCP governance (armory-gateway integration) + +When [armory-gateway](https://github.com/getpipher/armory-gateway) is resolvable, fleet +registers an MCP governance provider at session start: every MCP call made through the +gateway passes fleet's `mcpDeny` policy before it executes. + +`~/.pi/agent/fleet/settings.json` (global) and `/.pi/fleet/settings.json` (project, +wins per-field): + +```json +{ + "mcpDeny": [ + "github__delete_repo", + "internal-tools" + ] +} +``` + +- Entries: bare `server` (deny the whole server) or `server__tool` (deny one exact tool). +- Invalid entries produce an actionable warning and are dropped; valid entries stay enforced. +- Policy is re-read per call — edits take effect immediately. +- Gateway absent (the default for public fleet installs)? Nothing changes: registration + is skipped silently and fleet behaves exactly as before. Check the gateway's `status` + output — `interceptors governance=✗` means standalone. +```` + +No claims beyond wired behavior. No mention of deferred features as if shipped. + +- [ ] **Step 2: Relocate spec + plan into `docs/`** + +```bash +cp ~/Documents/secret/strategy/getpipher/armory-gateway/SPEC-1b-2-fleet-governance-adapter.md docs/ +cp ~/Documents/secret/strategy/getpipher/armory-gateway/PLAN-1b-2-fleet-governance-adapter.md docs/ +``` + +- [ ] **Step 3: Full gate + commit** + +```bash +pnpm typecheck && pnpm test:run +git add README.md docs/ +git commit -m "docs: MCP governance section + SPEC/PLAN-1b-2 relocation" +``` + +- [ ] **Step 4: PR** — push, open with `--body-file` (body drafted at execution time from the spec's §13 acceptance + this plan's verification table; dev-humble tone, no AI attribution), merge `gh pr merge N --merge --delete-branch` **only after**: full gate green locally AND the CI job green (which requires RECTOR's `SIBLINGS_PAT` secret — see plan header). + +- [ ] **Step 5: Post-merge pointer** — add one line to armory-gateway's progress table (1b-2 → DONE + PR #) and a pointer note in `SPEC-1b §16` that 1b-2 landed as a fleet PR with ARMORY_*/cost deferral per SPEC-1b-2 §15. Then live smoke (V5, dogfood, not CI): pi session running fleet-from-local + linked gateway → gateway `status` shows `interceptors governance=✓`; a deny-listed call renders `governance_denied`; `mcpDeny` edit takes effect without restart. diff --git a/docs/SPEC-1b-2-fleet-governance-adapter.md b/docs/SPEC-1b-2-fleet-governance-adapter.md new file mode 100644 index 0000000..20f7fde --- /dev/null +++ b/docs/SPEC-1b-2-fleet-governance-adapter.md @@ -0,0 +1,179 @@ +# armory-gateway SPEC-1b-2 — Fleet Governance Adapter (lean & live) + +> **Status:** 🧠 Design (brainstormed 2026-08-30, RECTOR-approved via 5 scope gates). Next gate: RECTOR spec review → writing-plans (plan-phase verification V1–V4 BEFORE freeze) → SDD. +> **Parent:** `SPEC-1b-armory-native-moat.md` §16 (names this slice) + `SPEC-1-design.md` §5 integration model, §10.3 (open question this slice resolves). +> **Changing repo:** `@getpipher/armory-fleet` (v1.2.0, main @ `aa0dbef`) — **this slice is a pure fleet PR; zero gateway code changes.** Consumed surface: `@getpipher/armory-gateway` main @ `410a22c` (unpublished/private; registration API + contract types exported from its index). +> **Relocation:** spec stages here; moves into **fleet's** `docs/` at PR time (with as-built notes). Gateway's progress table gets a pointer line. +> **Process:** identical to prior slices — brainstorm gates ✅ → this spec → writing-plans → SDD (fresh implementer/reviewer per task, final whole-branch review on session model) → PR → `--merge --delete-branch`. + +--- + +## 1. Goal & scope + +Register armory-fleet as the **first live governance provider** against armory-gateway's 1b IoC contract: fleet's extension boots in the parent pi process, registers a `registerGovernanceProvider` implementation backed by a settings-driven deny-list, and fleet's pi dependency line aligns with the gateway suite floor (`^0.84.4`). With gateway absent (the public-npm reality for fleet), behavior is byte-identical to fleet v1.2.0 — silent skip, no registration, standalone degradation. + +This slice **reverses one premise of SPEC-1b §16** (see §3: ARMORY_* env emission is architecturally dead — deferred with rationale, not dropped silently) and **resolves SPEC-1 §10.3** (fleet pi-version alignment). + +| # | Deliverable | Source | +|---|---|---| +| D1 | pi `^0.84.4` alignment (fleet deps bump + gate green) | Q2, SPEC-1 §10.3 | +| D2 | `mcpDeny` settings field (additive, validated, global+project) | Q1, Q5a/b | +| D3 | Pure policy matcher (`src/governance/mcp-policy.ts`) | Q5a | +| D4 | Gateway adapter + boot registration (guarded dynamic import, per-call settings read) — wired in the `session_start` handler, reusing the existing `FleetSettingsStore` | Q1, Q3, Q5c | +| D5 | Fleet README governance section + deferral rationale one-liner | Q1, Q4 | + +**Out of scope (deliberate, named — do not flag as missing):** ARMORY_AGENT_ID/ARMORY_TASK_ID env emission (§15.1 — no live reader); per-call cost sink (§15.2 — no fleet accounting home); per-agent/per-run policy scoping (§15.3 — needs concurrency-safe context story); wildcard patterns in `mcpDeny`; mesh-auth; child MCP access (noExtensions stays); any gateway repo change; typebox bump (unless V2/V3 surfaces skew); dependencies→peerDependencies placement change. + +## 2. Locked decisions (brainstorm 2026-08-30 — do not re-litigate) + +| Q | Decision | +|---|---| +| Q1 slice shape | **A — lean & live**: version alignment + boot-time governance registration (parent process) + minimal settings-driven deny-list. ARMORY_* emission deferred (§15.1). No speculative policy surface (GateRegistry confirmed lifecycle-only). | +| Q2 version strategy | **A — bump to `^0.84.4`**: fleet's three `@earendil-works/*` deps `^0.81.1` → `^0.84.4` in **dependencies, placement unchanged**. Broadening ranges rejected (two test matrices, ratifies skew). Peer placement = own follow-up slice (depends on pi package-manager peer semantics). Folded: typebox `^1.1.38` untouched unless verification flags skew; no in-PR package version bump (release-time `v*` tag per getpipher convention). | +| Q3 package linkage | **A — guarded dynamic import + dev `file:` link + optional-peer-later**: extension boot does `try { const gw = await import("@getpipher/armory-gateway"); gw.registerGovernanceProvider(provider) } catch { /* absent → standalone */ }`. Dev/contract tests use a `file:` devDependency link into the fleet repo (never published as resolvable). When gateway graduates and publishes, fleet adds it as optional peer and the try/catch becomes belt-and-suspenders. Symbol.for direct-store write **rejected** (bypasses the typed seam, forks an undocumented runtime ABI). | +| Q4 sink scope | **A — governance only.** Cost sink = named follow-up gated on fleet growing an accounting home for MCP context-cost (§15.2). SPEC-1 §5's CostMeter→fleet mapping stays aspirational until then. | +| Q5a entry forms | **A — two exact forms**: bare `server` (deny whole server) or `server__tool` (deny one tool). No globs. `server__*` is INVALID (warn+drop). | +| Q5b invalid entries | **A — store precedent**: per-entry validation; invalid entry → actionable warning + drop; valid entries stay enforced. Rationale: `FleetSettingsStore` already established warn-and-drop for bad values (actionable warnings ARE the TierStore-lesson mitigation); shape errors are operator mistakes, not runtime attacks. Gateway's throw→deny fail-closed still guards provider *runtime* failures. Strict deny-all-on-typo rejected (new semantic, turns a typo into full MCP outage). | +| Q5c freshness | **Per-call fresh read**: the provider reads settings on every governed call (two small `readFileSync` — negligible vs MCP call latency). Policy edits take effect immediately, matching the contract's live-lookup philosophy. Boot snapshot rejected (stale security control). | + +## 3. Architecture findings (2026-08-30 survey — the evidence behind Q1/Q4) + +These findings reverse SPEC-1b premises and are recorded here as the durable rationale: + +1. **Fleet's pi children are in-process SDK sessions, not processes.** `createChildSessionFactory` (`src/index.ts`) builds each child with `createAgentSession()` from fleet's own `@earendil-works/pi-coding-agent` copy — same process as the parent. Only claude-backend children are real processes (`claude-factory.ts`), and they run the `claude` CLI, not pi extensions. +2. **`noExtensions: true`** (`src/engine/child-loader.ts:83`) — children load NO extensions: deterministic child, no host-extension leakage. Consequences: (a) gateway never loads in fleet children → children have no MCP tools → **all MCP calls originate in the parent/orchestrator session**; (b) setting `process.env.ARMORY_*` per-spawn would mutate shared parent-process env — racy across concurrent children and would leak scoping into the parent's own gateway calls. +3. **Therefore env-var threading (gateway SPEC-1b §4) has no live reader on any fleet code path** — parent calls are legitimately unscoped (`agent`/`task` = `undefined`, which the contract treats as "no scoping context"); children can neither call MCP nor read env. Emission is deferred, not dead-plain (§15.1 names the revival conditions). +4. **GateRegistry gates are lifecycle-phase constructs** (`GateCtx` = `{phaseRec, spawnRes, lifecycle, …}`) — no per-call policy surface exists in fleet. The adapter's deny-list is the minimal honest policy; a richer per-call policy surface waits for a consumer (YAGNI). +5. **Two SDK copies are structural, not a bug to fix here.** The host runtime (globally installed pi) is never a package dep; fleet's `dependencies` copy resolves separately regardless. Alignment (D1) buys behavioral-skew reduction — above all `SessionManager` file-format compat for resume, since fleet's copy `SessionManager.open()`s files written by the host's copy. +6. **Symbol-store convergence makes the cross-repo import safe under duplicate copies.** Fleet's `import("@getpipher/armory-gateway")` may resolve to a different module instance than the gateway extension's own (dev `file:` link vs the live extension install); `registerGovernanceProvider` writes `globalThis[Symbol.for("@getpipher/armory-gateway:registry")]` — one runtime store regardless (gateway 1b plan-phase V2 proved convergence; fleet contract tests re-pin it). + +## 4. D1 — Version alignment + +- `package.json` dependencies: `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`, `@earendil-works/pi-ai` → `^0.84.4` (matching installed runtime 0.84.4 and gateway's dev floor). +- Placement, peer map (`{}`), and all other deps unchanged. `typebox ^1.1.38` unchanged unless V2/V3 flags skew. +- No `version` bump in the PR (release-time `v*` tag; CI publish per getpipher convention). +- Risk carrier: the 0.81→0.84 jump. V2 (§12) gates the plan on the full fleet suite + gates being green post-bump; any breakage triages as fix-in-slice vs named follow-up — NOT silently pinned around. + +## 5. D2 — `mcpDeny` settings field (`src/settings/fleet-settings.ts`) + +```ts +export interface FleetSettings { + defaultSubagentThinking?: ThinkingLevel; // existing (#78) + /** SPEC-1b-2: MCP governance deny-list. Entries are bare server names (deny the whole + * server) or `server__tool` (deny one exact tool). Invalid entries warn + drop. */ + mcpDeny?: string[]; +} +``` + +- Parse (inside `parseFleetSettings`): absent → `undefined` (normal). Present → must be an array of strings; each entry validated: non-empty, and either contains no `__` (bare server) or contains `__` with non-empty server AND non-empty tool parts (`server__tool`). Invalid entries → actionable warning (`