@bevel-software/platform-core-backend 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/core-ports.d.ts +11 -1
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +62 -13
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +22 -3
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +158 -23
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +34 -1
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +89 -13
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/core-config.d.ts +0 -12
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +11 -13
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +42 -12
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +67 -10
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +214 -35
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-requests.contract.d.ts +75 -0
- package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
- package/dist/modules/access/access-requests.contract.js +20 -0
- package/dist/modules/access/access-requests.contract.js.map +1 -0
- package/dist/modules/access/access-requests.routes.d.ts +35 -0
- package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
- package/dist/modules/access/access-requests.routes.js +237 -0
- package/dist/modules/access/access-requests.routes.js.map +1 -0
- package/dist/modules/access/access-requests.service.d.ts +123 -0
- package/dist/modules/access/access-requests.service.d.ts.map +1 -0
- package/dist/modules/access/access-requests.service.js +337 -0
- package/dist/modules/access/access-requests.service.js.map +1 -0
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +13 -15
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +12 -0
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +24 -0
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/auth/account-admission.d.ts +58 -7
- package/dist/modules/auth/account-admission.d.ts.map +1 -1
- package/dist/modules/auth/account-admission.js +44 -1
- package/dist/modules/auth/account-admission.js.map +1 -1
- package/dist/modules/auth/account.routes.d.ts +1 -1
- package/dist/modules/auth/account.routes.d.ts.map +1 -1
- package/dist/modules/auth/account.routes.js +61 -5
- package/dist/modules/auth/account.routes.js.map +1 -1
- package/dist/modules/auth/auth.middleware.d.ts +1 -1
- package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
- package/dist/modules/auth/auth.middleware.js +18 -7
- package/dist/modules/auth/auth.middleware.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +8 -0
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +96 -3
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +187 -9
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.js +7 -2
- package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +41 -24
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +35 -27
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/github-app/github-app.client.d.ts +101 -0
- package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.client.js +222 -0
- package/dist/modules/github-app/github-app.client.js.map +1 -0
- package/dist/modules/github-app/github-app.connection.d.ts +102 -0
- package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.connection.js +185 -0
- package/dist/modules/github-app/github-app.connection.js.map +1 -0
- package/dist/modules/github-app/github-app.routes.d.ts +59 -0
- package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.routes.js +323 -0
- package/dist/modules/github-app/github-app.routes.js.map +1 -0
- package/dist/modules/github-app/index.d.ts +4 -0
- package/dist/modules/github-app/index.d.ts.map +1 -0
- package/dist/modules/github-app/index.js +4 -0
- package/dist/modules/github-app/index.js.map +1 -0
- package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
- package/dist/modules/kb-fs/branch-name.js +12 -2
- package/dist/modules/kb-fs/branch-name.js.map +1 -1
- package/dist/modules/kb-fs/remote-url.d.ts +22 -0
- package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
- package/dist/modules/kb-fs/remote-url.js +35 -0
- package/dist/modules/kb-fs/remote-url.js.map +1 -0
- package/dist/modules/kb-fs/repo-path.d.ts +11 -16
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
- package/dist/modules/kb-fs/repo-path.js +79 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -1
- package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
- package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
- package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
- package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
- package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
- package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
- package/dist/modules/kb-sync/sync-auth.js +1 -1
- package/dist/modules/kb-sync/sync-auth.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/plugins/join-proposals.d.ts +17 -5
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
- package/dist/modules/plugins/join-proposals.js +76 -22
- package/dist/modules/plugins/join-proposals.js.map +1 -1
- package/dist/modules/plugins/join-requests.service.d.ts +111 -18
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-requests.service.js +173 -29
- package/dist/modules/plugins/join-requests.service.js.map +1 -1
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
- package/dist/modules/plugins/plugins.routes.js +3 -2
- package/dist/modules/plugins/plugins.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +101 -7
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/managed-repository.d.ts +43 -0
- package/dist/modules/settings/managed-repository.d.ts.map +1 -0
- package/dist/modules/settings/managed-repository.js +60 -0
- package/dist/modules/settings/managed-repository.js.map +1 -0
- package/dist/modules/settings/repository-source.d.ts +128 -0
- package/dist/modules/settings/repository-source.d.ts.map +1 -0
- package/dist/modules/settings/repository-source.js +150 -0
- package/dist/modules/settings/repository-source.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +78 -4
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +379 -17
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
- package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.js +24 -63
- package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +90 -18
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +4 -1
- package/dist/modules/skills/skills.contract.js.map +1 -1
- package/dist/modules/skills/skills.service.d.ts +98 -9
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +239 -37
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +7 -0
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +71 -7
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +4 -2
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +15 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.js +25 -4
- package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/tool-helpers/validate-token.js +1 -1
- package/dist/modules/tool-helpers/validate-token.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +113 -37
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +24 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +30 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +72 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +170 -6
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.js +5 -0
- package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +99 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +55 -0
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
- package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
- package/dist/modules/workflow/workflow-hooks.js +16 -1
- package/dist/modules/workflow/workflow-hooks.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +3 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +94 -13
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +258 -51
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
- package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
- package/dist/modules/workspace/agent-access.gate.js +123 -0
- package/dist/modules/workspace/agent-access.gate.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +5 -6
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/session-sink.d.ts +5 -5
- package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
- package/dist/modules/workspace/set-aside-clone.js +92 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +117 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +190 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +92 -3
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +115 -5
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +272 -26
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +2 -2
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +337 -117
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/modules/write-access/write-access.d.ts +60 -0
- package/dist/modules/write-access/write-access.d.ts.map +1 -0
- package/dist/modules/write-access/write-access.js +129 -0
- package/dist/modules/write-access/write-access.js.map +1 -0
- package/dist/shared/domain-errors.d.ts +64 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +83 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/git.contract.d.ts +30 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +26 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/tenancy/static-tenant-source.d.ts +0 -1
- package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
- package/dist/tenancy/static-tenant-source.js +0 -2
- package/dist/tenancy/static-tenant-source.js.map +1 -1
- package/migrations/0014_change_request_closed_reason.sql +1 -0
- package/migrations/0015_account_deactivation.sql +3 -0
- package/migrations/meta/0014_snapshot.json +2265 -0
- package/migrations/meta/0015_snapshot.json +2271 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +3 -3
- package/src/__tests__/kb-layout-config.test.ts +0 -2
- package/src/__tests__/retired-settings.test.ts +96 -0
- package/src/core/__tests__/gated-boot.test.ts +132 -0
- package/src/core/__tests__/lifecycle.test.ts +170 -1
- package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
- package/src/core/core-ports.ts +11 -1
- package/src/core/create-core-server.ts +69 -15
- package/src/core/create-core-services.ts +190 -27
- package/src/core/lifecycle.ts +98 -13
- package/src/core-config.ts +13 -14
- package/src/index.ts +12 -1
- package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
- package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
- package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
- package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
- package/src/modules/access/access-control.interface.ts +43 -12
- package/src/modules/access/access-control.service.ts +234 -36
- package/src/modules/access/access-requests.contract.ts +93 -0
- package/src/modules/access/access-requests.routes.ts +286 -0
- package/src/modules/access/access-requests.service.ts +420 -0
- package/src/modules/access/access.routes.ts +13 -15
- package/src/modules/access-model/access-grammar.ts +22 -0
- package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
- package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
- package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
- package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
- package/src/modules/auth/__tests__/auth.service.test.ts +39 -1
- package/src/modules/auth/account-admission.ts +78 -9
- package/src/modules/auth/account.routes.ts +63 -7
- package/src/modules/auth/auth.middleware.ts +19 -8
- package/src/modules/auth/auth.routes.ts +8 -0
- package/src/modules/auth/auth.service.ts +207 -7
- package/src/modules/auth/oidc-auth-provider.ts +7 -2
- package/src/modules/database/core-schema.ts +35 -27
- package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
- package/src/modules/github-app/github-app.client.ts +268 -0
- package/src/modules/github-app/github-app.connection.ts +204 -0
- package/src/modules/github-app/github-app.routes.ts +359 -0
- package/src/modules/github-app/index.ts +19 -0
- package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
- package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
- package/src/modules/kb-fs/branch-name.ts +14 -1
- package/src/modules/kb-fs/remote-url.ts +35 -0
- package/src/modules/kb-fs/repo-path.ts +85 -0
- package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
- package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
- package/src/modules/kb-sync/sync-auth.ts +2 -2
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
- package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
- package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
- package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
- package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
- package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
- package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
- package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
- package/src/modules/plugins/join-proposals.ts +87 -19
- package/src/modules/plugins/join-requests.service.ts +199 -39
- package/src/modules/plugins/plugins.routes.ts +3 -2
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
- package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
- package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +386 -0
- package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +299 -0
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +236 -0
- package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
- package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
- package/src/modules/settings/deployment-settings.service.ts +117 -6
- package/src/modules/settings/managed-repository.ts +68 -0
- package/src/modules/settings/repository-source.ts +197 -0
- package/src/modules/settings/setup.routes.ts +463 -15
- package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
- package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
- package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
- package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
- package/src/modules/skills/skill-access-requests.routes.ts +25 -75
- package/src/modules/skills/skills.contract.ts +91 -18
- package/src/modules/skills/skills.service.ts +278 -41
- package/src/modules/skills/skills.tools.ts +80 -8
- package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
- package/src/modules/tool-auth/external-api-key.service.ts +4 -2
- package/src/modules/tool-auth/internal-token.service.ts +3 -3
- package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
- package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
- package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
- package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
- package/src/modules/tool-helpers/tool-context.ts +15 -0
- package/src/modules/tool-helpers/tool-handler.ts +24 -4
- package/src/modules/tool-helpers/tool.contract.ts +3 -2
- package/src/modules/tool-helpers/validate-token.ts +1 -1
- package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
- package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
- package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
- package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +405 -7
- package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
- package/src/modules/workflow/agent-tools/workflow.tools.ts +135 -38
- package/src/modules/workflow/file-lock.service.ts +31 -0
- package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
- package/src/modules/workflow/git/git.service.ts +193 -4
- package/src/modules/workflow/git/node-git-runner.ts +6 -0
- package/src/modules/workflow/git/pull-request.service.ts +119 -0
- package/src/modules/workflow/pending-commits.service.ts +59 -1
- package/src/modules/workflow/workflow-hooks.ts +64 -26
- package/src/modules/workflow/workflow.routes.ts +3 -1
- package/src/modules/workflow/workflow.service.ts +290 -55
- package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
- package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
- package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
- package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
- package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
- package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +235 -10
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +720 -17
- package/src/modules/workspace/agent-access.gate.ts +164 -0
- package/src/modules/workspace/routine-write-policy.ts +5 -6
- package/src/modules/workspace/session-sink.ts +5 -5
- package/src/modules/workspace/set-aside-clone.ts +96 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +297 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +242 -2
- package/src/modules/workspace/workspace.routes.ts +95 -3
- package/src/modules/workspace/workspace.service.ts +273 -25
- package/src/modules/workspace/workspace.tools.ts +373 -119
- package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
- package/src/modules/write-access/write-access.ts +153 -0
- package/src/shared/domain-errors.ts +89 -0
- package/src/shared/git.contract.ts +37 -0
- package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
- package/src/tenancy/static-tenant-source.ts +0 -3
- package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
- package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
- package/dist/modules/workflow/session-ontology.policy.js +0 -62
- package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
- package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
- package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
- package/dist/modules/workflow/session-ontology.service.js +0 -147
- package/dist/modules/workflow/session-ontology.service.js.map +0 -1
- package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
- package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
- package/dist/modules/workspace/session-ontology.gate.js +0 -161
- package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
- package/dist/shared/kb-layout.d.ts +0 -39
- package/dist/shared/kb-layout.d.ts.map +0 -1
- package/dist/shared/kb-layout.js +0 -103
- package/dist/shared/kb-layout.js.map +0 -1
- package/dist/shared/kb-layout.test.d.ts +0 -2
- package/dist/shared/kb-layout.test.d.ts.map +0 -1
- package/dist/shared/kb-layout.test.js +0 -75
- package/dist/shared/kb-layout.test.js.map +0 -1
- package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
- package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
- package/src/modules/workflow/session-ontology.policy.ts +0 -70
- package/src/modules/workflow/session-ontology.service.ts +0 -183
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
- package/src/modules/workspace/session-ontology.gate.ts +0 -191
- package/src/shared/kb-layout.test.ts +0 -98
- package/src/shared/kb-layout.ts +0 -102
|
@@ -7,17 +7,16 @@ import type { IToolRegistry, JsonSchema } from '../tool-registry/tool.contract.j
|
|
|
7
7
|
import { ToolError, type ToolContext, type ToolHandler } from '../tool-helpers/tool.contract.js';
|
|
8
8
|
import { BRANCH_INPUT, toolDef } from '../tool-helpers/tool-def.js';
|
|
9
9
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
assertShellAllowedWithinOntology,
|
|
13
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
10
|
+
notifyAgentRead,
|
|
11
|
+
assertAgentWriteAllowed,
|
|
14
12
|
SESSION_ID_INPUT,
|
|
15
|
-
type
|
|
16
|
-
} from './
|
|
13
|
+
type AgentAccessGate,
|
|
14
|
+
} from './agent-access.gate.js';
|
|
17
15
|
import type { IRoutineWritePolicy } from './routine-write-policy.js';
|
|
18
16
|
import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
|
|
19
17
|
import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
|
|
20
18
|
import { workspaceIdForBranch } from '../../shared/workspace-id.js';
|
|
19
|
+
import { assertBranchProvided } from '../../shared/domain-errors.js';
|
|
21
20
|
// Leaf-level shared primitive (same exception `workspace.service.ts` already
|
|
22
21
|
// relies on) — not a workflow service, so this stays inside the module boundary.
|
|
23
22
|
import { assertValidBranchName } from '../kb-fs/branch-name.js';
|
|
@@ -435,6 +434,27 @@ const WRITE_MODE_NOTE =
|
|
|
435
434
|
'(creating it if there is nothing), `update` replaces an existing file and refuses a path that does not exist (`missing`). ' +
|
|
436
435
|
'A refused path is left exactly as it was.';
|
|
437
436
|
|
|
437
|
+
/**
|
|
438
|
+
* What an agent needs to know about escape sequences in the content it sends,
|
|
439
|
+
* on the three tools that take content as a JSON string.
|
|
440
|
+
*
|
|
441
|
+
* The three write routes — the MCP endpoint, the `/api/agent/tools/<name>`
|
|
442
|
+
* route and `call_tool_chain` — were measured end to end against raw requests
|
|
443
|
+
* and a byte-level read of the stored file (see
|
|
444
|
+
* `__tests__/escape-sequences.routes.test.ts`): each stores content exactly as
|
|
445
|
+
* the JSON string value decodes ONCE. So when an escape arrives already
|
|
446
|
+
* decoded, the decoding happened in the client that built the request, and no
|
|
447
|
+
* tool here can tell that content from content that was meant to be decoded.
|
|
448
|
+
* Hence a warning rather than a fix, and the pointer to the one route whose
|
|
449
|
+
* payload is bytes rather than a JSON string.
|
|
450
|
+
*/
|
|
451
|
+
const ESCAPE_SEQUENCE_NOTE =
|
|
452
|
+
' Escape sequences: some clients decode them in arguments before sending, so content meant to CONTAIN an escape rather ' +
|
|
453
|
+
'than what it stands for (the six characters backslash, `u`, `0`, `0`, `4`, `1`, say, rather than the letter `A`) can ' +
|
|
454
|
+
'reach this tool already decoded — what arrives is stored byte for byte, so when that distinction matters, verify what ' +
|
|
455
|
+
'landed (`read_file`, or a hash) and send such content through the upload route (`request_upload_token` + `apply_upload` ' +
|
|
456
|
+
'where offered, otherwise Upload in the app), which lands it unchanged.';
|
|
457
|
+
|
|
438
458
|
/** The refusal `create` gives on a path that already holds something. */
|
|
439
459
|
function pathExists(path: string): ToolError {
|
|
440
460
|
return new ToolError(
|
|
@@ -590,7 +610,7 @@ async function grepWalk(
|
|
|
590
610
|
max: number,
|
|
591
611
|
depth: number,
|
|
592
612
|
gate: ReadGate,
|
|
593
|
-
|
|
613
|
+
notifyRead: (path: string) => Promise<void>,
|
|
594
614
|
docs: DocGrepState,
|
|
595
615
|
): Promise<void> {
|
|
596
616
|
if (out.length >= max || depth > 12) return;
|
|
@@ -609,12 +629,12 @@ async function grepWalk(
|
|
|
609
629
|
if (e.type !== 'directory' && isFolderPlaceholder(e.name)) continue;
|
|
610
630
|
const p = dir ? `${dir}/${e.name}` : e.name;
|
|
611
631
|
if (e.type === 'directory') {
|
|
612
|
-
await grepWalk(fs, p, re, out, max, depth + 1, gate,
|
|
632
|
+
await grepWalk(fs, p, re, out, max, depth + 1, gate, notifyRead, docs);
|
|
613
633
|
} else {
|
|
614
|
-
// Opening a file
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
await
|
|
634
|
+
// Opening a file is a read of it, even when the walk started at a root
|
|
635
|
+
// the read hook was already told about — so every file the walk opens
|
|
636
|
+
// reaches the hook by name (closes the read-leak).
|
|
637
|
+
await notifyRead(p);
|
|
618
638
|
// A file the walk cannot read is silently skipped: one unreadable entry
|
|
619
639
|
// must not fail a search over the whole tree.
|
|
620
640
|
try {
|
|
@@ -662,6 +682,20 @@ const BATCH_SAVE_WARNINGS_OUTPUT: JsonSchema = {
|
|
|
662
682
|
items: { type: 'object' },
|
|
663
683
|
};
|
|
664
684
|
|
|
685
|
+
/**
|
|
686
|
+
* The `sessionId` property inside a BUILT tool def's input schema, or
|
|
687
|
+
* `undefined` for a tool that declares none. `toolDef` wraps the flat inputs
|
|
688
|
+
* under `body` and copies the schema it is given, so a note registered after
|
|
689
|
+
* the tools were built has to be written here rather than onto the shared
|
|
690
|
+
* `SESSION_ID_INPUT` constant.
|
|
691
|
+
*/
|
|
692
|
+
function sessionIdInputOf(def: { inputs?: unknown }): { description?: string } | undefined {
|
|
693
|
+
const inputs = def.inputs as
|
|
694
|
+
| { properties?: { body?: { properties?: Record<string, { description?: string }> } } }
|
|
695
|
+
| undefined;
|
|
696
|
+
return inputs?.properties?.body?.properties?.sessionId;
|
|
697
|
+
}
|
|
698
|
+
|
|
665
699
|
/**
|
|
666
700
|
* Workspace domain tools: the file primitives (replacing Mastra's auto-injected
|
|
667
701
|
* Workspace tools) + unzip. Most just re-expose the SAME `LocalFilesystem`
|
|
@@ -680,7 +714,7 @@ export function registerWorkspaceTools(
|
|
|
680
714
|
docExtract: DocExtractService,
|
|
681
715
|
accessControl: IAccessControl,
|
|
682
716
|
kb: KbContext,
|
|
683
|
-
|
|
717
|
+
agentAccessGate: AgentAccessGate,
|
|
684
718
|
writePolicy: IRoutineWritePolicy,
|
|
685
719
|
sessionSink: ISessionSink,
|
|
686
720
|
/**
|
|
@@ -779,7 +813,7 @@ export function registerWorkspaceTools(
|
|
|
779
813
|
* Every spelling first, then the resolved form against the branch's
|
|
780
814
|
* workspace, so a link into the folder is refused the same way. The resolved
|
|
781
815
|
* check only runs on a branch that is already cloned: bootstrapping a clone
|
|
782
|
-
* here would happen before the handler's access and
|
|
816
|
+
* here would happen before the handler's access and agent-access gates. A branch
|
|
783
817
|
* not cloned yet (or that does not resolve) is left to the handler; the
|
|
784
818
|
* filesystem refuses again underneath regardless.
|
|
785
819
|
*/
|
|
@@ -801,7 +835,7 @@ export function registerWorkspaceTools(
|
|
|
801
835
|
* The branch's workspace root, to judge a spelling against what is on disk
|
|
802
836
|
* — or null when there is nothing to judge it against yet. Only a branch
|
|
803
837
|
* ALREADY cloned is used: bootstrapping one here would clone before the
|
|
804
|
-
* handler's access and
|
|
838
|
+
* handler's access and agent-access gates have had their say.
|
|
805
839
|
*/
|
|
806
840
|
const gitCheckRootFor = async (args: Record<string, unknown>, ctx: ToolContext): Promise<string | null> => {
|
|
807
841
|
if (typeof args.branch !== 'string' || args.branch === '') return null;
|
|
@@ -864,6 +898,128 @@ export function registerWorkspaceTools(
|
|
|
864
898
|
return { read, write, download, owner };
|
|
865
899
|
};
|
|
866
900
|
|
|
901
|
+
/** Whether any one of the caller's four verdicts differs between the two sides of a preview. */
|
|
902
|
+
const verbsDiffer = (before: AccessVerbs, after: AccessVerbs): boolean =>
|
|
903
|
+
(Object.keys(before) as (keyof AccessVerbs)[]).some((v) => before[v] !== after[v]);
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Why `copy_file` will not take a folder. One sentence, said by the dry
|
|
907
|
+
* run and by the call itself, so the preflight and the execution never
|
|
908
|
+
* disagree — the rule this whole section is built on.
|
|
909
|
+
*/
|
|
910
|
+
const folderCopyRefusal = (src: string): string =>
|
|
911
|
+
`"${src}" is a folder; copy_file copies one file. Copy its files one by one, or move the folder with move_file.`;
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* The caller's verdicts at `dest` as they will be once `src` has been
|
|
915
|
+
* moved (or, with `sourceRemains`, copied) there — the `after` half of a
|
|
916
|
+
* move's or copy's preview.
|
|
917
|
+
*
|
|
918
|
+
* `accessAt(dest)` is the wrong answer to that question for a folder: the
|
|
919
|
+
* destination on disk has neither the folder nor the `access.md` files it
|
|
920
|
+
* carries, so it describes the destination's PARENT. A rename of a folder
|
|
921
|
+
* that names the caller owner in its own `access.md` therefore warned
|
|
922
|
+
* about losing owner access the move was about to hand straight back, and
|
|
923
|
+
* a warning that is wrong is a warning people learn to click through.
|
|
924
|
+
*
|
|
925
|
+
* Preview only, like everything else in this section: it answers what the
|
|
926
|
+
* caller WILL have, never whether they may do it. The write verdicts that
|
|
927
|
+
* gate the move are `writeBlocked` and the lock gate, both of which read
|
|
928
|
+
* the tree as it is.
|
|
929
|
+
*/
|
|
930
|
+
const accessAfter = async (
|
|
931
|
+
branch: string,
|
|
932
|
+
ctx: ToolContext,
|
|
933
|
+
src: string,
|
|
934
|
+
dest: string,
|
|
935
|
+
opts?: { sourceRemains?: boolean },
|
|
936
|
+
): Promise<AccessVerbs> => {
|
|
937
|
+
const from = toKbRelative(src, kbDirName);
|
|
938
|
+
const to = toKbRelative(dest, kbDirName);
|
|
939
|
+
// Outside the repository there are no rules to carry, and none to land
|
|
940
|
+
// among — the same answer `accessAt` gives for such a path.
|
|
941
|
+
if (from === null || to === null) return accessAt(branch, ctx, dest);
|
|
942
|
+
return accessControl.previewAccessAfterRelocation(
|
|
943
|
+
workspaceIdForBranch(branch),
|
|
944
|
+
ctx.user.email,
|
|
945
|
+
from,
|
|
946
|
+
to,
|
|
947
|
+
opts,
|
|
948
|
+
);
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* What `copy_file`'s dry run answers: the same impact shape `move_file`
|
|
953
|
+
* previews, over a copy's own rules.
|
|
954
|
+
*
|
|
955
|
+
* A copy LEAVES the source where it is, so the rules it carries are
|
|
956
|
+
* duplicated rather than relocated (`sourceRemains`) — otherwise the two
|
|
957
|
+
* previews ask the same question. The order of the refusals is `copy_file`'s
|
|
958
|
+
* own and is load-bearing: the write verdict on the destination outranks
|
|
959
|
+
* "that name is taken", because a caller who may not write a folder must
|
|
960
|
+
* not learn what is in it from a refusal.
|
|
961
|
+
*
|
|
962
|
+
* A folder source is reported as the refusal it is. `copy_file` copies one
|
|
963
|
+
* file; the preview says so rather than promising a copy that would fail,
|
|
964
|
+
* and still answers `access.after` for the folder it was asked about.
|
|
965
|
+
*
|
|
966
|
+
* NOTHING is probed on disk until the write verdict on the destination has
|
|
967
|
+
* been taken — not the destination, and not the source either, which is the
|
|
968
|
+
* order the call itself keeps at length: a caller who may not write there
|
|
969
|
+
* gets the same refusal whether the source is a file, a folder, or missing
|
|
970
|
+
* altogether. Probing the source first put a 404 in front of that 403 and
|
|
971
|
+
* handed a denied caller the source's kind and its file count. So a refused
|
|
972
|
+
* preview answers `allowed: false` with the sentence and no `kind` or
|
|
973
|
+
* `descendants`: those are the half of the impact the caller has to have
|
|
974
|
+
* earned. The two `access` sides are the caller's own four verbs and tell
|
|
975
|
+
* them nothing they could not ask `file_stat` for.
|
|
976
|
+
*/
|
|
977
|
+
const copyImpact = async (branch: string, ctx: ToolContext, src: string, dest: string) => {
|
|
978
|
+
const [before, after, blocked] = await Promise.all([
|
|
979
|
+
accessAt(branch, ctx, src),
|
|
980
|
+
accessAfter(branch, ctx, src, dest, { sourceRemains: true }),
|
|
981
|
+
writeBlocked(branch, ctx, [dest]),
|
|
982
|
+
]);
|
|
983
|
+
const access = { before, after };
|
|
984
|
+
const accessChanges = verbsDiffer(before, after);
|
|
985
|
+
if (blocked.length > 0) {
|
|
986
|
+
return {
|
|
987
|
+
src,
|
|
988
|
+
dest,
|
|
989
|
+
access,
|
|
990
|
+
accessChanges,
|
|
991
|
+
allowed: false,
|
|
992
|
+
reason: `You may not write "${dest}", so the copy cannot run.`,
|
|
993
|
+
dryRun: true,
|
|
994
|
+
copied: false,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
const fs = await ctx.getFilesystem(branch);
|
|
998
|
+
const kind = await kindOf(fs, src);
|
|
999
|
+
if (kind === null) throw notFound(src, 'Nothing to copy');
|
|
1000
|
+
const srcFiles = kind === 'folder' ? (await filesUnder(fs, src)).files : [src];
|
|
1001
|
+
const occupiedBy = await existingAt(await workspaceRoot(branch, ctx), dest);
|
|
1002
|
+
const reason = occupiedBy !== null
|
|
1003
|
+
? entryExistsMessage(occupiedBy, dest)
|
|
1004
|
+
: kind === 'folder'
|
|
1005
|
+
? folderCopyRefusal(src)
|
|
1006
|
+
: undefined;
|
|
1007
|
+
return {
|
|
1008
|
+
src,
|
|
1009
|
+
dest,
|
|
1010
|
+
kind,
|
|
1011
|
+
// The placeholder travels with its folder, but it is never content —
|
|
1012
|
+
// counted as `move_file` counts it.
|
|
1013
|
+
descendants: srcFiles.filter((f) => !isFolderPlaceholder(f)).length,
|
|
1014
|
+
access,
|
|
1015
|
+
accessChanges,
|
|
1016
|
+
allowed: reason === undefined,
|
|
1017
|
+
...(reason !== undefined ? { reason } : {}),
|
|
1018
|
+
dryRun: true,
|
|
1019
|
+
copied: false,
|
|
1020
|
+
};
|
|
1021
|
+
};
|
|
1022
|
+
|
|
867
1023
|
/**
|
|
868
1024
|
* The paths among `paths` the caller may NOT write, judged exactly as the
|
|
869
1025
|
* lock gate judges them (`WorkflowService.acquireLock`): on a protected
|
|
@@ -1171,6 +1327,23 @@ export function registerWorkspaceTools(
|
|
|
1171
1327
|
proposable?: boolean;
|
|
1172
1328
|
/** False for a tool that is not a file tool (the shell), which the content rule does not describe. */
|
|
1173
1329
|
fileTool?: boolean;
|
|
1330
|
+
/**
|
|
1331
|
+
* This tool runs through the agent-access gate, so a call to it reaches
|
|
1332
|
+
* the deployment's read or write hook. Such a tool carries the note the
|
|
1333
|
+
* deployment registered ({@link ToolDescriptionNotes}) at the end of its
|
|
1334
|
+
* description — core registers none, so on a core-only deployment the
|
|
1335
|
+
* flag adds nothing to what the agent reads.
|
|
1336
|
+
*/
|
|
1337
|
+
gated?: boolean;
|
|
1338
|
+
/**
|
|
1339
|
+
* This tool resolves `branch` ITSELF and must not be pre-checked here.
|
|
1340
|
+
* Only `execute_command` sets it: for an internal session that leaves the
|
|
1341
|
+
* argument off, it falls back to the caller's own focused branch (from its
|
|
1342
|
+
* signed token) rather than refusing. It still answers the same
|
|
1343
|
+
* `branch-required` kind when there is nothing to fall back on — see its
|
|
1344
|
+
* handler. Every other tool takes the check below.
|
|
1345
|
+
*/
|
|
1346
|
+
resolvesBranchItself?: boolean;
|
|
1174
1347
|
handler: ToolHandler;
|
|
1175
1348
|
}): void => {
|
|
1176
1349
|
const path = `/api/agent/tools/${spec.name}`;
|
|
@@ -1178,11 +1351,18 @@ export function registerWorkspaceTools(
|
|
|
1178
1351
|
// tool the one content rule, and every tool a permission can refuse the
|
|
1179
1352
|
// proposal route — appended once here so no tool (especially the
|
|
1180
1353
|
// read-only ones a session hits first) can miss them.
|
|
1354
|
+
// Whether a call to this tool MUST name a branch, read off the tool's own
|
|
1355
|
+
// declaration rather than assumed of the family. Every tool mounted here
|
|
1356
|
+
// requires `branch` today; keying on the schema means a tool that declares
|
|
1357
|
+
// it optional (and resolves absence itself, as `list_tool_setup` does on its
|
|
1358
|
+
// own route) is not handed a refusal it never asked for.
|
|
1359
|
+
const requiresBranch = ((spec.inputs as { required?: string[] }).required ?? []).includes('branch');
|
|
1181
1360
|
const describe = (): string =>
|
|
1182
1361
|
(typeof spec.description === 'function' ? spec.description() : spec.description) +
|
|
1183
1362
|
(spec.proposable ? PROPOSAL_ROUTE_NOTE : '') +
|
|
1184
1363
|
(spec.fileTool === false ? '' : CONTENT_RULE) +
|
|
1185
|
-
kbConventionsNote(kb.layout)
|
|
1364
|
+
kbConventionsNote(kb.layout) +
|
|
1365
|
+
(spec.gated ? agentAccessGate.notes.gatedToolNote() : '');
|
|
1186
1366
|
const def = toolDef({
|
|
1187
1367
|
name: spec.name,
|
|
1188
1368
|
description: describe(),
|
|
@@ -1193,16 +1373,32 @@ export function registerWorkspaceTools(
|
|
|
1193
1373
|
});
|
|
1194
1374
|
registry.registerInternalTool(def);
|
|
1195
1375
|
if (!spec.internalOnly) registry.registerExternalTool(def);
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1376
|
+
/**
|
|
1377
|
+
* What the agent reads about this tool, rebuilt from whatever is in effect
|
|
1378
|
+
* NOW: the layout's names and the notes the deployment registered.
|
|
1379
|
+
*
|
|
1380
|
+
* The catalog FOLLOWS the layout. The conventions reminder above names the
|
|
1381
|
+
* guide, and several descriptions name it again as a platform file, so the
|
|
1382
|
+
* save that completes first-run setup — which applies the names the admin
|
|
1383
|
+
* just chose, in that same request, without a restart — must be able to
|
|
1384
|
+
* move the text with them. Rewritten in place: the registry holds this
|
|
1385
|
+
* object, both surfaces hold the same one, and re-registering would be a
|
|
1386
|
+
* duplicate name. The `sessionId` input is rewritten on the DEF rather
|
|
1387
|
+
* than on `SESSION_ID_INPUT`, because `toolDef` copies the schema it is
|
|
1388
|
+
* given.
|
|
1389
|
+
*/
|
|
1390
|
+
const redescribe = (): void => {
|
|
1204
1391
|
def.description = describe();
|
|
1205
|
-
|
|
1392
|
+
const sessionId = sessionIdInputOf(def);
|
|
1393
|
+
if (sessionId) sessionId.description = agentAccessGate.notes.sessionIdDescription();
|
|
1394
|
+
};
|
|
1395
|
+
// Once for a note registered BEFORE the tools were mounted (the `sessionId`
|
|
1396
|
+
// input is copied by `toolDef`, so it carries the bare default until this
|
|
1397
|
+
// runs), and then on every later change: a note may be registered AFTER
|
|
1398
|
+
// the mount, from the tool-surface hook an overlay registers on.
|
|
1399
|
+
redescribe();
|
|
1400
|
+
kb.onLayoutApplied(redescribe);
|
|
1401
|
+
agentAccessGate.notes.onChange(redescribe);
|
|
1206
1402
|
// Internal-only tools (e.g. `execute_command`) keep their route mounted —
|
|
1207
1403
|
// our agent calls it over the same loopback — but gate it to internal-source
|
|
1208
1404
|
// callers so an external connection key can't invoke it by name.
|
|
@@ -1223,6 +1419,16 @@ export function registerWorkspaceTools(
|
|
|
1223
1419
|
// never reached the repository — the whole bug, spelled with a prefix.
|
|
1224
1420
|
toolHandler(
|
|
1225
1421
|
async (args, ctx) => {
|
|
1422
|
+
// FIRST, before the path work and before any handler: every tool
|
|
1423
|
+
// mounted here declares `branch` as a required, non-empty string, and
|
|
1424
|
+
// nothing enforced that, so a call that named none was carried down
|
|
1425
|
+
// until `workspaceIdForBranch` made a workspace directory out of the
|
|
1426
|
+
// missing value. Most of these tools would meet the same refusal one
|
|
1427
|
+
// layer down at `getFilesystem`, but not all of them do — `unzip`
|
|
1428
|
+
// hands `branch` straight to the workspace service by id — so the
|
|
1429
|
+
// check belongs on the mount every one of them shares rather than on
|
|
1430
|
+
// the resolver only some of them reach.
|
|
1431
|
+
if (requiresBranch && !spec.resolvesBranchItself) assertBranchProvided(args.branch);
|
|
1226
1432
|
// BEFORE the normaliser: see `assertToolPathsNotGitInternals`.
|
|
1227
1433
|
if (spec.fileTool !== false) await assertToolPathsNotGitInternals(args, ctx);
|
|
1228
1434
|
const normalized = normalizePathArgs(
|
|
@@ -1262,22 +1468,20 @@ export function registerWorkspaceTools(
|
|
|
1262
1468
|
};
|
|
1263
1469
|
|
|
1264
1470
|
// ── session bootstrap (external agents) ─────────────────────────────────
|
|
1265
|
-
// Every read/write tool below
|
|
1266
|
-
//
|
|
1267
|
-
// agent
|
|
1268
|
-
//
|
|
1269
|
-
// it onto every later
|
|
1471
|
+
// Every read/write tool below takes a `sessionId`: the conversation the
|
|
1472
|
+
// call belongs to, which is what a deployment's hooks scope their rule to.
|
|
1473
|
+
// The in-process agent carries its thread id, but an external agent has no
|
|
1474
|
+
// ambient run id, so this mints one up front (called ONCE); the MCP proxy
|
|
1475
|
+
// then threads it onto every later call via its sessionId-output continuity
|
|
1270
1476
|
// convention. EXTERNAL-ONLY (not registered internal): the in-process agent
|
|
1271
1477
|
// already supplies its session id and ignores any body value.
|
|
1272
1478
|
//
|
|
1273
1479
|
// WHAT the minted id is backed by is the `ISessionSink` port's business
|
|
1274
1480
|
// (session-sink.ts). In the enterprise app it is a REAL chat-thread id, so
|
|
1275
|
-
// the SAME id works end to end:
|
|
1276
|
-
//
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
// deployment (no chat/ask) the default sink mints a bare id, which is all
|
|
1280
|
-
// the ontology gate needs.
|
|
1481
|
+
// the SAME id works end to end: the file tools take it AND `ask` accepts it
|
|
1482
|
+
// (its sessionId IS a chat thread, resolved via getThread), so a run's reads
|
|
1483
|
+
// and its questions are one conversation rather than two. In a core-only
|
|
1484
|
+
// deployment (no chat/ask) the default sink mints a bare id.
|
|
1281
1485
|
//
|
|
1282
1486
|
// The description tells the caller that retrying is safe, and that is a
|
|
1283
1487
|
// property of the sink rather than a promise this route makes on its own:
|
|
@@ -1290,7 +1494,7 @@ export function registerWorkspaceTools(
|
|
|
1290
1494
|
const startSessionDef = toolDef({
|
|
1291
1495
|
name: 'start_session',
|
|
1292
1496
|
description:
|
|
1293
|
-
'Mint the
|
|
1497
|
+
'Mint the id of this conversation, which the KnowledgeBase tools take as `sessionId`. Call this ONCE, at the start of your work and only once per run — minting a new id mid-run starts a second conversation as far as the server is concerned. The id is also a chat session in the app, so you can hand the SAME id to the `ask` tool: your reads and your questions are then one conversation. Pass the returned id explicitly as `sessionId` on every subsequent KnowledgeBase tool call (direct MCP calls and inside `call_tool_chain` alike). RETRYING IS SAFE: a call that fails created nothing, so retry it — there is no half-made session to clean up. If a retry lands after a success you simply hold two independent ids, which is harmless: keep passing the one id you have already used for the rest of the run and ignore the other. Returns `{ sessionId }`.',
|
|
1294
1498
|
path: '/api/agent/tools/start_session',
|
|
1295
1499
|
inputs: { type: 'object', properties: {}, additionalProperties: false },
|
|
1296
1500
|
outputs: {
|
|
@@ -1302,12 +1506,12 @@ export function registerWorkspaceTools(
|
|
|
1302
1506
|
});
|
|
1303
1507
|
registry.registerExternalTool(startSessionDef);
|
|
1304
1508
|
// Mint the session id via the sink and return it (see comment above: one id
|
|
1305
|
-
// spans start_session -> reads -> ask
|
|
1509
|
+
// spans start_session -> reads -> ask).
|
|
1306
1510
|
router.post(
|
|
1307
1511
|
'/agent/tools/start_session',
|
|
1308
1512
|
toolAuth,
|
|
1309
1513
|
// External-only: an internal token already carries its run's sessionId, so
|
|
1310
|
-
// minting a new thread mid-run would
|
|
1514
|
+
// minting a new thread mid-run would split one run in two. Note
|
|
1311
1515
|
// "external" includes the MCP proxy's `externalProxy` loopback tokens
|
|
1312
1516
|
// (OAuth/JWT MCP sessions) — the verifier resolves those to
|
|
1313
1517
|
// `source: 'external'`, and one such session may legitimately mint several
|
|
@@ -1322,9 +1526,9 @@ export function registerWorkspaceTools(
|
|
|
1322
1526
|
// ── reads ──────────────────────────────────────────────────────────────
|
|
1323
1527
|
mount({
|
|
1324
1528
|
name: 'read_file',
|
|
1529
|
+
gated: true,
|
|
1325
1530
|
description:
|
|
1326
|
-
'Read a workspace file as text. Returns `{ path, content }`. Images (.png/.jpg/.jpeg/.gif/.webp) return the IMAGE ITSELF as native MCP image content (plus a one-line text note naming the file), so you can look at the picture — up to 3.5 MB of raw image data; a larger image gets an honest refusal asking for a locally downscaled copy or a smaller export (`.svg` is text and reads as text). Images come back only on a DIRECT call: inside `call_tool_chain` an image read yields an `{ image_omitted, note }` stub instead. Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods) and PDFs return their EXTRACTED text under an honest `[extracted text of …]` header, with `[slide N]`/`[sheet: Name]`/`[page N]` markers — the extraction is READ-ONLY (layout/images omitted; such files cannot be edited as text, only replaced by uploading a new version). Email files (.eml/.msg) return their EXTRACTED text the same way: a `[from]`/`[to]`/`[subject]`/`[date]` header block, the body (plain-text part preferred; an HTML-only body is stripped to text), and an `[attachments]` name list — attachments are listed, never extracted. Other binary files return a one-line description instead of raw bytes. Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref; ignored for an image) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. A spill ref is workspace-independent: `branch` is ignored for it.'
|
|
1327
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
1531
|
+
'Read a workspace file as text. Returns `{ path, content }`. Images (.png/.jpg/.jpeg/.gif/.webp) return the IMAGE ITSELF as native MCP image content (plus a one-line text note naming the file), so you can look at the picture — up to 3.5 MB of raw image data; a larger image gets an honest refusal asking for a locally downscaled copy or a smaller export (`.svg` is text and reads as text). Images come back only on a DIRECT call: inside `call_tool_chain` an image read yields an `{ image_omitted, note }` stub instead. Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods) and PDFs return their EXTRACTED text under an honest `[extracted text of …]` header, with `[slide N]`/`[sheet: Name]`/`[page N]` markers — the extraction is READ-ONLY (layout/images omitted; such files cannot be edited as text, only replaced by uploading a new version). Email files (.eml/.msg) return their EXTRACTED text the same way: a `[from]`/`[to]`/`[subject]`/`[date]` header block, the body (plain-text part preferred; an HTML-only body is stripped to text), and an `[attachments]` name list — attachments are listed, never extracted. Other binary files return a one-line description instead of raw bytes. Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref; ignored for an image) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. A spill ref is workspace-independent: `branch` is ignored for it.',
|
|
1328
1532
|
inputs: {
|
|
1329
1533
|
type: 'object',
|
|
1330
1534
|
properties: {
|
|
@@ -1350,12 +1554,12 @@ export function registerWorkspaceTools(
|
|
|
1350
1554
|
if (spillStore.isSpillRef(p)) {
|
|
1351
1555
|
return { path: p, content: await spillStore.read(p, offset, limit) };
|
|
1352
1556
|
}
|
|
1353
|
-
await
|
|
1557
|
+
await notifyAgentRead(agentAccessGate, ctx, a.branch as string, p);
|
|
1354
1558
|
await assertCanRead(readGateFor(a.branch as string, ctx), p);
|
|
1355
1559
|
const fs = await ctx.getFilesystem(a.branch as string);
|
|
1356
1560
|
// Reading (extraction, image and binary handling included) happens AFTER
|
|
1357
|
-
// the access gate and the
|
|
1358
|
-
//
|
|
1561
|
+
// the access gate and the read hook above — a document read is still a
|
|
1562
|
+
// KB read. ONE registry dispatch picks the reader by
|
|
1359
1563
|
// extension; everything below just maps its ReadResult onto the tool's
|
|
1360
1564
|
// result shape.
|
|
1361
1565
|
const bytes = await orNotFound(p, async () => asBytes(await fs.readFile(p)));
|
|
@@ -1388,9 +1592,9 @@ export function registerWorkspaceTools(
|
|
|
1388
1592
|
|
|
1389
1593
|
mount({
|
|
1390
1594
|
name: 'list_files',
|
|
1595
|
+
gated: true,
|
|
1391
1596
|
description:
|
|
1392
|
-
`List a directory. Returns \`{ path, entries: [{ name, type, size? }] }\`. Omit \`path\` for the workspace root, which holds the repository as the \`${kbDirName}/\` folder: every content path is under it (e.g. \`${kbDirName}/KnowledgeBase\`), and a path given without that prefix is placed under it
|
|
1393
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
1597
|
+
`List a directory. Returns \`{ path, entries: [{ name, type, size? }] }\`. Omit \`path\` for the workspace root, which holds the repository as the \`${kbDirName}/\` folder: every content path is under it (e.g. \`${kbDirName}/KnowledgeBase\`), and a path given without that prefix is placed under it.`,
|
|
1394
1598
|
inputs: {
|
|
1395
1599
|
type: 'object',
|
|
1396
1600
|
properties: {
|
|
@@ -1420,7 +1624,7 @@ export function registerWorkspaceTools(
|
|
|
1420
1624
|
write: false,
|
|
1421
1625
|
handler: async (a, ctx: ToolContext) => {
|
|
1422
1626
|
const dir = (a.path as string) || '';
|
|
1423
|
-
await
|
|
1627
|
+
await notifyAgentRead(agentAccessGate, ctx, a.branch as string, dir);
|
|
1424
1628
|
const fs = await ctx.getFilesystem(a.branch as string);
|
|
1425
1629
|
const entries = withoutPlaceholder((await fs.readdir(dir || '.')) as DirEntry[]);
|
|
1426
1630
|
const filtered = await filterReadableEntries(readGateFor(a.branch as string, ctx), dir, entries);
|
|
@@ -1430,14 +1634,14 @@ export function registerWorkspaceTools(
|
|
|
1430
1634
|
|
|
1431
1635
|
mount({
|
|
1432
1636
|
name: 'file_stat',
|
|
1637
|
+
gated: true,
|
|
1433
1638
|
description: () =>
|
|
1434
1639
|
'Get a file/directory\'s metadata (name, type, size, …) without returning content. A file also reports `contentMode`: `text` (read, write and edit it as text), `document` (read returns an extraction; replace it by upload) or `binary` (bytes: copy, move, delete, or replace by upload), plus `kind` (`text` | `document` | `image` | `binary`), `mime`, `mimeSource` and `textEditable` — decided by the same file readers read_file, grep and the write tools use, so an extensionless text file is `text/plain`.' +
|
|
1435
1640
|
' Every entry also reports what you may DO with it. ' +
|
|
1436
1641
|
`\`managed\` is true for a platform item — a platform file (${platformFileList(kb.layout)}) or a platform folder (the repository root or a reserved root folder such as \`KnowledgeBase/\`); managed items are never movable or deletable through these tools. ` +
|
|
1437
1642
|
'`access: { read, write, download, owner }` is your own verdict under the access rules; pass `explainAccess: true` to learn why, and who else holds each verb. `movable` and `deletable` say whether `move_file` / `delete_file` / `delete_folder` would be allowed for you, judged like their dry runs: not managed, no symbolic link, and on a protected branch you hold write on the item AND on every file under a folder (on a draft branch writes are not gated). `movable` judges the source side only; the destination is judged by a `move_file` dry run. ' +
|
|
1438
1643
|
'For a folder, `descendants` is the number of files under it at any depth; counting stops at 10000 and `descendantsTruncated` says so, and past that point `movable` and `deletable` are false because a folder that large was not judged in full — run the `move_file` or `delete_folder` dry run for the real verdict. ' +
|
|
1439
|
-
'Call this before a move or delete to see what it would touch.'
|
|
1440
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
1644
|
+
'Call this before a move or delete to see what it would touch.',
|
|
1441
1645
|
inputs: {
|
|
1442
1646
|
type: 'object',
|
|
1443
1647
|
properties: {
|
|
@@ -1513,7 +1717,7 @@ export function registerWorkspaceTools(
|
|
|
1513
1717
|
handler: async (a, ctx: ToolContext) => {
|
|
1514
1718
|
const p = a.path as string;
|
|
1515
1719
|
const branch = a.branch as string;
|
|
1516
|
-
await
|
|
1720
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, p);
|
|
1517
1721
|
await assertCanRead(readGateFor(branch, ctx), p);
|
|
1518
1722
|
// Nothing there is a 404, and the placeholder — never content — gets
|
|
1519
1723
|
// exactly that answer: the one every file tool gives (see not-found.ts).
|
|
@@ -1614,9 +1818,9 @@ export function registerWorkspaceTools(
|
|
|
1614
1818
|
|
|
1615
1819
|
mount({
|
|
1616
1820
|
name: 'grep',
|
|
1821
|
+
gated: true,
|
|
1617
1822
|
description:
|
|
1618
|
-
'Regex content search across the workspace. Returns `{ matches: [{ path, line, text }] }` (capped). Use to find where something is defined/referenced. `path` may name a DIRECTORY (searches the subtree) or a single FILE (searches just that file); a path with nothing at it is an error, never an empty result. Searches INSIDE Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods), PDFs and email files (.eml/.msg) via their extracted text — matches there carry the extraction\'s line numbers, and the `[slide N]`/`[sheet: Name]`/`[page N]`/`[from]`/`[subject]` marker lines locate them; a bounded number of not-yet-extracted documents is extracted per call, and the result notes how many were skipped (re-run to cover them).'
|
|
1619
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
1823
|
+
'Regex content search across the workspace. Returns `{ matches: [{ path, line, text }] }` (capped). Use to find where something is defined/referenced. `path` may name a DIRECTORY (searches the subtree) or a single FILE (searches just that file); a path with nothing at it is an error, never an empty result. Searches INSIDE Office and OpenDocument files (.docx/.pptx/.xlsx, .odt/.odp/.ods), PDFs and email files (.eml/.msg) via their extracted text — matches there carry the extraction\'s line numbers, and the `[slide N]`/`[sheet: Name]`/`[page N]`/`[from]`/`[subject]` marker lines locate them; a bounded number of not-yet-extracted documents is extracted per call, and the result notes how many were skipped (re-run to cover them).',
|
|
1620
1824
|
inputs: {
|
|
1621
1825
|
type: 'object',
|
|
1622
1826
|
properties: {
|
|
@@ -1666,11 +1870,10 @@ export function registerWorkspaceTools(
|
|
|
1666
1870
|
// (an empty path is the handler's to explain), and here it would
|
|
1667
1871
|
// otherwise name the workspace directory by another spelling.
|
|
1668
1872
|
const searchRoot = typeof a.path === 'string' && a.path.length > 0 ? a.path : kbDirName;
|
|
1669
|
-
// The search root itself
|
|
1670
|
-
//
|
|
1671
|
-
//
|
|
1672
|
-
|
|
1673
|
-
await recordOntologyRead(sessionOntologyGate, ctx, searchRoot);
|
|
1873
|
+
// The search root itself goes to the read hook here; each file the walk
|
|
1874
|
+
// actually opens goes to it per-file below, so a hook sees every path a
|
|
1875
|
+
// grep reached rather than only the root it started from.
|
|
1876
|
+
await notifyAgentRead(agentAccessGate, ctx, a.branch as string, searchRoot);
|
|
1674
1877
|
const fs = await ctx.getFilesystem(a.branch as string);
|
|
1675
1878
|
const gate = readGateFor(a.branch as string, ctx);
|
|
1676
1879
|
const out: { path: string; line: number; text: string }[] = [];
|
|
@@ -1697,7 +1900,7 @@ export function registerWorkspaceTools(
|
|
|
1697
1900
|
max,
|
|
1698
1901
|
0,
|
|
1699
1902
|
gate,
|
|
1700
|
-
(p) =>
|
|
1903
|
+
(p) => notifyAgentRead(agentAccessGate, ctx, a.branch as string, p),
|
|
1701
1904
|
docs,
|
|
1702
1905
|
);
|
|
1703
1906
|
} else {
|
|
@@ -1742,12 +1945,13 @@ export function registerWorkspaceTools(
|
|
|
1742
1945
|
// ── writes (through the lock/commit pipeline) ───────────────────────────
|
|
1743
1946
|
mount({
|
|
1744
1947
|
name: 'write_file',
|
|
1948
|
+
gated: true,
|
|
1745
1949
|
description:
|
|
1746
1950
|
'Write a workspace TEXT file. The change is committed + pushed as you. Returns `{ path, bytes, outcome }`, where `outcome` is ' +
|
|
1747
1951
|
'`created`, `replaced` or `updated`.' +
|
|
1748
1952
|
WRITE_MODE_NOTE +
|
|
1749
1953
|
IMAGE_CONVENTION_NOTE +
|
|
1750
|
-
|
|
1954
|
+
ESCAPE_SEQUENCE_NOTE,
|
|
1751
1955
|
inputs: {
|
|
1752
1956
|
type: 'object',
|
|
1753
1957
|
properties: {
|
|
@@ -1783,7 +1987,7 @@ export function registerWorkspaceTools(
|
|
|
1783
1987
|
// (today only `watchlist_check`, to `.html`). Unrestricted sessions pass straight
|
|
1784
1988
|
// through (see `assertPathWritable`), so it does not limit other agents.
|
|
1785
1989
|
writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
|
|
1786
|
-
await
|
|
1990
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, a.path as string);
|
|
1787
1991
|
const mode = modeOf(a);
|
|
1788
1992
|
const fs = await ctx.getFilesystem(a.branch as string);
|
|
1789
1993
|
await assertNotBinaryOverwrite(readers,a.path as string, fs);
|
|
@@ -1825,18 +2029,19 @@ export function registerWorkspaceTools(
|
|
|
1825
2029
|
|
|
1826
2030
|
mount({
|
|
1827
2031
|
name: 'write_files',
|
|
2032
|
+
gated: true,
|
|
1828
2033
|
description:
|
|
1829
2034
|
'Batch-write many files in ONE commit — far faster than calling write_file once per file when ' +
|
|
1830
2035
|
'creating many files at once (e.g. seeding a knowledge base). Each entry is `{ path, content }`, and the files it ' +
|
|
1831
2036
|
'writes are committed + pushed together as you. Prefer this over many write_file ' +
|
|
1832
|
-
'calls.
|
|
2037
|
+
'calls. Text files only. ' +
|
|
1833
2038
|
'Returns `{ count, files }`: one entry per REQUESTED path, in the order you gave them, each `{ path, outcome }` — ' +
|
|
1834
2039
|
'`created` / `replaced` / `updated` for a path it wrote, or `refused` with `error` (the code) and `message` (why) for a ' +
|
|
1835
2040
|
'path it could not. `count` is how many were written. A path it refuses — the mode said no, or the file is not text — ' +
|
|
1836
2041
|
'does not stop the others; read `files` to see what landed.' +
|
|
1837
2042
|
WRITE_MODE_NOTE +
|
|
1838
2043
|
IMAGE_CONVENTION_NOTE +
|
|
1839
|
-
|
|
2044
|
+
ESCAPE_SEQUENCE_NOTE,
|
|
1840
2045
|
inputs: {
|
|
1841
2046
|
type: 'object',
|
|
1842
2047
|
properties: {
|
|
@@ -1889,13 +2094,13 @@ export function registerWorkspaceTools(
|
|
|
1889
2094
|
const files = (a.files as Array<{ path: string; content: string }>) ?? [];
|
|
1890
2095
|
if (files.length === 0) return { count: 0, files: [] };
|
|
1891
2096
|
const mode = modeOf(a);
|
|
1892
|
-
// The POLICY
|
|
1893
|
-
//
|
|
1894
|
-
//
|
|
1895
|
-
//
|
|
1896
|
-
// path already holds (the mode) is
|
|
2097
|
+
// The POLICY gate still judges the whole batch: a restricted run is a
|
|
2098
|
+
// call that should not have been made at all, not a per-path outcome.
|
|
2099
|
+
// The write hook is asked PER PATH, below, so a path it refuses is that
|
|
2100
|
+
// path's outcome and the rest of the batch still lands. What a single
|
|
2101
|
+
// FILE is (not text) or what its path already holds (the mode) is
|
|
2102
|
+
// decided per path too.
|
|
1897
2103
|
for (const f of files) writePolicy.assertPathWritable(ctx.sessionId, f.path);
|
|
1898
|
-
for (const f of files) await assertOntologyWriteAllowed(sessionOntologyGate, ctx, f.path);
|
|
1899
2104
|
const fs = await ctx.getFilesystem(a.branch as string);
|
|
1900
2105
|
// `write: true` guarantees a LockingFilesystem here; `writeFiles` lands the
|
|
1901
2106
|
// batch as one commit. Structural cast avoids a workflow-internal import.
|
|
@@ -1924,6 +2129,18 @@ export function registerWorkspaceTools(
|
|
|
1924
2129
|
for (const f of files) {
|
|
1925
2130
|
const entry: Record<string, unknown> = { path: f.path };
|
|
1926
2131
|
outcomes.push(entry);
|
|
2132
|
+
try {
|
|
2133
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, f.path);
|
|
2134
|
+
} catch (err) {
|
|
2135
|
+
// A DELIBERATE refusal by the deployment's write hook is this path's
|
|
2136
|
+
// outcome and no more: its message is what the caller is meant to
|
|
2137
|
+
// read, and one refused path must not take the others down. Anything
|
|
2138
|
+
// else the hook throws is not a verdict — it is the gate itself
|
|
2139
|
+
// failing — so `refuse` rethrows it and the whole batch fails loudly,
|
|
2140
|
+
// exactly as it does in `write_file`.
|
|
2141
|
+
refuse(entry, err);
|
|
2142
|
+
continue;
|
|
2143
|
+
}
|
|
1927
2144
|
try {
|
|
1928
2145
|
assertNotDocumentEdit(readers, f.path);
|
|
1929
2146
|
await assertNotBinaryOverwrite(readers, f.path, fs);
|
|
@@ -1991,9 +2208,10 @@ export function registerWorkspaceTools(
|
|
|
1991
2208
|
|
|
1992
2209
|
mount({
|
|
1993
2210
|
name: 'edit_file',
|
|
2211
|
+
gated: true,
|
|
1994
2212
|
description:
|
|
1995
2213
|
'Replace an exact string in a workspace TEXT file. `old_string` must appear exactly once unless `replace_all`. Committed + pushed as you.' +
|
|
1996
|
-
|
|
2214
|
+
ESCAPE_SEQUENCE_NOTE,
|
|
1997
2215
|
inputs: {
|
|
1998
2216
|
type: 'object',
|
|
1999
2217
|
properties: {
|
|
@@ -2017,7 +2235,7 @@ export function registerWorkspaceTools(
|
|
|
2017
2235
|
handler: async (a, ctx: ToolContext) => {
|
|
2018
2236
|
assertNotDocumentEdit(readers, a.path as string);
|
|
2019
2237
|
writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
|
|
2020
|
-
await
|
|
2238
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, a.path as string);
|
|
2021
2239
|
const fs = await ctx.getFilesystem(a.branch as string);
|
|
2022
2240
|
const path = a.path as string;
|
|
2023
2241
|
const oldStr = a.old_string as string;
|
|
@@ -2041,10 +2259,10 @@ export function registerWorkspaceTools(
|
|
|
2041
2259
|
|
|
2042
2260
|
mount({
|
|
2043
2261
|
name: 'delete_file',
|
|
2262
|
+
gated: true,
|
|
2044
2263
|
description: () =>
|
|
2045
2264
|
'Delete ONE workspace file (a symbolic link is refused: links are never followed or removed). Committed + pushed as you. Its folder stays, even when this was its last file. Files only: a folder is refused with a pointer to `delete_folder`. ' +
|
|
2046
|
-
`A platform file (\`access.md\` or \`.bevelignore\` in any folder, \`roles.yaml\` or \`${kb.layout.agentsFile}\` at the repository root) and git metadata are refused
|
|
2047
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
2265
|
+
`A platform file (\`access.md\` or \`.bevelignore\` in any folder, \`roles.yaml\` or \`${kb.layout.agentsFile}\` at the repository root) and git metadata are refused.`,
|
|
2048
2266
|
inputs: {
|
|
2049
2267
|
type: 'object',
|
|
2050
2268
|
properties: {
|
|
@@ -2063,14 +2281,14 @@ export function registerWorkspaceTools(
|
|
|
2063
2281
|
write: true,
|
|
2064
2282
|
proposable: true,
|
|
2065
2283
|
handler: async (a, ctx: ToolContext) => {
|
|
2066
|
-
// A delete
|
|
2067
|
-
//
|
|
2068
|
-
//
|
|
2069
|
-
//
|
|
2284
|
+
// A delete carries no bytes from anywhere else — it removes a node — so
|
|
2285
|
+
// it goes to the READ hook, like a read, not the write hook. The
|
|
2286
|
+
// extension policy DOES apply though: a dashboard-only run must not
|
|
2287
|
+
// delete graph `.md` nodes.
|
|
2070
2288
|
const path = a.path as string;
|
|
2071
2289
|
const branch = a.branch as string;
|
|
2072
2290
|
writePolicy.assertPathWritable(ctx.sessionId, path);
|
|
2073
|
-
await
|
|
2291
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, path);
|
|
2074
2292
|
const fs = await ctx.getFilesystem(branch);
|
|
2075
2293
|
assertPlainPath(path);
|
|
2076
2294
|
const root = await workspaceRoot(branch, ctx);
|
|
@@ -2094,13 +2312,13 @@ export function registerWorkspaceTools(
|
|
|
2094
2312
|
|
|
2095
2313
|
mount({
|
|
2096
2314
|
name: 'delete_folder',
|
|
2315
|
+
gated: true,
|
|
2097
2316
|
description:
|
|
2098
2317
|
'Delete a workspace FOLDER and every file under it, at any depth; the whole folder lands as ONE committed + pushed change as you — all of it or none of it — then the empty folder is removed. This is the one way a folder goes away: the folder that held it stays, even if this was all it had, and a folder holding nothing but its empty-folder placeholder counts as empty. ' +
|
|
2099
2318
|
'Preflight first: `dryRun: true` changes nothing and answers `{ path, kind: "folder", descendants, files, filesTruncated, allowed, reason? }` — `descendants` is the file count, `files` names up to 100 of them. ' +
|
|
2100
2319
|
'A non-empty folder is deleted only with `confirm: true`; without it the call deletes nothing and returns the same impact with `confirmationRequired: true`. Do NOT set `confirm: true` on your first call — dry-run, check the impact, then confirm. ' +
|
|
2101
2320
|
'Refused (in a dry run as `allowed: false` with the `reason`): a platform folder (the repository root or a reserved root folder such as `KnowledgeBase/`), git metadata, a folder holding a symbolic link (links are never removed), and a folder holding any file you may not write. A path that is a file is refused with a pointer to `delete_file`, and a path through a symbolic link is refused (links are never followed). ' +
|
|
2102
|
-
'The folder\'s own platform files (`access.md`, `.bevelignore`) go with it in that same one change, so its files are never left ungoverned part-way; you must be able to write those platform files too.'
|
|
2103
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
2321
|
+
'The folder\'s own platform files (`access.md`, `.bevelignore`) go with it in that same one change, so its files are never left ungoverned part-way; you must be able to write those platform files too.',
|
|
2104
2322
|
inputs: {
|
|
2105
2323
|
type: 'object',
|
|
2106
2324
|
properties: {
|
|
@@ -2138,7 +2356,7 @@ export function registerWorkspaceTools(
|
|
|
2138
2356
|
// The normaliser has already placed the path inside the repository; this
|
|
2139
2357
|
// is the check that it really is in there before a folder is walked.
|
|
2140
2358
|
assertInsideRepo(path, kbDirName);
|
|
2141
|
-
await
|
|
2359
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, path);
|
|
2142
2360
|
const fs = await ctx.getFilesystem(branch);
|
|
2143
2361
|
const kind = await kindOf(fs, path);
|
|
2144
2362
|
if (kind === null) throw new ToolError(`"${path}" does not exist.`, 404);
|
|
@@ -2233,7 +2451,8 @@ export function registerWorkspaceTools(
|
|
|
2233
2451
|
|
|
2234
2452
|
mount({
|
|
2235
2453
|
name: 'mkdir',
|
|
2236
|
-
|
|
2454
|
+
gated: true,
|
|
2455
|
+
description: 'Create a directory (recursive). It lists as an empty folder and persists in git until it is deleted explicitly.',
|
|
2237
2456
|
inputs: {
|
|
2238
2457
|
type: 'object',
|
|
2239
2458
|
properties: {
|
|
@@ -2253,7 +2472,7 @@ export function registerWorkspaceTools(
|
|
|
2253
2472
|
proposable: true,
|
|
2254
2473
|
handler: async (a, ctx: ToolContext) => {
|
|
2255
2474
|
writePolicy.assertPathWritable(ctx.sessionId, a.path as string);
|
|
2256
|
-
await
|
|
2475
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, a.path as string);
|
|
2257
2476
|
await (await ctx.getFilesystem(a.branch as string)).mkdir(a.path as string, { recursive: true });
|
|
2258
2477
|
return { path: a.path, created: true };
|
|
2259
2478
|
},
|
|
@@ -2261,12 +2480,12 @@ export function registerWorkspaceTools(
|
|
|
2261
2480
|
|
|
2262
2481
|
mount({
|
|
2263
2482
|
name: 'move_file',
|
|
2483
|
+
gated: true,
|
|
2264
2484
|
description: () =>
|
|
2265
2485
|
'Move or rename a workspace FILE or FOLDER; a folder moves recursively, with everything under it. `dest` is the full new path, not the folder to move into. Lands as a delete + create, committed + pushed as you. ' +
|
|
2266
2486
|
`Rules: the destination must not exist — a move never overwrites a file or merges into a folder; a platform file (\`access.md\` or \`.bevelignore\` in any folder, \`roles.yaml\` or \`${kb.layout.agentsFile}\` at the repository root) is refused with "<name> is a platform file and stays in its folder." — a folder that moves takes its own platform files along, still in their folder; a platform folder (the repository root or a reserved root folder such as \`KnowledgeBase/\`) and git metadata are refused; a move cannot create a platform file or folder at \`dest\` either (renaming a note to \`access.md\` is refused); a path through a symbolic link is refused, since links are never followed; on a protected branch you must be able to write both ends — for a folder, every file under it at its old and its new path. ` +
|
|
2267
|
-
'Access follows the destination folder. Preflight first: `dryRun: true` changes nothing and answers `{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }` — `access` is your own `{ read, write, download, owner }` at the source and at the destination. ' +
|
|
2268
|
-
'A move whose `accessChanges` is true runs only with `confirm: true`; without it the call moves nothing and returns the same impact with `confirmationRequired: true`. Do NOT set `confirm: true` on your first call — dry-run, check the impact, then confirm.'
|
|
2269
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
2487
|
+
'Access follows the destination folder. Preflight first: `dryRun: true` changes nothing and answers `{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }` — `access` is your own `{ read, write, download, owner }` at the source and at the destination AS IT WILL BE once the move has landed, with every `access.md` inside a moved folder counted at its new place. ' +
|
|
2488
|
+
'A move whose `accessChanges` is true runs only with `confirm: true`; without it the call moves nothing and returns the same impact with `confirmationRequired: true`. Do NOT set `confirm: true` on your first call — dry-run, check the impact, then confirm.',
|
|
2270
2489
|
inputs: {
|
|
2271
2490
|
type: 'object',
|
|
2272
2491
|
properties: {
|
|
@@ -2287,8 +2506,8 @@ export function registerWorkspaceTools(
|
|
|
2287
2506
|
dest: str('Destination path (echoes the input).'),
|
|
2288
2507
|
kind: str('`file` or `folder`.'),
|
|
2289
2508
|
descendants: int('Files that move: 1 for a file, the file count under a folder.'),
|
|
2290
|
-
access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and destination (`after`).' },
|
|
2291
|
-
accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between
|
|
2509
|
+
access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and at the destination once the move has landed (`after`).' },
|
|
2510
|
+
accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between `before` and `after`.' },
|
|
2292
2511
|
allowed: { type: 'boolean', description: 'Whether the move may run.' },
|
|
2293
2512
|
reason: str('Why it may not, when `allowed` is false.'),
|
|
2294
2513
|
dryRun: { type: 'boolean', description: 'True on a dry run.' },
|
|
@@ -2306,12 +2525,12 @@ export function registerWorkspaceTools(
|
|
|
2306
2525
|
const src = (a.src as string).replace(/\/+$/, '');
|
|
2307
2526
|
const dest = (a.dest as string).replace(/\/+$/, '');
|
|
2308
2527
|
const branch = a.branch as string;
|
|
2309
|
-
// A move CARRIES the source content into the destination
|
|
2310
|
-
//
|
|
2311
|
-
//
|
|
2312
|
-
//
|
|
2313
|
-
await
|
|
2314
|
-
await
|
|
2528
|
+
// A move CARRIES the source content into the destination, so BOTH ends
|
|
2529
|
+
// go to the write hook (unlike a plain delete, which moves no content).
|
|
2530
|
+
// Ask about both BEFORE touching disk, so a refused end can't leave the
|
|
2531
|
+
// source already deleted.
|
|
2532
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, src);
|
|
2533
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, dest);
|
|
2315
2534
|
const fs = await ctx.getFilesystem(branch);
|
|
2316
2535
|
const root = await workspaceRoot(branch, ctx);
|
|
2317
2536
|
// Before the kind check, which stats THROUGH a link: a dangling link at
|
|
@@ -2328,8 +2547,13 @@ export function registerWorkspaceTools(
|
|
|
2328
2547
|
writePolicy.assertPathWritable(ctx.sessionId, dest + f.slice(src.length));
|
|
2329
2548
|
}
|
|
2330
2549
|
|
|
2331
|
-
|
|
2332
|
-
|
|
2550
|
+
// `after` is the destination as it WILL be — with the `access.md` files
|
|
2551
|
+
// under `src` counted where they land. See `accessAfter`.
|
|
2552
|
+
const [before, after] = await Promise.all([
|
|
2553
|
+
accessAt(branch, ctx, src),
|
|
2554
|
+
accessAfter(branch, ctx, src, dest),
|
|
2555
|
+
]);
|
|
2556
|
+
const accessChanges = verbsDiffer(before, after);
|
|
2333
2557
|
// The placeholder moves with its folder, but it is never content.
|
|
2334
2558
|
const descendants = srcFiles.filter((f) => !isFolderPlaceholder(f)).length;
|
|
2335
2559
|
// Neither end may be the platform's own: a move neither takes a platform
|
|
@@ -2422,15 +2646,18 @@ export function registerWorkspaceTools(
|
|
|
2422
2646
|
|
|
2423
2647
|
mount({
|
|
2424
2648
|
name: 'copy_file',
|
|
2649
|
+
gated: true,
|
|
2425
2650
|
description:
|
|
2426
|
-
'Copy a workspace
|
|
2427
|
-
|
|
2651
|
+
'Copy a workspace FILE to a new path. The destination must not exist — like a move, a copy never overwrites a file or a folder; to change what is in a file that already exists, write it. Committed + pushed as you. ' +
|
|
2652
|
+
'Preflight first: `dryRun: true` changes nothing and answers `{ src, dest, kind, descendants, access: { before, after }, accessChanges, allowed, reason? }` — `access` is your own `{ read, write, download, owner }` at the source and at the destination AS IT WILL BE once the copy has landed, with every `access.md` inside a copied folder counted at its new place. ' +
|
|
2653
|
+
'One exception to that shape: when the destination is one you may not write, the answer is the refusal alone — `allowed: false` with `reason`, and no `kind` and no `descendants`, because nothing about the source is read before that verdict.',
|
|
2428
2654
|
inputs: {
|
|
2429
2655
|
type: 'object',
|
|
2430
2656
|
properties: {
|
|
2431
2657
|
branch: BRANCH_INPUT,
|
|
2432
2658
|
src: wsPath(kbDirName, 'Source path'),
|
|
2433
2659
|
dest: wsPath(kbDirName, 'Destination path — must not exist yet'),
|
|
2660
|
+
dryRun: { type: 'boolean', description: 'Answer with the impact and change nothing.' },
|
|
2434
2661
|
sessionId: SESSION_ID_INPUT,
|
|
2435
2662
|
},
|
|
2436
2663
|
required: ['branch', 'src', 'dest'],
|
|
@@ -2438,20 +2665,30 @@ export function registerWorkspaceTools(
|
|
|
2438
2665
|
},
|
|
2439
2666
|
outputs: {
|
|
2440
2667
|
type: 'object',
|
|
2441
|
-
properties: {
|
|
2668
|
+
properties: {
|
|
2669
|
+
src: str('Source path (echoes the input).'),
|
|
2670
|
+
dest: str('Destination path (echoes the input).'),
|
|
2671
|
+
kind: str('`file` or `folder` (dry run only; absent when `allowed` is false because you may not write the destination).'),
|
|
2672
|
+
descendants: int('Files the copy would carry: 1 for a file, the file count under a folder (dry run only; absent when `allowed` is false because you may not write the destination).'),
|
|
2673
|
+
access: { type: 'object', description: 'Your `{ read, write, download, owner }` at the source (`before`) and at the destination once the copy has landed (`after`) — dry run only.' },
|
|
2674
|
+
accessChanges: { type: 'boolean', description: 'True when any of your verdicts differs between `before` and `after` (dry run only).' },
|
|
2675
|
+
allowed: { type: 'boolean', description: 'Whether the copy may run (dry run only).' },
|
|
2676
|
+
reason: str('Why it may not, when `allowed` is false.'),
|
|
2677
|
+
dryRun: { type: 'boolean', description: 'True on a dry run.' },
|
|
2678
|
+
copied: { type: 'boolean', description: 'True once the copy landed; false on a dry run.' },
|
|
2679
|
+
},
|
|
2442
2680
|
required: ['src', 'dest', 'copied'],
|
|
2443
2681
|
},
|
|
2444
2682
|
write: true,
|
|
2445
2683
|
proposable: true,
|
|
2446
2684
|
handler: async (a, ctx: ToolContext) => {
|
|
2447
|
-
// A copy CARRIES the source content into the destination
|
|
2448
|
-
//
|
|
2449
|
-
// Check both before touching disk.
|
|
2685
|
+
// A copy CARRIES the source content into the destination, so BOTH ends
|
|
2686
|
+
// go to the write hook. Ask about both before touching disk.
|
|
2450
2687
|
writePolicy.assertPathWritable(ctx.sessionId, a.src as string);
|
|
2451
2688
|
writePolicy.assertPathWritable(ctx.sessionId, a.dest as string);
|
|
2452
|
-
await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.src as string);
|
|
2453
|
-
await assertOntologyWriteAllowed(sessionOntologyGate, ctx, a.dest as string);
|
|
2454
2689
|
const branch = a.branch as string;
|
|
2690
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, a.src as string);
|
|
2691
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, a.dest as string);
|
|
2455
2692
|
const src = a.src as string;
|
|
2456
2693
|
const dest = a.dest as string;
|
|
2457
2694
|
// A copy lands bytes at a name of its own, so it is refused by the same
|
|
@@ -2463,6 +2700,7 @@ export function registerWorkspaceTools(
|
|
|
2463
2700
|
// own containment check: a path with a `..` segment must not reach
|
|
2464
2701
|
// `lstat` outside the workspace, even to be told a name is taken.
|
|
2465
2702
|
assertPlainPath(dest);
|
|
2703
|
+
if (a.dryRun === true) return copyImpact(branch, ctx, src, dest);
|
|
2466
2704
|
// The write verdict comes FIRST, for the reason `move_file` gives at
|
|
2467
2705
|
// length: "already exists" is a fact about the destination folder, and a
|
|
2468
2706
|
// caller who may not write there must not be told it. The lock gate
|
|
@@ -2499,6 +2737,12 @@ export function registerWorkspaceTools(
|
|
|
2499
2737
|
try {
|
|
2500
2738
|
await asEntryExists(() => fs.copyFile(src, dest));
|
|
2501
2739
|
} catch (err) {
|
|
2740
|
+
// The filesystem's own "that is a directory" becomes the sentence the
|
|
2741
|
+
// dry run predicts, instead of escaping as a 500 carrying the
|
|
2742
|
+
// server's absolute path.
|
|
2743
|
+
if ((err as { name?: string } | null)?.name === 'IsDirectoryError') {
|
|
2744
|
+
throw new ToolError(folderCopyRefusal(src), 400);
|
|
2745
|
+
}
|
|
2502
2746
|
const missing = isAbsence(err) || (err as { name?: string }).name === 'FileNotFoundError';
|
|
2503
2747
|
if (missing) {
|
|
2504
2748
|
throw (await kindOf(fs, src)) === null
|
|
@@ -2513,9 +2757,9 @@ export function registerWorkspaceTools(
|
|
|
2513
2757
|
|
|
2514
2758
|
mount({
|
|
2515
2759
|
name: 'unzip',
|
|
2760
|
+
gated: true,
|
|
2516
2761
|
description:
|
|
2517
|
-
'Extract a .zip already in the workspace (defaults to the zip\'s parent). Returns extracted files + skipped entries. Existing files are overwritten.'
|
|
2518
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
2762
|
+
'Extract a .zip already in the workspace (defaults to the zip\'s parent). Returns extracted files + skipped entries. Existing files are overwritten.',
|
|
2519
2763
|
inputs: {
|
|
2520
2764
|
type: 'object',
|
|
2521
2765
|
properties: {
|
|
@@ -2547,9 +2791,9 @@ export function registerWorkspaceTools(
|
|
|
2547
2791
|
write: true,
|
|
2548
2792
|
handler: async (a, ctx: ToolContext) => {
|
|
2549
2793
|
const zipPath = a.path as string;
|
|
2550
|
-
//
|
|
2551
|
-
//
|
|
2552
|
-
await
|
|
2794
|
+
// Opening the archive is a read of the archive, so the read hook hears
|
|
2795
|
+
// about it before a single entry is extracted out of it.
|
|
2796
|
+
await notifyAgentRead(agentAccessGate, ctx, a.branch as string, zipPath);
|
|
2553
2797
|
// A .zip that is not there is a missing PATH, not an unreadable archive:
|
|
2554
2798
|
// the service now says so (PathNotFoundError) and the helper turns it
|
|
2555
2799
|
// into the same 404 every other file tool answers. Only that declared
|
|
@@ -2561,10 +2805,10 @@ export function registerWorkspaceTools(
|
|
|
2561
2805
|
workspaceIdForBranch(a.branch as string),
|
|
2562
2806
|
zipPath,
|
|
2563
2807
|
typeof a.destination === 'string' ? a.destination : undefined,
|
|
2564
|
-
// Each extracted file is a write:
|
|
2565
|
-
// is skipped (not extracted), so an archive can't
|
|
2566
|
-
// extension policy applies per entry too, so
|
|
2567
|
-
// `.md` into the graph.
|
|
2808
|
+
// Each extracted file is a write of its own: an entry the write
|
|
2809
|
+
// hook refuses is skipped (not extracted), so an archive can't be
|
|
2810
|
+
// a way around it — the extension policy applies per entry too, so
|
|
2811
|
+
// a restricted run can't unzip a `.md` into the graph.
|
|
2568
2812
|
(wsRelPath) => {
|
|
2569
2813
|
// An entry that would land beside the repository is skipped with the
|
|
2570
2814
|
// corrected-path reason, like any other refused entry.
|
|
@@ -2578,7 +2822,7 @@ export function registerWorkspaceTools(
|
|
|
2578
2822
|
);
|
|
2579
2823
|
}
|
|
2580
2824
|
writePolicy.assertPathWritable(ctx.sessionId, wsRelPath);
|
|
2581
|
-
return
|
|
2825
|
+
return assertAgentWriteAllowed(agentAccessGate, ctx, a.branch as string, wsRelPath);
|
|
2582
2826
|
},
|
|
2583
2827
|
),
|
|
2584
2828
|
'Nothing to extract',
|
|
@@ -2589,11 +2833,14 @@ export function registerWorkspaceTools(
|
|
|
2589
2833
|
// ── shell (internal-only) ───────────────────────────────────────────────
|
|
2590
2834
|
mount({
|
|
2591
2835
|
name: 'execute_command',
|
|
2836
|
+
gated: true,
|
|
2592
2837
|
description:
|
|
2593
|
-
'Run a shell command in the workspace directory. Returns `{ stdout, stderr, exitCode }` (output capped). Use for git status/log, grep/rg, build/test commands.'
|
|
2594
|
-
ONTOLOGY_BOUNDARY_NOTE,
|
|
2838
|
+
'Run a shell command in the workspace directory. Returns `{ stdout, stderr, exitCode }` (output capped). Use for git status/log, grep/rg, build/test commands.',
|
|
2595
2839
|
internalOnly: true,
|
|
2596
2840
|
fileTool: false,
|
|
2841
|
+
// The one tool the mount's branch check skips: the handler below resolves an
|
|
2842
|
+
// omitted `branch` to the internal caller's focused branch before refusing.
|
|
2843
|
+
resolvesBranchItself: true,
|
|
2597
2844
|
inputs: {
|
|
2598
2845
|
type: 'object',
|
|
2599
2846
|
properties: {
|
|
@@ -2650,6 +2897,11 @@ export function registerWorkspaceTools(
|
|
|
2650
2897
|
throw new ToolError(
|
|
2651
2898
|
'execute_command requires a `branch`: pass the branch (draft) whose workspace to run the command in — the one you are currently working on.',
|
|
2652
2899
|
400,
|
|
2900
|
+
// The same discriminator every other KB tool answers a branch-less
|
|
2901
|
+
// call with, so a client switches on one kind across the surface.
|
|
2902
|
+
// The MESSAGE stays this tool's own: it can name the focused-branch
|
|
2903
|
+
// fallback that only applies here.
|
|
2904
|
+
{ kind: 'branch-required' },
|
|
2653
2905
|
);
|
|
2654
2906
|
}
|
|
2655
2907
|
// A stringified absent value. Both are syntactically valid git branch names,
|
|
@@ -2662,6 +2914,7 @@ export function registerWorkspaceTools(
|
|
|
2662
2914
|
`execute_command got the literal string "${branch}" as \`branch\` — that is a stringified absent value, not a branch. ` +
|
|
2663
2915
|
'Pass the real branch (draft) whose workspace to run the command in.',
|
|
2664
2916
|
400,
|
|
2917
|
+
{ kind: 'branch-required' },
|
|
2665
2918
|
);
|
|
2666
2919
|
}
|
|
2667
2920
|
// Then the SHAPE, via the one canonical validator every other branch path
|
|
@@ -2679,11 +2932,12 @@ export function registerWorkspaceTools(
|
|
|
2679
2932
|
400,
|
|
2680
2933
|
);
|
|
2681
2934
|
}
|
|
2682
|
-
// Shell is a write path with no single target path to check, so
|
|
2683
|
-
//
|
|
2684
|
-
//
|
|
2935
|
+
// Shell is a write path with no single target path to check, so the
|
|
2936
|
+
// write hook is asked once for the call itself, with no path — and the
|
|
2937
|
+
// run must not be restricted to a file type either, since shell could
|
|
2938
|
+
// write anything.
|
|
2685
2939
|
writePolicy.assertUnrestricted(ctx.sessionId);
|
|
2686
|
-
await
|
|
2940
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch);
|
|
2687
2941
|
// Canonical per-branch bootstrap entry point — it owns the workspace-id
|
|
2688
2942
|
// encoding and the single-flight clone, so the shell never derives a
|
|
2689
2943
|
// workspace path by hand.
|