@intentius/chant 0.79.0 → 0.81.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/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +30 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/report-html.d.ts +5 -0
- package/dist/audit/report-html.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +13 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/terraform-state.d.ts +13 -0
- package/dist/audit/terraform-state.d.ts.map +1 -1
- package/dist/cli/command-group.d.ts +6 -0
- package/dist/cli/command-group.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +44 -3
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts +8 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts +6 -0
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/vendor.d.ts +5 -0
- package/dist/cli/commands/vendor.d.ts.map +1 -1
- package/dist/cli/conflict-check.d.ts +6 -0
- package/dist/cli/conflict-check.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/dev.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/init.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +7 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts +20 -0
- package/dist/cli/handlers/promote.d.ts.map +1 -0
- package/dist/cli/handlers/run-generate.d.ts +39 -0
- package/dist/cli/handlers/run-generate.d.ts.map +1 -0
- package/dist/cli/handlers/run.d.ts +4 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/vendor.d.ts +8 -3
- package/dist/cli/handlers/vendor.d.ts.map +1 -1
- package/dist/cli/main.d.ts +12 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +14 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +46 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/fetch.d.ts +10 -0
- package/dist/codegen/fetch.d.ts.map +1 -1
- package/dist/components/capability-plugin-loader.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/driver.d.ts +16 -2
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/gate-plan.d.ts +40 -0
- package/dist/components/gate-plan.d.ts.map +1 -0
- package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
- package/dist/components/presets/ecs-fargate.d.ts +10 -4
- package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
- package/dist/components/promote.d.ts +264 -0
- package/dist/components/promote.d.ts.map +1 -0
- package/dist/components/sandbox/driver.d.ts.map +1 -1
- package/dist/components/sandbox/run.d.ts.map +1 -1
- package/dist/components/verbs/build-archive.d.ts +22 -11
- package/dist/components/verbs/build-archive.d.ts.map +1 -1
- package/dist/components/verbs/run-agent.d.ts.map +1 -1
- package/dist/config-static.d.ts +59 -0
- package/dist/config-static.d.ts.map +1 -0
- package/dist/config.d.ts +104 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/content-digest.d.ts +17 -0
- package/dist/content-digest.d.ts.map +1 -0
- package/dist/discovery/files.d.ts +48 -4
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +32 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts +20 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +20 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/discovery/walk.d.ts +119 -0
- package/dist/discovery/walk.d.ts.map +1 -0
- package/dist/graph-ir.d.ts +17 -1
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/join-key.d.ts +31 -0
- package/dist/join-key.d.ts.map +1 -0
- package/dist/lexicon-module.d.ts +110 -0
- package/dist/lexicon-module.d.ts.map +1 -0
- package/dist/lexicon.d.ts +82 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
- package/dist/lifecycle/digest.d.ts +5 -1
- package/dist/lifecycle/digest.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +20 -5
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +64 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +8 -1
- package/dist/lifecycle/lease.d.ts.map +1 -1
- package/dist/lifecycle/legacy-digest.d.ts +59 -0
- package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
- package/dist/lifecycle/member-ledger.d.ts +67 -0
- package/dist/lifecycle/member-ledger.d.ts.map +1 -0
- package/dist/lifecycle/plan-digest.d.ts +4 -3
- package/dist/lifecycle/plan-digest.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.d.ts +42 -1
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +90 -5
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +32 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts +75 -0
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -0
- package/dist/op/activity-contract-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +7 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +16 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/project-root.d.ts +26 -0
- package/dist/project-root.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/contract-repo.d.ts +37 -0
- package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -0
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts +11 -0
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -0
- package/dist/workspace/checks/generated.d.ts +156 -0
- package/dist/workspace/checks/generated.d.ts.map +1 -0
- package/dist/workspace/checks/ledgers.d.ts +78 -0
- package/dist/workspace/checks/ledgers.d.ts.map +1 -0
- package/dist/workspace/checks/links.d.ts +25 -0
- package/dist/workspace/checks/links.d.ts.map +1 -0
- package/dist/workspace/checks/pipelines.d.ts +76 -0
- package/dist/workspace/checks/pipelines.d.ts.map +1 -0
- package/dist/workspace/checks.d.ts +161 -0
- package/dist/workspace/checks.d.ts.map +1 -0
- package/dist/workspace/compose-graph.d.ts +115 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -0
- package/dist/workspace/compose-reports.d.ts +68 -0
- package/dist/workspace/compose-reports.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +220 -0
- package/dist/workspace/declaration.d.ts.map +1 -0
- package/dist/workspace/declaration.schema.json +434 -0
- package/dist/workspace/generated-files.d.ts +65 -0
- package/dist/workspace/generated-files.d.ts.map +1 -0
- package/dist/workspace/glob.d.ts +13 -0
- package/dist/workspace/glob.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +75 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -0
- package/dist/workspace/init.d.ts +96 -0
- package/dist/workspace/init.d.ts.map +1 -0
- package/dist/workspace/jsonc.d.ts +38 -0
- package/dist/workspace/jsonc.d.ts.map +1 -0
- package/dist/workspace/kinds.d.ts +192 -0
- package/dist/workspace/kinds.d.ts.map +1 -0
- package/dist/workspace/lineage-check.d.ts +84 -0
- package/dist/workspace/lineage-check.d.ts.map +1 -0
- package/dist/workspace/lineage-cli.d.ts +31 -0
- package/dist/workspace/lineage-cli.d.ts.map +1 -0
- package/dist/workspace/lineage-init.d.ts +101 -0
- package/dist/workspace/lineage-init.d.ts.map +1 -0
- package/dist/workspace/lineage-lock.d.ts +258 -0
- package/dist/workspace/lineage-lock.d.ts.map +1 -0
- package/dist/workspace/lineage-merge.d.ts +14 -0
- package/dist/workspace/lineage-merge.d.ts.map +1 -0
- package/dist/workspace/lineage-migrations.d.ts +178 -0
- package/dist/workspace/lineage-migrations.d.ts.map +1 -0
- package/dist/workspace/lineage-update.d.ts +59 -0
- package/dist/workspace/lineage-update.d.ts.map +1 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts +40 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -0
- package/dist/workspace/lineage-upgrade.d.ts +146 -0
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -0
- package/dist/workspace/lineage-vendor.d.ts +63 -0
- package/dist/workspace/lineage-vendor.d.ts.map +1 -0
- package/dist/workspace/lineage-version.d.ts +32 -0
- package/dist/workspace/lineage-version.d.ts.map +1 -0
- package/dist/workspace/links.d.ts +150 -0
- package/dist/workspace/links.d.ts.map +1 -0
- package/dist/workspace/ls.d.ts +99 -0
- package/dist/workspace/ls.d.ts.map +1 -0
- package/dist/workspace/member-commands.d.ts +139 -0
- package/dist/workspace/member-commands.d.ts.map +1 -0
- package/dist/workspace/member-handles.d.ts +54 -0
- package/dist/workspace/member-handles.d.ts.map +1 -0
- package/dist/workspace/member-pipeline.d.ts +143 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -0
- package/dist/workspace/member-run.d.ts +67 -0
- package/dist/workspace/member-run.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +65 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -0
- package/dist/workspace/record-source.d.ts +22 -0
- package/dist/workspace/record-source.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +81 -0
- package/dist/workspace/records-cli.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +118 -0
- package/dist/workspace/records.d.ts.map +1 -0
- package/dist/workspace/root-refusal.d.ts +53 -0
- package/dist/workspace/root-refusal.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +150 -0
- package/dist/workspace/status.d.ts.map +1 -0
- package/dist/workspace/template-manifest.d.ts +90 -0
- package/dist/workspace/template-manifest.d.ts.map +1 -0
- package/dist/workspace/tree.d.ts +36 -0
- package/dist/workspace/tree.d.ts.map +1 -0
- package/dist/workspace/trust/attestor.d.ts +67 -0
- package/dist/workspace/trust/attestor.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +111 -0
- package/dist/workspace/trust/policy.d.ts.map +1 -0
- package/dist/workspace/trust/provenance.d.ts +67 -0
- package/dist/workspace/trust/provenance.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +43 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -0
- package/dist/workspace/trust/test-repo.d.ts +42 -0
- package/dist/workspace/trust/test-repo.d.ts.map +1 -0
- package/dist/workspace/trust/verify-cli.d.ts +9 -0
- package/dist/workspace/trust/verify-cli.d.ts.map +1 -0
- package/dist/workspace/trust/verify.d.ts +66 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -0
- package/dist/workspace/which-chant.d.ts +58 -0
- package/dist/workspace/which-chant.d.ts.map +1 -0
- package/dist/workspace/workspace-kinds.schema.json +133 -0
- package/package.json +4 -3
- package/src/audit/core.ts +13 -2
- package/src/audit/discover.ts +102 -36
- package/src/audit/report-html.ts +3 -1
- package/src/audit/report-model.ts +14 -1
- package/src/audit/terraform-state.test.ts +81 -1
- package/src/audit/terraform-state.ts +25 -0
- package/src/cli/command-group.ts +7 -0
- package/src/cli/commands/audit-truncation.test.ts +167 -0
- package/src/cli/commands/audit.ts +132 -29
- package/src/cli/commands/check-lexicon-examples.ts +2 -1
- package/src/cli/commands/check-lexicon.ts +24 -0
- package/src/cli/commands/doctor.test.ts +30 -0
- package/src/cli/commands/doctor.ts +21 -0
- package/src/cli/commands/import-agents.ts +15 -4
- package/src/cli/commands/init.ts +76 -25
- package/src/cli/commands/lint.test.ts +52 -1
- package/src/cli/commands/lint.ts +41 -71
- package/src/cli/commands/onboard.ts +15 -0
- package/src/cli/commands/update.ts +18 -5
- package/src/cli/commands/vendor.ts +11 -6
- package/src/cli/conflict-check.test.ts +18 -1
- package/src/cli/conflict-check.ts +13 -0
- package/src/cli/discovery-skip.test.ts +168 -0
- package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +38 -0
- package/src/cli/handlers/build-member.test.ts +127 -0
- package/src/cli/handlers/build.test.ts +11 -0
- package/src/cli/handlers/build.ts +35 -1
- package/src/cli/handlers/components.test.ts +127 -0
- package/src/cli/handlers/components.ts +70 -8
- package/src/cli/handlers/dev.ts +6 -1
- package/src/cli/handlers/fan-out.ts +9 -2
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +4 -2
- package/src/cli/handlers/init.ts +54 -1
- package/src/cli/handlers/lifecycle.ts +2 -1
- package/src/cli/handlers/misc.ts +16 -0
- package/src/cli/handlers/operator.test.ts +68 -0
- package/src/cli/handlers/operator.ts +39 -10
- package/src/cli/handlers/promote.test.ts +377 -0
- package/src/cli/handlers/promote.ts +391 -0
- package/src/cli/handlers/run-generate-member.test.ts +83 -0
- package/src/cli/handlers/run-generate.test.ts +158 -0
- package/src/cli/handlers/run-generate.ts +168 -0
- package/src/cli/handlers/run.test.ts +47 -0
- package/src/cli/handlers/run.ts +41 -6
- package/src/cli/handlers/vendor.ts +147 -43
- package/src/cli/main.test.ts +137 -1
- package/src/cli/main.ts +290 -24
- package/src/cli/mcp/op-tools.ts +2 -1
- package/src/cli/path-lexicon-messages.test.ts +152 -0
- package/src/cli/plugins.ts +43 -5
- package/src/cli/registry.ts +46 -1
- package/src/cli/static-config-read.test.ts +208 -0
- package/src/codegen/fetch.test.ts +63 -1
- package/src/codegen/fetch.ts +30 -1
- package/src/components/__fixtures__/alb-ecs-service.json +0 -8
- package/src/components/capability-plugin-loader.ts +3 -1
- package/src/components/cli-support.ts +9 -3
- package/src/components/discover.test.ts +27 -57
- package/src/components/discover.ts +20 -51
- package/src/components/driver.test.ts +90 -12
- package/src/components/driver.ts +43 -5
- package/src/components/fan-out-support.ts +2 -1
- package/src/components/gate-plan.ts +53 -0
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +7 -7
- package/src/components/presets/ecs-fargate.ts +10 -5
- package/src/components/promote.test.ts +645 -0
- package/src/components/promote.ts +641 -0
- package/src/components/sandbox/driver.ts +8 -8
- package/src/components/sandbox/run.ts +4 -1
- package/src/components/verbs/build-archive.ts +27 -21
- package/src/components/verbs/run-agent.ts +3 -5
- package/src/config-static.test.ts +136 -0
- package/src/config-static.ts +232 -0
- package/src/config.test.ts +40 -0
- package/src/config.ts +161 -11
- package/src/content-digest.test.ts +120 -0
- package/src/content-digest.ts +21 -0
- package/src/discovery/files.test.ts +127 -1
- package/src/discovery/files.ts +91 -59
- package/src/discovery/fold-import.ts +127 -9
- package/src/discovery/index.ts +1 -0
- package/src/discovery/sandbox/driver.ts +73 -35
- package/src/discovery/sandbox/fork-diagnostic.test.ts +136 -13
- package/src/discovery/sandbox/fork.ts +83 -43
- package/src/discovery/sandbox/path-lexicon-multifile.test.ts +285 -0
- package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
- package/src/discovery/walk.test.ts +263 -0
- package/src/discovery/walk.ts +334 -0
- package/src/effect-receipt.ts +2 -2
- package/src/graph-ir.ts +18 -1
- package/src/join-key.test.ts +25 -0
- package/src/join-key.ts +39 -0
- package/src/lexicon-module.test.ts +203 -0
- package/src/lexicon-module.ts +215 -0
- package/src/lexicon.ts +105 -1
- package/src/lifecycle/build-ledger-store.ts +13 -2
- package/src/lifecycle/digest.ts +9 -5
- package/src/lifecycle/gate-ledger.test.ts +20 -0
- package/src/lifecycle/gate-ledger.ts +28 -6
- package/src/lifecycle/git.test.ts +13 -1
- package/src/lifecycle/git.ts +221 -176
- package/src/lifecycle/lease.ts +25 -10
- package/src/lifecycle/legacy-digest.test.ts +206 -0
- package/src/lifecycle/legacy-digest.ts +103 -0
- package/src/lifecycle/member-ledger.test.ts +319 -0
- package/src/lifecycle/member-ledger.ts +145 -0
- package/src/lifecycle/plan-digest.ts +7 -7
- package/src/lifecycle/release-ledger.test.ts +20 -0
- package/src/lifecycle/release-ledger.ts +53 -3
- package/src/lifecycle/status.test.ts +151 -1
- package/src/lifecycle/status.ts +228 -7
- package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
- package/src/op/activities/activity-contracts.ts +37 -0
- package/src/op/activities/index.ts +10 -0
- package/src/op/activities/propose-upgrade.ts +206 -0
- package/src/op/activity-contract-registry.ts +18 -4
- package/src/op/activity-registry.ts +26 -0
- package/src/op/discover.test.ts +14 -4
- package/src/op/discover.ts +19 -23
- package/src/op/gate-approval.ts +3 -1
- package/src/op/gate-name.ts +8 -0
- package/src/op/gate-summary.test.ts +14 -0
- package/src/op/gate-summary.ts +25 -2
- package/src/op/gate.ts +36 -7
- package/src/op/generate-pipeline.test.ts +14 -5
- package/src/op/generate-pipeline.ts +2 -1
- package/src/op/runtimes/local.ts +2 -1
- package/src/project-root.test.ts +66 -1
- package/src/project-root.ts +48 -0
- package/src/workspace/__fixtures__/contract-repo.ts +100 -0
- package/src/workspace/__fixtures__/two-member-workspace.ts +35 -0
- package/src/workspace/behold-kinds.test.ts +162 -0
- package/src/workspace/check-contract.test.ts +127 -0
- package/src/workspace/check.schema.json +347 -0
- package/src/workspace/checks/generated.test.ts +287 -0
- package/src/workspace/checks/generated.ts +475 -0
- package/src/workspace/checks/ledgers.test.ts +139 -0
- package/src/workspace/checks/ledgers.ts +246 -0
- package/src/workspace/checks/links.ts +183 -0
- package/src/workspace/checks/pipelines.test.ts +133 -0
- package/src/workspace/checks/pipelines.ts +225 -0
- package/src/workspace/checks-repo.test.ts +71 -0
- package/src/workspace/checks.test.ts +450 -0
- package/src/workspace/checks.ts +461 -0
- package/src/workspace/compose-graph.test.ts +146 -0
- package/src/workspace/compose-graph.ts +228 -0
- package/src/workspace/compose-reports.test.ts +109 -0
- package/src/workspace/compose-reports.ts +186 -0
- package/src/workspace/declaration.schema.json +434 -0
- package/src/workspace/declaration.test.ts +347 -0
- package/src/workspace/declaration.ts +623 -0
- package/src/workspace/generated-files.test.ts +114 -0
- package/src/workspace/generated-files.ts +94 -0
- package/src/workspace/glob.ts +49 -0
- package/src/workspace/graph-cli.ts +220 -0
- package/src/workspace/graph-contract.test.ts +204 -0
- package/src/workspace/graph.schema.json +662 -0
- package/src/workspace/init.test.ts +223 -0
- package/src/workspace/init.ts +585 -0
- package/src/workspace/jsonc.test.ts +65 -0
- package/src/workspace/jsonc.ts +231 -0
- package/src/workspace/kinds.test.ts +288 -0
- package/src/workspace/kinds.ts +506 -0
- package/src/workspace/lineage-check.ts +264 -0
- package/src/workspace/lineage-cli.ts +114 -0
- package/src/workspace/lineage-init.test.ts +231 -0
- package/src/workspace/lineage-init.ts +365 -0
- package/src/workspace/lineage-lock.test.ts +224 -0
- package/src/workspace/lineage-lock.ts +357 -0
- package/src/workspace/lineage-merge.ts +44 -0
- package/src/workspace/lineage-migrations.test.ts +178 -0
- package/src/workspace/lineage-migrations.ts +343 -0
- package/src/workspace/lineage-update.ts +160 -0
- package/src/workspace/lineage-upgrade-cli.ts +151 -0
- package/src/workspace/lineage-upgrade.test.ts +474 -0
- package/src/workspace/lineage-upgrade.ts +624 -0
- package/src/workspace/lineage-vendor.test.ts +126 -0
- package/src/workspace/lineage-vendor.ts +215 -0
- package/src/workspace/lineage-version.ts +127 -0
- package/src/workspace/links.test.ts +344 -0
- package/src/workspace/links.ts +331 -0
- package/src/workspace/ls-contract.test.ts +200 -0
- package/src/workspace/ls.schema.json +190 -0
- package/src/workspace/ls.ts +254 -0
- package/src/workspace/member-commands.e2e.test.ts +244 -0
- package/src/workspace/member-commands.test.ts +217 -0
- package/src/workspace/member-commands.ts +540 -0
- package/src/workspace/member-handles.test.ts +76 -0
- package/src/workspace/member-handles.ts +228 -0
- package/src/workspace/member-pipeline.test.ts +135 -0
- package/src/workspace/member-pipeline.ts +317 -0
- package/src/workspace/member-run.ts +146 -0
- package/src/workspace/read-contract.test.ts +129 -0
- package/src/workspace/reason-codes.test.ts +113 -0
- package/src/workspace/reason-codes.ts +81 -0
- package/src/workspace/record-source.ts +117 -0
- package/src/workspace/records-cli.ts +198 -0
- package/src/workspace/records-contract.test.ts +93 -0
- package/src/workspace/records.schema.json +150 -0
- package/src/workspace/records.test.ts +243 -0
- package/src/workspace/records.ts +384 -0
- package/src/workspace/root-refusal.ts +97 -0
- package/src/workspace/status-contract.test.ts +308 -0
- package/src/workspace/status.schema.json +210 -0
- package/src/workspace/status.ts +412 -0
- package/src/workspace/template-manifest.test.ts +79 -0
- package/src/workspace/template-manifest.ts +221 -0
- package/src/workspace/tree.ts +132 -0
- package/src/workspace/trust/attestor.ts +93 -0
- package/src/workspace/trust/policy.ts +306 -0
- package/src/workspace/trust/provenance.ts +181 -0
- package/src/workspace/trust/ssh-commit.ts +138 -0
- package/src/workspace/trust/test-repo.ts +138 -0
- package/src/workspace/trust/trust.test.ts +397 -0
- package/src/workspace/trust/verify-cli.ts +56 -0
- package/src/workspace/trust/verify.ts +194 -0
- package/src/workspace/which-chant.test.ts +117 -0
- package/src/workspace/which-chant.ts +151 -0
- package/src/workspace/workspace-kinds.schema.json +133 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust policy, always read from the base revision (#2524 D5, Threat model;
|
|
3
|
+
* ws-001, ws-002).
|
|
4
|
+
*
|
|
5
|
+
* A check reads who may sign, and who holds which role, from the target
|
|
6
|
+
* branch tip or `--base <rev>`, never from the change under review. A change
|
|
7
|
+
* can edit its own copy of these files, and those edits have no effect until
|
|
8
|
+
* they are merged: the change is still judged by the policy at base. Editing
|
|
9
|
+
* the policy is itself a protected write (see ./verify.ts).
|
|
10
|
+
*
|
|
11
|
+
* Two files, both optional:
|
|
12
|
+
*
|
|
13
|
+
* - `.chant/allowed_signers` holds the signers, in ssh-keygen's
|
|
14
|
+
* allowed_signers format, so `git log --show-signature` can use the same
|
|
15
|
+
* file. `trust.json` may name another path.
|
|
16
|
+
* - `.chant/trust.json` holds the override for the signers path, the role
|
|
17
|
+
* grants and the adopted commit ranges. Role grants move into
|
|
18
|
+
* `chant.workspace.json` once the declaration exists (#2534).
|
|
19
|
+
*
|
|
20
|
+
* Attestation is opt-in (#2525 rule 6). Without a signers file at base,
|
|
21
|
+
* nothing is verified and every record is `unattested`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { posix } from "node:path";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import type { RecordSource } from "../record-source";
|
|
27
|
+
|
|
28
|
+
/** The trust config, relative to the repository root. Fixed: it is how the signers path is overridden. */
|
|
29
|
+
export const TRUST_CONFIG_PATH = ".chant/trust.json";
|
|
30
|
+
|
|
31
|
+
/** Where the signers are when `trust.json` names no other path. */
|
|
32
|
+
export const DEFAULT_SIGNERS_PATH = ".chant/allowed_signers";
|
|
33
|
+
|
|
34
|
+
// ── allowed_signers ──────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/** Key types ssh-keygen accepts in an allowed_signers line. */
|
|
37
|
+
const KEY_TYPES = new Set([
|
|
38
|
+
"ssh-ed25519",
|
|
39
|
+
"ssh-rsa",
|
|
40
|
+
"ecdsa-sha2-nistp256",
|
|
41
|
+
"ecdsa-sha2-nistp384",
|
|
42
|
+
"ecdsa-sha2-nistp521",
|
|
43
|
+
"sk-ssh-ed25519@openssh.com",
|
|
44
|
+
"sk-ecdsa-sha2-nistp256@openssh.com",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** One usable signer: a principal and the public key it signs with. */
|
|
48
|
+
export interface Signer {
|
|
49
|
+
principal: string;
|
|
50
|
+
/** `<type> <base64>`, with no comment. */
|
|
51
|
+
key: string;
|
|
52
|
+
/** The `namespaces="..."` option as written, or undefined when the key may sign any namespace. */
|
|
53
|
+
namespaces?: string;
|
|
54
|
+
/** 1-based line in the signers file. */
|
|
55
|
+
line: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A line of the signers file that is not used, and why. */
|
|
59
|
+
export interface ExcludedSigner {
|
|
60
|
+
line: number;
|
|
61
|
+
reason: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SignerSet {
|
|
65
|
+
signers: Signer[];
|
|
66
|
+
excluded: ExcludedSigner[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Split one line into fields. Double quotes group, so an option such as
|
|
71
|
+
* `namespaces="git,file"` stays one field.
|
|
72
|
+
*/
|
|
73
|
+
function fields(line: string): string[] {
|
|
74
|
+
const out: string[] = [];
|
|
75
|
+
let cur = "";
|
|
76
|
+
let quoted = false;
|
|
77
|
+
let any = false;
|
|
78
|
+
for (const ch of line) {
|
|
79
|
+
if (ch === '"') {
|
|
80
|
+
quoted = !quoted;
|
|
81
|
+
cur += ch;
|
|
82
|
+
any = true;
|
|
83
|
+
} else if (!quoted && (ch === " " || ch === "\t")) {
|
|
84
|
+
if (any) out.push(cur);
|
|
85
|
+
cur = "";
|
|
86
|
+
any = false;
|
|
87
|
+
} else {
|
|
88
|
+
cur += ch;
|
|
89
|
+
any = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (any) out.push(cur);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `a="x,y",b` as `{ a: "x,y", b: "" }`. */
|
|
97
|
+
function options(field: string): Map<string, string> {
|
|
98
|
+
const opts = new Map<string, string>();
|
|
99
|
+
let cur = "";
|
|
100
|
+
let quoted = false;
|
|
101
|
+
const flush = () => {
|
|
102
|
+
if (!cur) return;
|
|
103
|
+
const eq = cur.indexOf("=");
|
|
104
|
+
const name = (eq < 0 ? cur : cur.slice(0, eq)).toLowerCase();
|
|
105
|
+
const value = eq < 0 ? "" : cur.slice(eq + 1).replace(/^"|"$/g, "");
|
|
106
|
+
opts.set(name, value);
|
|
107
|
+
cur = "";
|
|
108
|
+
};
|
|
109
|
+
for (const ch of field) {
|
|
110
|
+
if (ch === '"') quoted = !quoted;
|
|
111
|
+
if (ch === "," && !quoted) flush();
|
|
112
|
+
else cur += ch;
|
|
113
|
+
}
|
|
114
|
+
flush();
|
|
115
|
+
return opts;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parse an allowed_signers file, keeping only lines whose meaning does not
|
|
120
|
+
* depend on anything the signer controls.
|
|
121
|
+
*
|
|
122
|
+
* Refused, each with a reason, rather than passed to ssh-keygen:
|
|
123
|
+
*
|
|
124
|
+
* - `valid-after` and `valid-before`. git checks them against the commit's own
|
|
125
|
+
* date, which whoever makes the commit sets. A backdated commit would pass a
|
|
126
|
+
* window its key has left. Revocation is by position in history (#2553),
|
|
127
|
+
* never by date.
|
|
128
|
+
* - `cert-authority`. A certificate's validity is also a date, and a CA key
|
|
129
|
+
* vouches for keys this file never names.
|
|
130
|
+
* - Principal patterns (`*`, `?`, `!`). A signer is named by one exact
|
|
131
|
+
* principal, so role grants can name it.
|
|
132
|
+
*/
|
|
133
|
+
export function parseAllowedSigners(text: string): SignerSet {
|
|
134
|
+
const signers: Signer[] = [];
|
|
135
|
+
const excluded: ExcludedSigner[] = [];
|
|
136
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
137
|
+
for (let i = 0; i < lines.length; i++) {
|
|
138
|
+
const line = i + 1;
|
|
139
|
+
const raw = lines[i].trim();
|
|
140
|
+
if (!raw || raw.startsWith("#")) continue;
|
|
141
|
+
const f = fields(raw);
|
|
142
|
+
if (f.length < 3) {
|
|
143
|
+
excluded.push({ line, reason: "expected principals, optional options, a key type and a key" });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const [principals, second] = f;
|
|
147
|
+
const hasOptions = !KEY_TYPES.has(second);
|
|
148
|
+
const type = hasOptions ? f[2] : second;
|
|
149
|
+
const blob = hasOptions ? f[3] : f[2];
|
|
150
|
+
if (!KEY_TYPES.has(type) || !blob || !/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) {
|
|
151
|
+
excluded.push({ line, reason: `no supported public key (${type ?? "none"})` });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const opts = hasOptions ? options(second) : new Map<string, string>();
|
|
155
|
+
const timed = ["valid-after", "valid-before"].filter((o) => opts.has(o));
|
|
156
|
+
if (timed.length > 0) {
|
|
157
|
+
excluded.push({
|
|
158
|
+
line,
|
|
159
|
+
reason: `${timed.join(" and ")} is checked against the commit's own date, which its author sets; revoke a key by removing it`,
|
|
160
|
+
});
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (opts.has("cert-authority")) {
|
|
164
|
+
excluded.push({ line, reason: "cert-authority keys are not supported: a certificate's validity is a date the signer controls" });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const unknown = [...opts.keys()].filter((o) => o !== "namespaces");
|
|
168
|
+
if (unknown.length > 0) {
|
|
169
|
+
excluded.push({ line, reason: `unknown option ${unknown.join(", ")}` });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const names = principals.replace(/^"|"$/g, "").split(",").filter(Boolean);
|
|
173
|
+
const pattern = names.find((p) => /[*?!]/.test(p));
|
|
174
|
+
if (pattern !== undefined || names.length === 0) {
|
|
175
|
+
excluded.push({ line, reason: `principal ${JSON.stringify(pattern ?? principals)} is a pattern; name one signer exactly` });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
for (const principal of names) {
|
|
179
|
+
signers.push({ principal, key: `${type} ${blob}`, namespaces: opts.get("namespaces"), line });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { signers, excluded };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The signers as an allowed_signers file ssh-keygen reads, holding only the usable lines. */
|
|
186
|
+
export function renderAllowedSigners(signers: Signer[]): string {
|
|
187
|
+
return (
|
|
188
|
+
signers
|
|
189
|
+
.map((s) => `${JSON.stringify(s.principal)}${s.namespaces !== undefined ? ` namespaces=${JSON.stringify(s.namespaces)}` : ""} ${s.key}`)
|
|
190
|
+
.join("\n") + "\n"
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── trust.json ───────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
const fullCommit = z.string().regex(/^[0-9a-f]{40}([0-9a-f]{24})?$/, "a full commit id");
|
|
197
|
+
|
|
198
|
+
/** A repository-relative path: no `..`, not absolute, `/` separators. */
|
|
199
|
+
const repoPath = z
|
|
200
|
+
.string()
|
|
201
|
+
.min(1)
|
|
202
|
+
.refine((p) => !p.startsWith("/") && !p.includes("\\") && posix.normalize(p) === p && !p.split("/").includes(".."), {
|
|
203
|
+
message: "a path inside the repository, with / separators and no ..",
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
export const trustConfigSchema = z
|
|
207
|
+
.object({
|
|
208
|
+
schema: z.literal(1),
|
|
209
|
+
/** Where the signers file is, instead of `.chant/allowed_signers`. */
|
|
210
|
+
signers: repoPath.optional(),
|
|
211
|
+
/**
|
|
212
|
+
* Role name to the principals holding it. Until #2534 these live here;
|
|
213
|
+
* then they move into the declaration.
|
|
214
|
+
*/
|
|
215
|
+
roles: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/), z.array(z.string().min(1))).optional(),
|
|
216
|
+
/**
|
|
217
|
+
* Commit ranges admitted without signatures (#2524 D5, adoption). Each is
|
|
218
|
+
* exact: `to` and, when given, `from` are full commit ids, and the range is
|
|
219
|
+
* the commits `from..to`.
|
|
220
|
+
*/
|
|
221
|
+
adopted: z.array(z.object({ from: fullCommit.optional(), to: fullCommit, note: z.string().optional() }).strict()).optional(),
|
|
222
|
+
})
|
|
223
|
+
.strict();
|
|
224
|
+
|
|
225
|
+
export type TrustConfig = z.infer<typeof trustConfigSchema>;
|
|
226
|
+
|
|
227
|
+
/** The role whose holders may change the policy. Without any grant of it, every signer may. */
|
|
228
|
+
export const ADMIN_ROLE = "admin";
|
|
229
|
+
|
|
230
|
+
/** The policy a check applies, as read at base. */
|
|
231
|
+
export interface TrustPolicy {
|
|
232
|
+
/** The full commit id the policy was read at, or null when there is no base. */
|
|
233
|
+
base: string | null;
|
|
234
|
+
/** The signers path used. */
|
|
235
|
+
signersPath: string;
|
|
236
|
+
/** Whether a signers file exists at base. False means attestation is off. */
|
|
237
|
+
active: boolean;
|
|
238
|
+
signers: Signer[];
|
|
239
|
+
excluded: ExcludedSigner[];
|
|
240
|
+
roles: Record<string, string[]>;
|
|
241
|
+
adopted: Array<{ from?: string; to: string }>;
|
|
242
|
+
/** Why the policy could not be read, when it could not. Nothing verifies then. */
|
|
243
|
+
problems: string[];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Paths whose change is a protected write under `policy`. */
|
|
247
|
+
export function protectedPaths(policy: TrustPolicy): string[] {
|
|
248
|
+
return [...new Set([TRUST_CONFIG_PATH, policy.signersPath])].sort();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Principals allowed to change the policy: the admins when any are granted, otherwise every signer. */
|
|
252
|
+
export function policyWriters(policy: TrustPolicy): Set<string> {
|
|
253
|
+
const admins = policy.roles[ADMIN_ROLE];
|
|
254
|
+
if (admins && admins.length > 0) return new Set(admins);
|
|
255
|
+
return new Set(policy.signers.map((s) => s.principal));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A policy with nothing in it: attestation off. */
|
|
259
|
+
export function emptyPolicy(base: string | null, problems: string[] = []): TrustPolicy {
|
|
260
|
+
return { base, signersPath: DEFAULT_SIGNERS_PATH, active: false, signers: [], excluded: [], roles: {}, adopted: [], problems };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Read the policy through `source`, which must read the base revision.
|
|
265
|
+
* Never throws: a policy that cannot be read yields an inactive policy with
|
|
266
|
+
* its problems listed, so nothing is attested (fail closed).
|
|
267
|
+
*/
|
|
268
|
+
export function readTrustPolicy(source: RecordSource, base: string | null): TrustPolicy {
|
|
269
|
+
let config: TrustConfig = { schema: 1 };
|
|
270
|
+
const configText = readOptional(source, TRUST_CONFIG_PATH);
|
|
271
|
+
if (configText !== undefined) {
|
|
272
|
+
let raw: unknown;
|
|
273
|
+
try {
|
|
274
|
+
raw = JSON.parse(configText);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
return emptyPolicy(base, [`${TRUST_CONFIG_PATH} is not JSON: ${err instanceof Error ? err.message : String(err)}`]);
|
|
277
|
+
}
|
|
278
|
+
const parsed = trustConfigSchema.safeParse(raw);
|
|
279
|
+
if (!parsed.success) {
|
|
280
|
+
const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "/"}: ${i.message}`).join("; ");
|
|
281
|
+
return emptyPolicy(base, [`${TRUST_CONFIG_PATH} is invalid: ${detail}`]);
|
|
282
|
+
}
|
|
283
|
+
config = parsed.data;
|
|
284
|
+
}
|
|
285
|
+
const signersPath = config.signers ?? DEFAULT_SIGNERS_PATH;
|
|
286
|
+
const text = readOptional(source, signersPath);
|
|
287
|
+
const set = text === undefined ? { signers: [], excluded: [] } : parseAllowedSigners(text);
|
|
288
|
+
return {
|
|
289
|
+
base,
|
|
290
|
+
signersPath,
|
|
291
|
+
active: text !== undefined,
|
|
292
|
+
signers: set.signers,
|
|
293
|
+
excluded: set.excluded,
|
|
294
|
+
roles: config.roles ?? {},
|
|
295
|
+
adopted: config.adopted ?? [],
|
|
296
|
+
problems: [],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A file's text through `source`, or undefined when it is not there. */
|
|
301
|
+
export function readOptional(source: RecordSource, path: string): string | undefined {
|
|
302
|
+
const dir = posix.dirname(path);
|
|
303
|
+
const names = source.list(dir);
|
|
304
|
+
if (!names || !names.includes(posix.basename(path))) return undefined;
|
|
305
|
+
return source.read(path);
|
|
306
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provenance level of each record (#2524 D5, Threat model).
|
|
3
|
+
*
|
|
4
|
+
* A record's content comes from the last commit that changed its file. That
|
|
5
|
+
* commit is asked of each attestor, with the policy read at base. A file with
|
|
6
|
+
* uncommitted changes is `unattested`: its bytes are in no commit, so nothing
|
|
7
|
+
* vouches for them.
|
|
8
|
+
*
|
|
9
|
+
* On a developer machine this detects; it does not enforce (ws-002). An agent
|
|
10
|
+
* running as the developer can use an unlocked signing agent or skip the check.
|
|
11
|
+
* Enforcement is CI running the same check against base.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { gitRevisionSource } from "../record-source";
|
|
16
|
+
import { attestCommit, type CommitAttestor, type ProvenanceLevel } from "./attestor";
|
|
17
|
+
import { emptyPolicy, readTrustPolicy, type TrustPolicy } from "./policy";
|
|
18
|
+
|
|
19
|
+
/** What `records` reports for one record. */
|
|
20
|
+
export interface RecordProvenance {
|
|
21
|
+
level: ProvenanceLevel;
|
|
22
|
+
/** The commit the record's content comes from, or null for uncommitted content. */
|
|
23
|
+
commit: string | null;
|
|
24
|
+
attestor?: string;
|
|
25
|
+
principal?: string;
|
|
26
|
+
key?: string;
|
|
27
|
+
reason: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Where the base revision came from. */
|
|
31
|
+
export type BaseSource = "flag" | "origin/HEAD" | "main" | "master";
|
|
32
|
+
|
|
33
|
+
export interface ResolvedBase {
|
|
34
|
+
commit: string | null;
|
|
35
|
+
from: BaseSource | null;
|
|
36
|
+
/** Why there is no base, when there is none. */
|
|
37
|
+
problem?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function git(repo: string, args: string[]): string {
|
|
41
|
+
return execFileSync("git", args, { cwd: repo, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function tryCommit(repo: string, rev: string): string | undefined {
|
|
45
|
+
try {
|
|
46
|
+
return git(repo, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]).trim() || undefined;
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The base revision: `--base <rev>` when given, otherwise the target branch
|
|
54
|
+
* tip, taken as `origin/HEAD`, then a local `main`, then `master`. Never
|
|
55
|
+
* `HEAD`, which is the change under review.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveBase(repo: string, explicit?: string): ResolvedBase {
|
|
58
|
+
if (explicit !== undefined) {
|
|
59
|
+
const c = explicit.startsWith("-") ? undefined : tryCommit(repo, explicit);
|
|
60
|
+
return c ? { commit: c, from: "flag" } : { commit: null, from: null, problem: `--base ${explicit} names no commit in this repository` };
|
|
61
|
+
}
|
|
62
|
+
for (const [rev, from] of [
|
|
63
|
+
["refs/remotes/origin/HEAD", "origin/HEAD"],
|
|
64
|
+
["refs/heads/main", "main"],
|
|
65
|
+
["refs/heads/master", "master"],
|
|
66
|
+
] as const) {
|
|
67
|
+
const c = tryCommit(repo, rev);
|
|
68
|
+
if (c) return { commit: c, from };
|
|
69
|
+
}
|
|
70
|
+
return { commit: null, from: null, problem: "no base revision: pass --base <rev> (there is no origin/HEAD, main or master)" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The policy at `base`, or an inactive one when there is no base. */
|
|
74
|
+
export function policyAtBase(repo: string, base: ResolvedBase): TrustPolicy {
|
|
75
|
+
if (!base.commit) return emptyPolicy(null, base.problem ? [base.problem] : []);
|
|
76
|
+
return readTrustPolicy(gitRevisionSource(repo, base.commit), base.commit);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The last commit reachable from `rev` that changed each path. Merges are
|
|
81
|
+
* diffed against all their parents (`-c`), so a merge that changes a file
|
|
82
|
+
* beyond what either side brought is the commit that file comes from, and a
|
|
83
|
+
* merge that only takes one side's version is passed through to that side.
|
|
84
|
+
*/
|
|
85
|
+
export function lastCommits(repo: string, rev: string, paths: string[]): Map<string, string> {
|
|
86
|
+
const out = new Map<string, string>();
|
|
87
|
+
if (paths.length === 0) return out;
|
|
88
|
+
const log = git(repo, ["--literal-pathspecs", "log", "-c", "--name-only", "--no-renames", "--format=%x00%H", rev, "--", ...paths]);
|
|
89
|
+
const wanted = new Set(paths);
|
|
90
|
+
let commit = "";
|
|
91
|
+
for (const line of log.split("\n")) {
|
|
92
|
+
if (line.startsWith("\0")) commit = line.slice(1).trim();
|
|
93
|
+
else if (line && wanted.has(line) && !out.has(line)) out.set(line, commit);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Paths with staged, unstaged or untracked changes in the working tree. */
|
|
99
|
+
export function dirtyPaths(repo: string, paths: string[]): Set<string> {
|
|
100
|
+
if (paths.length === 0) return new Set();
|
|
101
|
+
const status = git(repo, ["--literal-pathspecs", "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ...paths]);
|
|
102
|
+
const dirty = new Set<string>();
|
|
103
|
+
const parts = status.split("\0");
|
|
104
|
+
for (let i = 0; i < parts.length; i++) {
|
|
105
|
+
const p = parts[i];
|
|
106
|
+
if (p.length < 4) continue;
|
|
107
|
+
dirty.add(p.slice(3));
|
|
108
|
+
if (p[0] === "R" || p[0] === "C") i++;
|
|
109
|
+
}
|
|
110
|
+
return dirty;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isAncestor(repo: string, a: string, b: string): boolean {
|
|
114
|
+
try {
|
|
115
|
+
execFileSync("git", ["merge-base", "--is-ancestor", a, b], { cwd: repo, stdio: "ignore" });
|
|
116
|
+
return true;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether `commit` lies in a range the policy adopts (`from..to`, exact). */
|
|
123
|
+
export function isAdopted(repo: string, policy: TrustPolicy, commit: string): boolean {
|
|
124
|
+
return policy.adopted.some((r) => isAncestor(repo, commit, r.to) && (r.from === undefined || !isAncestor(repo, commit, r.from)));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A commit's provenance level under `policy`. */
|
|
128
|
+
export function commitProvenance(repo: string, policy: TrustPolicy, commit: string, attestors: readonly CommitAttestor[]): RecordProvenance {
|
|
129
|
+
const a = attestCommit({ repo, policy }, commit, attestors);
|
|
130
|
+
if (a.level === "unattested" && isAdopted(repo, policy, commit)) {
|
|
131
|
+
return { level: "adopted", commit, attestor: a.attestor, reason: `in a commit range the policy at base adopts; ${a.reason}` };
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
level: a.level,
|
|
135
|
+
commit,
|
|
136
|
+
attestor: a.attestor,
|
|
137
|
+
...(a.principal ? { principal: a.principal } : {}),
|
|
138
|
+
...(a.key ? { key: a.key } : {}),
|
|
139
|
+
reason: a.reason,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface ProvenanceQuery {
|
|
144
|
+
repo: string | undefined;
|
|
145
|
+
policy: TrustPolicy;
|
|
146
|
+
/** The commit records were read at, or null for the working tree. */
|
|
147
|
+
at: string | null;
|
|
148
|
+
paths: string[];
|
|
149
|
+
attestors: readonly CommitAttestor[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The provenance of each path. With attestation off (no signers file at
|
|
154
|
+
* base) nothing is checked and every path is `unattested`.
|
|
155
|
+
*/
|
|
156
|
+
export function recordProvenance(q: ProvenanceQuery): Map<string, RecordProvenance> {
|
|
157
|
+
const out = new Map<string, RecordProvenance>();
|
|
158
|
+
const all = (reason: string) => {
|
|
159
|
+
for (const p of q.paths) out.set(p, { level: "unattested", commit: null, reason });
|
|
160
|
+
return out;
|
|
161
|
+
};
|
|
162
|
+
if (!q.repo) return all("not in a git repository");
|
|
163
|
+
if (q.policy.problems.length > 0) return all(q.policy.problems.join("; "));
|
|
164
|
+
if (!q.policy.active) return all(`no signers file (${q.policy.signersPath}) at base ${q.policy.base?.slice(0, 8)}; attestation is off`);
|
|
165
|
+
|
|
166
|
+
const dirty = q.at === null ? dirtyPaths(q.repo, q.paths) : new Set<string>();
|
|
167
|
+
const commits = lastCommits(q.repo, q.at ?? "HEAD", q.paths.filter((p) => !dirty.has(p)));
|
|
168
|
+
for (const p of q.paths) {
|
|
169
|
+
if (dirty.has(p)) {
|
|
170
|
+
out.set(p, { level: "unattested", commit: null, reason: "the working tree has uncommitted changes to this file" });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const c = commits.get(p);
|
|
174
|
+
if (!c) {
|
|
175
|
+
out.set(p, { level: "unattested", commit: null, reason: "no commit holds this file" });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
out.set(p, commitProvenance(q.repo, q.policy, c, q.attestors));
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The first attestor: ssh-signed commits, checked against the signers the
|
|
3
|
+
* base policy lists (#2524 D5, ws-001).
|
|
4
|
+
*
|
|
5
|
+
* It does not ask `git verify-commit`. That would read `gpg.ssh.program`,
|
|
6
|
+
* `gpg.ssh.allowedSignersFile` and the rest from git config, which whoever
|
|
7
|
+
* controls the machine can set, and it passes the commit's own date to
|
|
8
|
+
* ssh-keygen as the verification time. Instead it takes the signature and the
|
|
9
|
+
* signed payload out of the commit object itself and hands them to
|
|
10
|
+
* `ssh-keygen -Y verify` with a signers file built from the base policy, in
|
|
11
|
+
* the `git` namespace.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
15
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import type { AttestorContext, CommitAttestation, CommitAttestor } from "./attestor";
|
|
19
|
+
import { renderAllowedSigners, type Signer } from "./policy";
|
|
20
|
+
|
|
21
|
+
const NAME = "ssh-commit";
|
|
22
|
+
|
|
23
|
+
/** A commit object split into its signed payload and its signature. */
|
|
24
|
+
export interface SignedCommit {
|
|
25
|
+
/** The bytes the signature covers: the commit object without its signature header. */
|
|
26
|
+
payload: Buffer;
|
|
27
|
+
/** The armored signature, or undefined when the commit is unsigned. */
|
|
28
|
+
signature?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Split a raw commit object. `header` is `gpgsig` in a SHA-1 repository and
|
|
33
|
+
* `gpgsig-sha256` in a SHA-256 one; git removes exactly that header, with its
|
|
34
|
+
* continuation lines, to get the bytes it signed.
|
|
35
|
+
*/
|
|
36
|
+
export function splitSignedCommit(raw: Buffer, header = "gpgsig"): SignedCommit {
|
|
37
|
+
const end = raw.indexOf("\n\n");
|
|
38
|
+
const headEnd = end < 0 ? raw.length : end;
|
|
39
|
+
const head = raw.subarray(0, headEnd).toString("latin1").split("\n");
|
|
40
|
+
const kept: string[] = [];
|
|
41
|
+
const sig: string[] = [];
|
|
42
|
+
let inSig = false;
|
|
43
|
+
for (const line of head) {
|
|
44
|
+
if (inSig && line.startsWith(" ")) {
|
|
45
|
+
sig.push(line.slice(1));
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
inSig = false;
|
|
49
|
+
if (sig.length === 0 && line.startsWith(`${header} `)) {
|
|
50
|
+
inSig = true;
|
|
51
|
+
sig.push(line.slice(header.length + 1));
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
kept.push(line);
|
|
55
|
+
}
|
|
56
|
+
const payload = Buffer.concat([Buffer.from(kept.join("\n"), "latin1"), raw.subarray(headEnd)]);
|
|
57
|
+
return { payload, signature: sig.length > 0 ? sig.join("\n") + "\n" : undefined };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function objectFormat(repo: string): string {
|
|
61
|
+
try {
|
|
62
|
+
return execFileSync("git", ["rev-parse", "--show-object-format"], { cwd: repo, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
63
|
+
} catch {
|
|
64
|
+
return "sha1";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Run ssh-keygen; `missing` is true when it is not installed. */
|
|
69
|
+
function sshKeygen(args: string[], input?: Buffer): { status: number | null; stdout: string; stderr: string; missing: boolean } {
|
|
70
|
+
const r = spawnSync("ssh-keygen", args, { input, encoding: "buffer", timeout: 30_000 });
|
|
71
|
+
const missing = (r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
|
72
|
+
return { status: r.status, stdout: r.stdout?.toString("utf-8") ?? "", stderr: r.stderr?.toString("utf-8") ?? "", missing };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const cache = new Map<string, CommitAttestation>();
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check a detached ssh signature over `payload` in `namespace` against
|
|
79
|
+
* `signers`. Shared with the rotation check (#2553), which signs signer sets
|
|
80
|
+
* in their own namespace so a commit signature can never stand in for one.
|
|
81
|
+
*/
|
|
82
|
+
export function verifySshSignature(
|
|
83
|
+
signers: Signer[],
|
|
84
|
+
payload: Buffer,
|
|
85
|
+
signature: string,
|
|
86
|
+
namespace: string,
|
|
87
|
+
): { ok: true; principal: string; key?: string } | { ok: false; missing: boolean; reason: string } {
|
|
88
|
+
if (signers.length === 0) return { ok: false, missing: false, reason: "the policy lists no usable signer" };
|
|
89
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-trust-"));
|
|
90
|
+
try {
|
|
91
|
+
const allowed = join(dir, "allowed_signers");
|
|
92
|
+
const sigFile = join(dir, "signature");
|
|
93
|
+
writeFileSync(allowed, renderAllowedSigners(signers));
|
|
94
|
+
writeFileSync(sigFile, signature);
|
|
95
|
+
const found = sshKeygen(["-Y", "find-principals", "-f", allowed, "-s", sigFile]);
|
|
96
|
+
if (found.missing) return { ok: false, missing: true, reason: "ssh-keygen is not installed here" };
|
|
97
|
+
const principals = found.status === 0 ? found.stdout.split("\n").map((l) => l.trim()).filter(Boolean) : [];
|
|
98
|
+
if (principals.length === 0) return { ok: false, missing: false, reason: "signed by a key the policy at base does not list" };
|
|
99
|
+
for (const principal of principals) {
|
|
100
|
+
const v = sshKeygen(["-Y", "verify", "-f", allowed, "-I", principal, "-n", namespace, "-s", sigFile], payload);
|
|
101
|
+
if (v.status === 0) {
|
|
102
|
+
const key = /key (SHA256:[A-Za-z0-9+/=]+)/.exec(v.stdout + v.stderr)?.[1];
|
|
103
|
+
return { ok: true, principal, ...(key ? { key } : {}) };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { ok: false, missing: false, reason: `the signature does not verify in the ${namespace} namespace for a key the policy lists` };
|
|
107
|
+
} finally {
|
|
108
|
+
rmSync(dir, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const sshCommitAttestor: CommitAttestor = {
|
|
113
|
+
name: NAME,
|
|
114
|
+
attestCommit(ctx: AttestorContext, commit: string): CommitAttestation {
|
|
115
|
+
const signers = ctx.policy.signers;
|
|
116
|
+
const memo = `${ctx.repo}\0${commit}\0${renderAllowedSigners(signers)}`;
|
|
117
|
+
const hit = cache.get(memo);
|
|
118
|
+
if (hit) return hit;
|
|
119
|
+
const answer = attest(ctx.repo, commit, signers);
|
|
120
|
+
cache.set(memo, answer);
|
|
121
|
+
return answer;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function attest(repo: string, commit: string, signers: Signer[]): CommitAttestation {
|
|
126
|
+
const raw = execFileSync("git", ["cat-file", "commit", commit], { cwd: repo, maxBuffer: 64 * 1024 * 1024 });
|
|
127
|
+
const header = objectFormat(repo) === "sha256" ? "gpgsig-sha256" : "gpgsig";
|
|
128
|
+
const { payload, signature } = splitSignedCommit(raw, header);
|
|
129
|
+
if (!signature) return { level: "unattested", attestor: NAME, reason: "the commit is not signed" };
|
|
130
|
+
if (!signature.startsWith("-----BEGIN SSH SIGNATURE-----")) {
|
|
131
|
+
const format = signature.startsWith("-----BEGIN PGP SIGNATURE-----") ? "an OpenPGP" : "a non-ssh";
|
|
132
|
+
return { level: "attested-unverifiable-here", attestor: NAME, reason: `the commit carries ${format} signature, which this attestor does not check` };
|
|
133
|
+
}
|
|
134
|
+
const r = verifySshSignature(signers, payload, signature, "git");
|
|
135
|
+
if (r.ok) return { level: "attested", attestor: NAME, principal: r.principal, ...(r.key ? { key: r.key } : {}), reason: `signed by ${r.principal}` };
|
|
136
|
+
if (r.missing) return { level: "attested-unverifiable-here", attestor: NAME, reason: r.reason };
|
|
137
|
+
return { level: "unattested", attestor: NAME, reason: r.reason };
|
|
138
|
+
}
|