@bevel-software/platform-core-backend 0.22.0 → 0.24.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 +46 -13
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +12 -2
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +137 -24
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +46 -1
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +99 -13
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/core-config.d.ts +16 -12
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +28 -13
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -3
- 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.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +17 -16
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
- package/dist/modules/access/directory-sync-bot.js +7 -3
- package/dist/modules/access/directory-sync-bot.js.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
- package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
- package/dist/modules/agent-instructions/compose.d.ts +38 -6
- package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
- package/dist/modules/agent-instructions/compose.js +39 -6
- package/dist/modules/agent-instructions/compose.js.map +1 -1
- package/dist/modules/agent-instructions/index.d.ts +2 -1
- package/dist/modules/agent-instructions/index.d.ts.map +1 -1
- package/dist/modules/agent-instructions/index.js +2 -1
- package/dist/modules/agent-instructions/index.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
- package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
- package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
- package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +4 -2
- package/dist/modules/audit/agent-audit.service.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-erasure.service.d.ts.map +1 -1
- package/dist/modules/auth/account-erasure.service.js +57 -16
- package/dist/modules/auth/account-erasure.service.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 +70 -2
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +197 -18
- 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/code-mode/code-mode.tool.d.ts +20 -2
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +66 -35
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/database/connection.d.ts +16 -0
- package/dist/modules/database/connection.d.ts.map +1 -1
- package/dist/modules/database/connection.js +117 -0
- package/dist/modules/database/connection.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +337 -120
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +116 -58
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/database/migrate.d.ts +8 -0
- package/dist/modules/database/migrate.d.ts.map +1 -1
- package/dist/modules/database/migrate.js +271 -1
- package/dist/modules/database/migrate.js.map +1 -1
- 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/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/mcp.service.d.ts +8 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +38 -8
- package/dist/modules/mcp/mcp.service.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-request-records.store.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-records.store.js +7 -4
- package/dist/modules/plugins/join-request-records.store.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +8 -3
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/setup.routes.d.ts +52 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +207 -19
- package/dist/modules/settings/setup.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 +12 -4
- 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-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/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-registry/description-length.d.ts +80 -0
- package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
- package/dist/modules/tool-registry/description-length.js +108 -0
- package/dist/modules/tool-registry/description-length.js.map +1 -0
- 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 +75 -35
- 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 +94 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +193 -3
- package/dist/modules/workflow/git/git.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 +60 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
- package/dist/modules/workflow/recovery-bot.js +7 -3
- package/dist/modules/workflow/recovery-bot.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
- package/dist/modules/workflow/review-workflow/review-workflow.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.service.d.ts +60 -6
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +115 -5
- 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/agent-upload.routes.d.ts +77 -0
- package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
- package/dist/modules/workspace/agent-upload.routes.js +210 -0
- package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
- package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
- package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
- package/dist/modules/workspace/agent-upload.store.js +553 -0
- package/dist/modules/workspace/agent-upload.store.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 +59 -9
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
- package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.js +46 -4
- package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
- package/dist/modules/workspace/upload-limits.d.ts +13 -0
- package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
- package/dist/modules/workspace/upload-limits.js +13 -0
- package/dist/modules/workspace/upload-limits.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +93 -4
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +128 -5
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +316 -58
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +13 -11
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +791 -198
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/modules/workspace/write-denial.d.ts +0 -6
- package/dist/modules/workspace/write-denial.d.ts.map +1 -1
- package/dist/modules/workspace/write-denial.js +0 -6
- package/dist/modules/workspace/write-denial.js.map +1 -1
- package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
- package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
- package/dist/modules/workspace/zip-entry-rules.js +154 -0
- package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
- 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/column-crypto.d.ts +194 -0
- package/dist/shared/column-crypto.d.ts.map +1 -0
- package/dist/shared/column-crypto.js +144 -0
- package/dist/shared/column-crypto.js.map +1 -0
- package/dist/shared/domain-errors.d.ts +25 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +28 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/git.contract.d.ts +20 -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/shared/token-crypto.d.ts.map +1 -1
- package/dist/shared/token-crypto.js +25 -1
- package/dist/shared/token-crypto.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 +1 -2
- package/dist/tenancy/static-tenant-source.js.map +1 -1
- package/dist/tenancy/tenant-secrets.d.ts +5 -1
- package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
- package/dist/tenancy/tenant-secrets.js +4 -0
- package/dist/tenancy/tenant-secrets.js.map +1 -1
- package/kb-template/AGENTS.md +2 -0
- package/migrations/0014_change_request_closed_reason.sql +1 -0
- package/migrations/0015_account_deactivation.sql +3 -0
- package/migrations/0016_pii_encryption.sql +20 -0
- package/migrations/meta/0014_snapshot.json +2265 -0
- package/migrations/meta/0015_snapshot.json +2271 -0
- package/migrations/meta/0016_snapshot.json +2327 -0
- package/migrations/meta/_journal.json +21 -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 +241 -5
- 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 +52 -16
- package/src/core/create-core-services.ts +156 -24
- package/src/core/lifecycle.ts +117 -13
- package/src/core-config.ts +31 -14
- package/src/index.ts +30 -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.routes.prospective.test.ts +6 -7
- package/src/modules/access/__tests__/users-db-double.ts +21 -12
- 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.routes.ts +17 -16
- package/src/modules/access/directory-sync-bot.ts +7 -3
- package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +11 -5
- package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
- package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
- package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
- package/src/modules/agent-instructions/compose.ts +50 -7
- package/src/modules/agent-instructions/index.ts +12 -0
- package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
- package/src/modules/audit/agent-audit.service.ts +4 -2
- package/src/modules/auth/__tests__/account-deactivation.test.ts +253 -0
- package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
- package/src/modules/auth/__tests__/account.routes.test.ts +87 -6
- package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
- package/src/modules/auth/__tests__/auth.service.test.ts +4 -2
- package/src/modules/auth/account-admission.ts +78 -9
- package/src/modules/auth/account-erasure.service.ts +69 -18
- 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 +206 -23
- package/src/modules/auth/oidc-auth-provider.ts +7 -2
- package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
- package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
- package/src/modules/code-mode/code-mode.tool.ts +80 -34
- package/src/modules/database/__tests__/connection.test.ts +12 -0
- package/src/modules/database/__tests__/pii-backfill.pg.test.ts +561 -0
- package/src/modules/database/connection.ts +117 -0
- package/src/modules/database/core-schema.ts +116 -58
- package/src/modules/database/migrate.ts +353 -1
- package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -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/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.e2e.test.ts +4 -3
- package/src/modules/mcp/__tests__/mcp.service.test.ts +115 -13
- package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
- package/src/modules/mcp/mcp.service.ts +46 -7
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
- package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
- package/src/modules/plugins/join-request-records.store.ts +7 -4
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
- package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
- package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
- 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 +8 -3
- package/src/modules/settings/setup.routes.ts +263 -19
- 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__/skills.service.test.ts +229 -5
- 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 +12 -4
- 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-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/tool-manuals/tool-manuals.tools.ts +44 -31
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
- package/src/modules/tool-registry/description-length.ts +111 -0
- 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.repository-replaced.test.ts +249 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
- package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
- package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
- 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.prFetchFailure.test.ts +170 -0
- package/src/modules/workflow/git/git.service.ts +215 -1
- package/src/modules/workflow/pending-commits.service.ts +64 -2
- package/src/modules/workflow/recovery-bot.ts +7 -3
- package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
- package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
- package/src/modules/workflow/workflow-hooks.ts +64 -26
- package/src/modules/workflow/workflow.service.ts +125 -5
- package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
- package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
- package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +394 -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.any-workspace-credentials.test.ts +156 -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 +402 -0
- package/src/modules/workspace/__tests__/workspace.service.test.ts +77 -9
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +41 -39
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +766 -56
- package/src/modules/workspace/agent-access.gate.ts +164 -0
- package/src/modules/workspace/agent-upload.routes.ts +214 -0
- package/src/modules/workspace/agent-upload.store.ts +668 -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 +86 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
- package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
- package/src/modules/workspace/startup/steps/template-source.ts +53 -5
- package/src/modules/workspace/upload-limits.ts +12 -0
- package/src/modules/workspace/workspace.routes.ts +96 -5
- package/src/modules/workspace/workspace.service.ts +319 -61
- package/src/modules/workspace/workspace.tools.ts +894 -214
- package/src/modules/workspace/write-denial.ts +0 -8
- package/src/modules/workspace/zip-entry-rules.ts +173 -0
- 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/__tests__/column-crypto.test.ts +217 -0
- package/src/shared/column-crypto.ts +218 -0
- package/src/shared/domain-errors.ts +31 -0
- package/src/shared/git.contract.ts +27 -0
- package/src/shared/token-crypto.ts +28 -1
- package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -1
- package/src/tenancy/static-tenant-source.ts +1 -3
- package/src/tenancy/tenant-secrets.ts +5 -1
- 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
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
2
3
|
import { logger } from '../../shared/logging.js';
|
|
3
4
|
|
|
4
5
|
const log = logger('database');
|
|
5
|
-
import { DEFAULT_DB_SCHEMA, dbSchemaOf, type Database } from './connection.js';
|
|
6
|
+
import { DEFAULT_DB_SCHEMA, dbSchemaOf, piiKeysOf, type Database } from './connection.js';
|
|
6
7
|
import { AdvisoryLock, withAdvisoryLock } from './advisory-lock.js';
|
|
8
|
+
import { PII_SEALED_SHAPE_SQL_REGEX, isEncryptedBlob, type PiiKeys } from '../../shared/column-crypto.js';
|
|
7
9
|
|
|
8
10
|
/*
|
|
9
11
|
* ── Per-tier migration folders ──────────────────────────────────────────────
|
|
@@ -69,6 +71,9 @@ export async function runCoreMigrations(db: Database, folder: string): Promise<v
|
|
|
69
71
|
log.info('Running core database migrations...');
|
|
70
72
|
await migrate(db, { migrationsFolder: folder, migrationsTable: '__drizzle_migrations_core', migrationsSchema });
|
|
71
73
|
log.info('Core migrations complete.');
|
|
74
|
+
// Under the same lock, before anything reads or writes a PII column:
|
|
75
|
+
// the data half of migration 0016, which SQL cannot do (see below).
|
|
76
|
+
await runPiiEncryptionBackfill(db);
|
|
72
77
|
},
|
|
73
78
|
{ tenantKey },
|
|
74
79
|
);
|
|
@@ -96,3 +101,350 @@ export async function runEnterpriseMigrations(db: Database, folder: string): Pro
|
|
|
96
101
|
{ tenantKey },
|
|
97
102
|
);
|
|
98
103
|
}
|
|
104
|
+
|
|
105
|
+
/*
|
|
106
|
+
* ── PII column encryption backfill ──────────────────────────────────────────
|
|
107
|
+
*
|
|
108
|
+
* Migration 0016 adds the `*_bidx` columns; the DATA change — rewriting
|
|
109
|
+
* pre-existing plaintext PII to AES-256-GCM ciphertext and filling the blind
|
|
110
|
+
* indexes — happens here, programmatically, because it needs the key the
|
|
111
|
+
* handle holds (SQL migrations cannot encrypt). Runs on every start right after
|
|
112
|
+
* the core history, under the migrations lock, and is idempotent: sealed rows
|
|
113
|
+
* carry the `PII_CIPHERTEXT_PREFIX` marker and are excluded by the SELECT's
|
|
114
|
+
* own WHERE clause, so a completed backfill degenerates to one cheap,
|
|
115
|
+
* empty-result query per table.
|
|
116
|
+
*
|
|
117
|
+
* Concurrency: the migrations lock (keyed per tenant, like the history it
|
|
118
|
+
* guards) serialises two processes booting at once. Each UPDATE is also
|
|
119
|
+
* compare-and-swap — the WHERE clause pins every value the row was read with
|
|
120
|
+
* — so a writer that slips in between the scan and the write (an old-version
|
|
121
|
+
* instance in a mixed-version window) loses nothing: the CAS update matches
|
|
122
|
+
* zero rows and the next start's pass seals whatever that writer left
|
|
123
|
+
* behind. Deployments should still stop the old app before starting the new
|
|
124
|
+
* one (see UPGRADING.md); the lock and CAS make the failure mode of not doing
|
|
125
|
+
* so "unsealed until next start", never "silently overwritten".
|
|
126
|
+
*
|
|
127
|
+
* Once every row is sealed, the same transaction applies the constraints that
|
|
128
|
+
* could not ship in the SQL migration: SET NOT NULL on the bidx columns and
|
|
129
|
+
* the unique-index swaps (`users_email_unique` on the now-randomized
|
|
130
|
+
* ciphertext is meaningless, `users_email_bidx_unq` takes over; the same for
|
|
131
|
+
* the approvals and the join requests). The new unique index goes up BEFORE
|
|
132
|
+
* the old one is dropped so duplicate protection never has a gap. Legacy rows
|
|
133
|
+
* that collide under the normalized blind index are handled first: duplicate
|
|
134
|
+
* approval and join-request rows (the same logical row, a case-variant email)
|
|
135
|
+
* are collapsed; duplicate USERS are a genuine account conflict and abort the
|
|
136
|
+
* start with the row ids so an operator can merge them deliberately. All
|
|
137
|
+
* statements are IF-EXISTS-guarded — re-running is a no-op.
|
|
138
|
+
*
|
|
139
|
+
* Runs against whatever schema the handle searches first: a tenant's own, or
|
|
140
|
+
* `public`, under that handle's key. Every table name is unqualified for that
|
|
141
|
+
* reason.
|
|
142
|
+
*
|
|
143
|
+
* This is the one place that works on the STORED form. The handle's
|
|
144
|
+
* connection opens every sealed value of a result, which here would hide
|
|
145
|
+
* exactly what has to be told apart — so the columns are read as bytes
|
|
146
|
+
* ({@link asStored}), which it leaves alone, and written as ready-made
|
|
147
|
+
* ciphertext, which it passes through.
|
|
148
|
+
*/
|
|
149
|
+
|
|
150
|
+
interface PiiBackfillTable {
|
|
151
|
+
table: string;
|
|
152
|
+
/** Columns that uniquely identify a row for the write-back UPDATE. */
|
|
153
|
+
key: string[];
|
|
154
|
+
/** Columns whose plaintext values get rewritten as ciphertext. */
|
|
155
|
+
encrypted: string[];
|
|
156
|
+
/** Blind-index column to fill from the plaintext of `source`. */
|
|
157
|
+
bidx?: { source: string; column: string };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const PII_BACKFILL_TABLES: PiiBackfillTable[] = [
|
|
161
|
+
{ table: 'users', key: ['id'], encrypted: ['email', 'name', 'avatar_url'], bidx: { source: 'email', column: 'email_bidx' } },
|
|
162
|
+
{ table: 'pr_file_approvals', key: ['id'], encrypted: ['approver_email', 'approver_name'], bidx: { source: 'approver_email', column: 'approver_email_bidx' } },
|
|
163
|
+
{ table: 'pr_merge_log', key: ['id'], encrypted: ['triggered_by_email', 'triggered_by_name', 'error'], bidx: { source: 'triggered_by_email', column: 'triggered_by_email_bidx' } },
|
|
164
|
+
{ table: 'pr_comments', key: ['id'], encrypted: ['author_email', 'author_name', 'body'], bidx: { source: 'author_email', column: 'author_email_bidx' } },
|
|
165
|
+
{ table: 'change_requests', key: ['id'], encrypted: ['author_email', 'author_name', 'title', 'body', 'apply_failure_reason', 'apply_failed_by_name'], bidx: { source: 'author_email', column: 'author_email_bidx' } },
|
|
166
|
+
{ table: 'pending_commits', key: ['id'], encrypted: ['author_email', 'author_name', 'last_error'], bidx: { source: 'author_email', column: 'author_email_bidx' } },
|
|
167
|
+
{ table: 'file_locks', key: ['workspace_id', 'branch', 'path'], encrypted: ['holder_name'] },
|
|
168
|
+
{ table: 'plugin_join_requests', key: ['id'], encrypted: ['requester_email', 'requester_name', 'failure_reason'], bidx: { source: 'requester_email', column: 'requester_email_bidx' } },
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
const ident = (name: string) => sql.raw(`"${name}"`);
|
|
172
|
+
|
|
173
|
+
/** What the backfill needs from a drizzle client — the db or a transaction. */
|
|
174
|
+
type Executor = Pick<Database, 'execute'>;
|
|
175
|
+
|
|
176
|
+
/** A text column as it is stored: bytes, so the handle's connection does not open it. */
|
|
177
|
+
const asStored = (col: string) => sql`convert_to(${ident(col)}, 'UTF8') AS ${ident(col)}`;
|
|
178
|
+
|
|
179
|
+
/** What {@link asStored} selected, back as text; anything else as it came. */
|
|
180
|
+
const storedText = (value: unknown): unknown => (Buffer.isBuffer(value) ? value.toString('utf8') : value);
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A column "needs sealing" when it holds non-empty text that isn't a blob.
|
|
184
|
+
* The shape regex is deliberately used as a NEGATIVE filter: anything failing
|
|
185
|
+
* it — including plaintext that merely BEGINS with the prefix — is selected
|
|
186
|
+
* and sealed, and `isEncryptedBlob` is the same pattern, so the two can never
|
|
187
|
+
* disagree about a value. Sealed rows all match, keeping the steady-state scan
|
|
188
|
+
* an empty-result query.
|
|
189
|
+
*
|
|
190
|
+
* `trustShape` is false on the FIRST backfill of a database (see
|
|
191
|
+
* {@link isFirstBackfill}): nothing has been sealed yet, so a value in this
|
|
192
|
+
* shape is plaintext somebody typed, and every non-empty value is selected.
|
|
193
|
+
*/
|
|
194
|
+
function needsSealing(col: string, trustShape: boolean) {
|
|
195
|
+
if (!trustShape) return sql`(${ident(col)} IS NOT NULL AND ${ident(col)} <> '')`;
|
|
196
|
+
return sql`(${ident(col)} IS NOT NULL AND ${ident(col)} <> '' AND ${ident(col)} !~ ${PII_SEALED_SHAPE_SQL_REGEX})`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Whether this database has never been through the backfill: the blind-index
|
|
201
|
+
* column of `users` is still nullable. The backfill, the duplicate collapse
|
|
202
|
+
* and the constraints run in ONE transaction, so a nullable column means no
|
|
203
|
+
* backfill has ever committed here — and therefore that nothing in it is
|
|
204
|
+
* sealed, whatever it looks like.
|
|
205
|
+
*
|
|
206
|
+
* That is what lets a first backfill trust no shape at all. A blob is
|
|
207
|
+
* recognised by its shape, and a title or a display name is text a person
|
|
208
|
+
* chose: one typed in that shape before the upgrade would be read as sealed,
|
|
209
|
+
* skipped, and left in clear for good — and, sampled by
|
|
210
|
+
* {@link assertKeyOpensSealedRows}, would refuse the start for a key that is
|
|
211
|
+
* perfectly right. Once the first backfill has committed, every write goes
|
|
212
|
+
* through a handle that seals it, so no such value can arrive again.
|
|
213
|
+
*/
|
|
214
|
+
async function isFirstBackfill(tx: Executor): Promise<boolean> {
|
|
215
|
+
const result = await tx.execute(sql`
|
|
216
|
+
SELECT is_nullable FROM information_schema.columns
|
|
217
|
+
WHERE table_schema = current_schema() AND table_name = 'users' AND column_name = 'email_bidx'
|
|
218
|
+
`);
|
|
219
|
+
return (result.rows[0] as { is_nullable?: string } | undefined)?.is_nullable === 'YES';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** How many rows one backfill query reads: a table is walked in batches, never loaded whole. */
|
|
223
|
+
const BACKFILL_BATCH_ROWS = 500;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The configured key must open what earlier starts sealed. A rotated or
|
|
227
|
+
* mistyped `SECRETS_ENC_KEY` would otherwise go unnoticed here — sealed rows
|
|
228
|
+
* are exactly the ones the scan skips — and surface only as every login
|
|
229
|
+
* failing and every lookup by email missing, because the blind indexes would
|
|
230
|
+
* be computed under the new key. One sealed value per table is enough: all
|
|
231
|
+
* rows of a deployment are sealed under one key. Refusing the start is the
|
|
232
|
+
* loud failure; re-keying a database is a deliberate operation, not a boot.
|
|
233
|
+
*/
|
|
234
|
+
async function assertKeyOpensSealedRows(tx: Executor, keys: PiiKeys): Promise<void> {
|
|
235
|
+
for (const t of PII_BACKFILL_TABLES) {
|
|
236
|
+
const col = t.bidx?.source ?? t.encrypted[0]!;
|
|
237
|
+
const sample = await tx.execute(
|
|
238
|
+
sql`SELECT ${asStored(col)} FROM ${ident(t.table)} WHERE ${ident(col)} ~ ${PII_SEALED_SHAPE_SQL_REGEX} LIMIT 1`,
|
|
239
|
+
);
|
|
240
|
+
const value = storedText((sample.rows[0] as Record<string, unknown> | undefined)?.[col]);
|
|
241
|
+
if (typeof value === 'string' && !keys.open(value).ok) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`PII encryption: ${t.table}.${col} is sealed with a key the configured SECRETS_ENC_KEY ` +
|
|
244
|
+
'(for a tenant: the one derived from TENANT_MASTER_KEY) does not open — refusing to start. ' +
|
|
245
|
+
'Restore the key that sealed it; changing the key is a re-keying of the database, not a configuration change.',
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function backfillTable(
|
|
252
|
+
tx: Executor,
|
|
253
|
+
keys: PiiKeys,
|
|
254
|
+
t: PiiBackfillTable,
|
|
255
|
+
trustShape: boolean,
|
|
256
|
+
): Promise<number> {
|
|
257
|
+
// The key columns are read as text beside themselves, so the next batch can
|
|
258
|
+
// be asked for by value whatever their type is (a uuid, a serial, a path).
|
|
259
|
+
const keyText = (k: string) => `${k}__key`;
|
|
260
|
+
const cols = [
|
|
261
|
+
...t.key.map(ident),
|
|
262
|
+
...t.key.map((k) => sql`${ident(k)}::text AS ${ident(keyText(k))}`),
|
|
263
|
+
...t.encrypted.map(asStored),
|
|
264
|
+
...(t.bidx ? [ident(t.bidx.column)] : []),
|
|
265
|
+
];
|
|
266
|
+
// Only rows with work left: the ciphertext prefix makes "unsealed" a plain
|
|
267
|
+
// SQL predicate, so a fully-sealed table costs one empty-result query.
|
|
268
|
+
const pending = [
|
|
269
|
+
...t.encrypted.map((col) => needsSealing(col, trustShape)),
|
|
270
|
+
...(t.bidx ? [sql`${ident(t.bidx.column)} IS NULL`] : []),
|
|
271
|
+
];
|
|
272
|
+
const keyTuple = sql`(${sql.join(t.key.map((k) => sql`${ident(k)}::text`), sql`, `)})`;
|
|
273
|
+
// A blob, as far as this pass may believe one. On a first backfill nothing
|
|
274
|
+
// is sealed yet, so nothing is believed (see `isFirstBackfill`).
|
|
275
|
+
const sealedAlready = (value: string): boolean => trustShape && isEncryptedBlob(value);
|
|
276
|
+
let rewritten = 0;
|
|
277
|
+
/** The key of the last row read, as text: the next batch starts after it. */
|
|
278
|
+
let after: string[] | null = null;
|
|
279
|
+
// IN BATCHES, in key order. A table is never loaded whole: a deployment with
|
|
280
|
+
// years of change requests holds every title and body it ever had, and
|
|
281
|
+
// reading them into one result held the first start after the upgrade — and
|
|
282
|
+
// the memory of the process making it — for as long as that took. Walking by
|
|
283
|
+
// key rather than re-asking for "what is left" also ends: a row this pass
|
|
284
|
+
// leaves as it found it (a write overtook it) is passed, not met again.
|
|
285
|
+
for (;;) {
|
|
286
|
+
const past = after ? sql` AND ${keyTuple} > (${sql.join(after.map((v) => sql`${v}`), sql`, `)})` : sql``;
|
|
287
|
+
const result = await tx.execute(
|
|
288
|
+
sql`SELECT ${sql.join(cols, sql`, `)} FROM ${ident(t.table)} WHERE (${sql.join(pending, sql` OR `)})${past} ORDER BY ${keyTuple} LIMIT ${BACKFILL_BATCH_ROWS}`,
|
|
289
|
+
);
|
|
290
|
+
const batch = result.rows as Array<Record<string, unknown>>;
|
|
291
|
+
for (const read of batch) {
|
|
292
|
+
const row = Object.fromEntries(Object.entries(read).map(([col, value]) => [col, storedText(value)]));
|
|
293
|
+
const sets = [];
|
|
294
|
+
// Compare-and-swap: pin every value this row was read with, so a write
|
|
295
|
+
// that lands between scan and update makes this UPDATE match zero rows
|
|
296
|
+
// instead of clobbering the newer value.
|
|
297
|
+
const where = t.key.map((k) => sql`${ident(k)} = ${row[k]}`);
|
|
298
|
+
for (const col of t.encrypted) {
|
|
299
|
+
const value = row[col];
|
|
300
|
+
if (typeof value === 'string' && value !== '' && !sealedAlready(value)) {
|
|
301
|
+
sets.push(sql`${ident(col)} = ${keys.seal(value)}`);
|
|
302
|
+
where.push(sql`${ident(col)} = ${value}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (t.bidx) {
|
|
306
|
+
// The blind index is derived from its source, so it is (re)computed
|
|
307
|
+
// whenever the source is being sealed in this pass — a legacy writer
|
|
308
|
+
// that put a NEW plaintext email on an already-indexed row left a stale
|
|
309
|
+
// index behind, and sealing the email alone would freeze that mismatch
|
|
310
|
+
// — and whenever it was never filled. The source may already be
|
|
311
|
+
// ciphertext (an index that was never filled beside a sealed address);
|
|
312
|
+
// the index is always computed over the plaintext, never over a blob
|
|
313
|
+
// the key cannot open.
|
|
314
|
+
const source = row[t.bidx.source];
|
|
315
|
+
const stored = row[t.bidx.column];
|
|
316
|
+
const text = typeof source === 'string' ? source : '';
|
|
317
|
+
const sealingSource = text !== '' && !sealedAlready(text);
|
|
318
|
+
if (sealingSource || stored == null) {
|
|
319
|
+
const opened = sealedAlready(text) ? keys.open(text) : ({ ok: true, plain: text } as const);
|
|
320
|
+
if (!opened.ok) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`PII encryption backfill: ${t.table}.${t.bidx.source} cannot be decrypted with the ` +
|
|
323
|
+
'configured SECRETS_ENC_KEY — refusing to derive a blind index from ciphertext. ' +
|
|
324
|
+
'Restore the key that sealed it, then restart.',
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
sets.push(sql`${ident(t.bidx.column)} = ${keys.index(opened.plain)}`);
|
|
328
|
+
// Pin the index AND its source: if a concurrent writer replaces the
|
|
329
|
+
// email between scan and write, the CAS must not attach the OLD
|
|
330
|
+
// email's blind index to the NEW value.
|
|
331
|
+
where.push(sql`${ident(t.bidx.column)} IS NOT DISTINCT FROM ${stored ?? null}`);
|
|
332
|
+
where.push(sql`${ident(t.bidx.source)} IS NOT DISTINCT FROM ${source ?? null}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (sets.length === 0) continue;
|
|
336
|
+
const updated = await tx.execute(
|
|
337
|
+
sql`UPDATE ${ident(t.table)} SET ${sql.join(sets, sql`, `)} WHERE ${sql.join(where, sql` AND `)}`,
|
|
338
|
+
);
|
|
339
|
+
rewritten += updated.rowCount ?? 0;
|
|
340
|
+
}
|
|
341
|
+
if (batch.length < BACKFILL_BATCH_ROWS) return rewritten;
|
|
342
|
+
const last = batch[batch.length - 1]!;
|
|
343
|
+
after = t.key.map((k) => String(last[keyText(k)]));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The name on a refused apply that was recorded BEFORE this release: the
|
|
349
|
+
* address of whoever it names was never kept, so no blind index stands beside
|
|
350
|
+
* it and an account erasure has nothing to find it by.
|
|
351
|
+
*
|
|
352
|
+
* Matching it by display name instead got both mistakes at once: a person
|
|
353
|
+
* who had since changed their name kept the old one on the request for good,
|
|
354
|
+
* and somebody else of the same name had theirs taken away. So the name goes,
|
|
355
|
+
* once, here. The refusal itself stays, with its reason and its time; it
|
|
356
|
+
* reads "could not apply" without saying who. A refusal recorded from this
|
|
357
|
+
* release on carries its index and is never touched by this.
|
|
358
|
+
*/
|
|
359
|
+
async function clearUnindexedRefusalNames(tx: Executor): Promise<number> {
|
|
360
|
+
const cleared = await tx.execute(sql.raw(`
|
|
361
|
+
UPDATE "change_requests" SET "apply_failed_by_name" = NULL
|
|
362
|
+
WHERE "apply_failed_by_name" IS NOT NULL AND "apply_failed_by_email_bidx" IS NULL
|
|
363
|
+
`));
|
|
364
|
+
return cleared.rowCount ?? 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Legacy uniqueness was on the RAW email, so rows differing only by case or
|
|
369
|
+
* whitespace could coexist; under the normalized blind index they collide and
|
|
370
|
+
* the unique-index creation below would abort the start. Approval rows and
|
|
371
|
+
* join-request rows are the same logical row — collapse them, keeping the
|
|
372
|
+
* earliest. Colliding USER rows are distinct accounts; refuse loudly with the
|
|
373
|
+
* ids so an operator resolves the conflict deliberately instead of the
|
|
374
|
+
* upgrade guessing.
|
|
375
|
+
*/
|
|
376
|
+
async function resolveBidxCollisions(tx: Executor): Promise<void> {
|
|
377
|
+
await tx.execute(sql.raw(`
|
|
378
|
+
DELETE FROM "pr_file_approvals" a USING "pr_file_approvals" b
|
|
379
|
+
WHERE a."pr_number" = b."pr_number" AND a."path" = b."path"
|
|
380
|
+
AND a."approver_email_bidx" = b."approver_email_bidx" AND a."head_sha" = b."head_sha"
|
|
381
|
+
AND (a."approved_at" > b."approved_at" OR (a."approved_at" = b."approved_at" AND a."id" > b."id"))
|
|
382
|
+
`));
|
|
383
|
+
await tx.execute(sql.raw(`
|
|
384
|
+
DELETE FROM "plugin_join_requests" a USING "plugin_join_requests" b
|
|
385
|
+
WHERE a."plugin_key" = b."plugin_key" AND a."requester_email_bidx" = b."requester_email_bidx"
|
|
386
|
+
AND (a."created_at" > b."created_at" OR (a."created_at" = b."created_at" AND a."id" > b."id"))
|
|
387
|
+
`));
|
|
388
|
+
const dupes = await tx.execute(sql.raw(`
|
|
389
|
+
SELECT array_agg("id") AS ids FROM "users" GROUP BY "email_bidx" HAVING count(*) > 1
|
|
390
|
+
`));
|
|
391
|
+
if (dupes.rows.length > 0) {
|
|
392
|
+
const groups = (dupes.rows as Array<{ ids: string[] }>).map((r) => r.ids.join(', '));
|
|
393
|
+
throw new Error(
|
|
394
|
+
'PII encryption backfill: multiple user rows share the same email after normalization ' +
|
|
395
|
+
'(case/whitespace variants of one address). Merge or delete the duplicates, then restart. ' +
|
|
396
|
+
`Conflicting user ids: [${groups.join('], [')}]`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const PII_FINALIZE_STATEMENTS = [
|
|
402
|
+
'ALTER TABLE "users" ALTER COLUMN "email_bidx" SET NOT NULL',
|
|
403
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS "users_email_bidx_unq" ON "users" ("email_bidx")',
|
|
404
|
+
'ALTER TABLE "users" DROP CONSTRAINT IF EXISTS "users_email_unique"',
|
|
405
|
+
'ALTER TABLE "pr_file_approvals" ALTER COLUMN "approver_email_bidx" SET NOT NULL',
|
|
406
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS "pr_file_approvals_bidx_unq" ON "pr_file_approvals" ("pr_number","path","approver_email_bidx","head_sha")',
|
|
407
|
+
'DROP INDEX IF EXISTS "pr_file_approvals_unq"',
|
|
408
|
+
'ALTER TABLE "pr_merge_log" ALTER COLUMN "triggered_by_email_bidx" SET NOT NULL',
|
|
409
|
+
'ALTER TABLE "pr_comments" ALTER COLUMN "author_email_bidx" SET NOT NULL',
|
|
410
|
+
'ALTER TABLE "change_requests" ALTER COLUMN "author_email_bidx" SET NOT NULL',
|
|
411
|
+
'ALTER TABLE "pending_commits" ALTER COLUMN "author_email_bidx" SET NOT NULL',
|
|
412
|
+
'ALTER TABLE "plugin_join_requests" ALTER COLUMN "requester_email_bidx" SET NOT NULL',
|
|
413
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS "plugin_join_requests_requester_bidx_plugin_unq" ON "plugin_join_requests" ("requester_email_bidx","plugin_key")',
|
|
414
|
+
'DROP INDEX IF EXISTS "plugin_join_requests_requester_plugin_unq"',
|
|
415
|
+
];
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Encrypt pre-existing plaintext PII rows, fill the blind-index columns, and
|
|
419
|
+
* apply the constraints migration 0016 deferred. Idempotent; `runCoreMigrations`
|
|
420
|
+
* runs it under the migrations lock right after the history. The handle must
|
|
421
|
+
* hold the knowledge base's key (`createDb(url, { piiKey })`; the composition
|
|
422
|
+
* root's does): one that holds none is refused before anything is read.
|
|
423
|
+
*/
|
|
424
|
+
export async function runPiiEncryptionBackfill(db: Database): Promise<void> {
|
|
425
|
+
const keys = piiKeysOf(db);
|
|
426
|
+
await db.transaction(async (tx) => {
|
|
427
|
+
const first = await isFirstBackfill(tx);
|
|
428
|
+
// Nothing is sealed before the first backfill, so there is no sealed row
|
|
429
|
+
// for the key to be checked against — and a value that only LOOKS sealed
|
|
430
|
+
// must not be taken for one (see `isFirstBackfill`).
|
|
431
|
+
if (!first) await assertKeyOpensSealedRows(tx, keys);
|
|
432
|
+
let rewritten = 0;
|
|
433
|
+
for (const t of PII_BACKFILL_TABLES) {
|
|
434
|
+
rewritten += await backfillTable(tx, keys, t, !first);
|
|
435
|
+
}
|
|
436
|
+
if (rewritten > 0) log.info(`PII encryption backfill: rewrote ${rewritten} row(s).`);
|
|
437
|
+
const cleared = await clearUnindexedRefusalNames(tx);
|
|
438
|
+
if (cleared > 0) {
|
|
439
|
+
log.info(`PII encryption backfill: took the name off ${cleared} refused apply(ies) recorded before this release.`);
|
|
440
|
+
}
|
|
441
|
+
// Only where the unique indexes are not up yet. Once they are, they are
|
|
442
|
+
// what keeps two rows from sharing an index, so there is nothing to
|
|
443
|
+
// collapse — and the collapse is two self-joins and an aggregate over
|
|
444
|
+
// whole tables, which every later start paid for nothing.
|
|
445
|
+
if (first) await resolveBidxCollisions(tx);
|
|
446
|
+
for (const statement of PII_FINALIZE_STATEMENTS) {
|
|
447
|
+
await tx.execute(sql.raw(statement));
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
}
|
|
@@ -67,6 +67,16 @@ describe('assertValidRelativePath', () => {
|
|
|
67
67
|
['nested parent', 'a/../b'],
|
|
68
68
|
['current segment', 'a/./b'],
|
|
69
69
|
['null byte', 'a\x00b'],
|
|
70
|
+
// A line break is not merely an unwritable name: git's `cat-file --batch`
|
|
71
|
+
// reads one `<ref>:<path>` object name per LINE, so a path carrying one
|
|
72
|
+
// is two specs and git answers about the first — under the gates that were
|
|
73
|
+
// asked about the whole string. Every other control character goes with
|
|
74
|
+
// them, matching what the shared `validateFilename` has always refused.
|
|
75
|
+
['line feed', 'Docs/secret.md\nzzz'],
|
|
76
|
+
['carriage return', 'Docs/secret.md\rzzz'],
|
|
77
|
+
['tab', 'Docs/a\tb.md'],
|
|
78
|
+
['escape', 'Docs/a\u001Bb.md'],
|
|
79
|
+
['delete', 'Docs/a\u007Fb.md'],
|
|
70
80
|
['leading dash', '-afile'],
|
|
71
81
|
])('rejects %s (%s)', (_label, p) => {
|
|
72
82
|
expect(() => assertValidRelativePath(p)).toThrow(WorkflowValidationError);
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
assertKbDirNameFree,
|
|
5
5
|
assertRepoRootNameFree,
|
|
6
6
|
assertRepoRootNameFreeArgs,
|
|
7
|
+
hasControlCharacter,
|
|
7
8
|
isInsideRepo,
|
|
8
9
|
normalizePathArgs,
|
|
9
10
|
normalizeWorkspacePath,
|
|
@@ -47,6 +48,14 @@ describe('isInsideRepo', () => {
|
|
|
47
48
|
expect(isInsideRepo('knowledge-base/KnowledgeBase/a\\b.md', KB)).toBe(false);
|
|
48
49
|
});
|
|
49
50
|
|
|
51
|
+
it('refuses a control character anywhere: git reads a line break in a path as a separator', () => {
|
|
52
|
+
expect(isInsideRepo('knowledge-base/Docs/secret.md\nzzz', KB)).toBe(false);
|
|
53
|
+
expect(isInsideRepo('knowledge-base/Docs/secret.md\rzzz', KB)).toBe(false);
|
|
54
|
+
expect(isInsideRepo('knowledge-base/Docs/a\u0000b.md', KB)).toBe(false);
|
|
55
|
+
expect(isInsideRepo('knowledge-base/Docs/a\u001Bb.md', KB)).toBe(false);
|
|
56
|
+
expect(isInsideRepo('knowledge-base/Docs/a\u007Fb.md', KB)).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
50
59
|
it('tolerates a trailing slash on a directory path', () => {
|
|
51
60
|
expect(isInsideRepo('knowledge-base/', KB)).toBe(true);
|
|
52
61
|
expect(isInsideRepo('knowledge-base/KnowledgeBase/Projects/', KB)).toBe(true);
|
|
@@ -113,6 +122,120 @@ describe('assertInsideRepo', () => {
|
|
|
113
122
|
});
|
|
114
123
|
});
|
|
115
124
|
|
|
125
|
+
describe('a line break in a path — one name to the gates, two object names to git', () => {
|
|
126
|
+
// `git cat-file --batch` reads one `<ref>:<path>` spec per LINE. A path
|
|
127
|
+
// carrying a `\n` therefore means TWO things at once: the read gate, the
|
|
128
|
+
// download gate and the not-a-directory check are answered about the whole
|
|
129
|
+
// string — a name that exists nowhere, so the real file's own frontmatter
|
|
130
|
+
// rules are never the ones consulted — while git answers about the part
|
|
131
|
+
// before the break and hands over its bytes. In the access resolver's batch
|
|
132
|
+
// read the extra answer also shifts every result after it, so one directory
|
|
133
|
+
// is judged by another's `access.md`.
|
|
134
|
+
//
|
|
135
|
+
// Which is why the refusal is HERE, at the normaliser every accepted path
|
|
136
|
+
// passes through, and not at the route that happened to surface it: the next
|
|
137
|
+
// caller to build a git spec out of a path will not remember this.
|
|
138
|
+
const INJECTED = 'knowledge-base/Docs/secret.md\nzzz';
|
|
139
|
+
|
|
140
|
+
it('is refused by the normaliser, with a message that says what is wrong', () => {
|
|
141
|
+
let err: unknown;
|
|
142
|
+
try {
|
|
143
|
+
normalizeWorkspacePath(INJECTED, KB);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
err = e;
|
|
146
|
+
}
|
|
147
|
+
expect(err).toBeInstanceOf(WorkflowValidationError);
|
|
148
|
+
const e = err as WorkflowValidationError;
|
|
149
|
+
expect(e.status).toBe(400);
|
|
150
|
+
expect(e.message).toMatch(/control character/);
|
|
151
|
+
// Never "use this instead": there is no corrected spelling of a name with
|
|
152
|
+
// an invisible byte in it.
|
|
153
|
+
expect(e.message).not.toMatch(/Use "/);
|
|
154
|
+
// The path is named, ESCAPED — `sanitizedPath`, the same spelling every
|
|
155
|
+
// other refusal uses — so the character that made the path invalid cannot
|
|
156
|
+
// forge a second line of the refusal either.
|
|
157
|
+
expect(e.payload).toMatchObject({
|
|
158
|
+
kind: 'path-control-character',
|
|
159
|
+
path: 'knowledge-base/Docs/secret.md\\nzzz',
|
|
160
|
+
});
|
|
161
|
+
expect(e.message).toContain('"knowledge-base/Docs/secret.md\\nzzz"');
|
|
162
|
+
expect(e.message).not.toContain('\n');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('is refused the same way unprefixed, which is the form a route receives', () => {
|
|
166
|
+
expect(() => normalizeWorkspacePath('Docs/secret.md\nzzz', KB)).toThrow(/control character/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('is refused by the check AFTER the normaliser too', () => {
|
|
170
|
+
expect(() => assertInsideRepo(INJECTED, KB)).toThrow(/control character/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('refuses every control character, not only the line breaks', () => {
|
|
174
|
+
for (const ch of ['\u0000', '\u0001', '\t', '\n', '\u000B', '\r', '\u001F', '\u007F']) {
|
|
175
|
+
expect(() => normalizeWorkspacePath(`knowledge-base/Docs/a${ch}b.md`, KB), ch).toThrow(
|
|
176
|
+
WorkflowValidationError,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('refuses a CHECKOUT FOLDER NAME carrying one, which the normaliser would otherwise emit', () => {
|
|
182
|
+
// The name is prefixed onto the result rather than read from it, so a
|
|
183
|
+
// clean path joined to `kb\nname` would come out carrying the break: the
|
|
184
|
+
// normaliser would emit exactly what it refuses from a caller, from inputs
|
|
185
|
+
// that were each individually fine.
|
|
186
|
+
let err: unknown;
|
|
187
|
+
try {
|
|
188
|
+
normalizeWorkspacePath('Docs/report.md', 'kb\nname');
|
|
189
|
+
} catch (e) {
|
|
190
|
+
err = e;
|
|
191
|
+
}
|
|
192
|
+
expect(err).toBeInstanceOf(WorkflowValidationError);
|
|
193
|
+
const e = err as WorkflowValidationError;
|
|
194
|
+
// Named as the CONFIGURATION error it is: an operator told their own path
|
|
195
|
+
// was invalid would have nothing to fix.
|
|
196
|
+
expect(e.message).toMatch(/checkout folder name/);
|
|
197
|
+
expect(e.message).toMatch(/KB_DIR_NAME/);
|
|
198
|
+
expect(e.message).toContain('"kb\\nname"');
|
|
199
|
+
expect(e.payload).toMatchObject({ kind: 'kb-dir-name-control-character', kbDirName: 'kb\\nname' });
|
|
200
|
+
// The guarantee the fence is for: no return value of this function carries
|
|
201
|
+
// a control character, whatever it was given.
|
|
202
|
+
for (const dir of ['kb\nname', 'kb\rname', 'kb\u0000name']) {
|
|
203
|
+
for (const p of ['Docs/report.md', `${dir}/Docs/report.md`, 'x']) {
|
|
204
|
+
let out: string | null = null;
|
|
205
|
+
try {
|
|
206
|
+
out = normalizeWorkspacePath(p, dir);
|
|
207
|
+
} catch {
|
|
208
|
+
out = null;
|
|
209
|
+
}
|
|
210
|
+
if (out !== null) expect(hasControlCharacter(out), `${dir} + ${p}`).toBe(false);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('exposes the rule as a predicate, so the places that SET a name share it', () => {
|
|
216
|
+
expect(hasControlCharacter('knowledge-base')).toBe(false);
|
|
217
|
+
expect(hasControlCharacter('kb\nname')).toBe(true);
|
|
218
|
+
expect(hasControlCharacter('kb\u007Fname')).toBe(true);
|
|
219
|
+
// U+2028 is not in the range: nothing to git, and a name carrying one is
|
|
220
|
+
// creatable today — refusing it would make an existing file unreadable.
|
|
221
|
+
expect(hasControlCharacter('kb\u2028name')).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('leaves ordinary names alone — the rule costs no real path', () => {
|
|
225
|
+
expect(normalizeWorkspacePath('knowledge-base/Docs/[Approved] Handbook_Order archive.docx', KB)).toBe(
|
|
226
|
+
'knowledge-base/Docs/[Approved] Handbook_Order archive.docx',
|
|
227
|
+
);
|
|
228
|
+
expect(normalizeWorkspacePath('knowledge-base/Docs/Ünïcödé — naïve.md', KB)).toBe(
|
|
229
|
+
'knowledge-base/Docs/Ünïcödé — naïve.md',
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('refuses it through normalizePathArgs, which is where the MCP tools meet the rule', () => {
|
|
234
|
+
expect(() => normalizePathArgs({ path: INJECTED }, KB)).toThrow(/control character/);
|
|
235
|
+
expect(() => normalizePathArgs({ files: [{ path: INJECTED }] }, KB)).toThrow(/control character/);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
116
239
|
describe('normalizeWorkspacePath — THE normaliser', () => {
|
|
117
240
|
it('leaves a path that is already inside the repository alone', () => {
|
|
118
241
|
expect(normalizeWorkspacePath('knowledge-base/KnowledgeBase/Foo.md', KB)).toBe('knowledge-base/KnowledgeBase/Foo.md');
|
|
@@ -49,7 +49,20 @@ export function assertValidBranchName(name: string): void {
|
|
|
49
49
|
export function assertValidRelativePath(relativePath: string): void {
|
|
50
50
|
if (!relativePath) throw new WorkflowValidationError('path is required');
|
|
51
51
|
if (relativePath.length > 1024) throw new WorkflowValidationError('path too long');
|
|
52
|
-
|
|
52
|
+
// NUL, every other C0 control and DEL. A NUL is named on its own because it
|
|
53
|
+
// terminates a C string and git's `-z` output; the rest are refused with it
|
|
54
|
+
// because a LINE BREAK in a path is a separator in git's line-oriented stdin
|
|
55
|
+
// protocols — `cat-file --batch` reads one `<ref>:<path>` per line, so
|
|
56
|
+
// `Docs/secret.md\nzzz` is two object names while every gate upstream was
|
|
57
|
+
// asked about one string. See `CONTROL_CHARACTERS` in `repo-path.ts`, which
|
|
58
|
+
// refuses the same range at the normaliser; this is the same rule at the
|
|
59
|
+
// check every path handed to git as a pathspec passes through.
|
|
60
|
+
// eslint-disable-next-line no-control-regex
|
|
61
|
+
if (/[\x00-\x1F\x7F]/.test(relativePath)) {
|
|
62
|
+
throw new WorkflowValidationError(
|
|
63
|
+
relativePath.includes('\0') ? 'path contains NUL' : 'path contains a control character',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
53
66
|
if (relativePath.includes('\\')) {
|
|
54
67
|
throw new WorkflowValidationError('path must use forward slashes only');
|
|
55
68
|
}
|