approval-md 0.1.0 → 0.3.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/README.md +629 -559
- package/SPEC.md +99 -24
- package/dist/src/adapters/agentmail.d.ts +426 -0
- package/dist/src/adapters/agentmail.js +2 -2
- package/dist/src/adapters/conformance.d.ts +149 -0
- package/dist/src/adapters/contract.d.ts +628 -0
- package/dist/src/adapters/contract.js +110 -16
- package/dist/src/adapters/contract.js.map +1 -1
- package/dist/src/adapters/email.d.ts +324 -0
- package/dist/src/adapters/env-passphrase.d.ts +93 -0
- package/dist/src/adapters/public.d.ts +11 -0
- package/dist/src/adapters/public.js +11 -0
- package/dist/src/adapters/public.js.map +1 -0
- package/dist/src/adapters/registry.d.ts +59 -0
- package/dist/src/adapters/registry.js +2 -1
- package/dist/src/adapters/registry.js.map +1 -1
- package/dist/src/adapters/smtp.d.ts +213 -0
- package/dist/src/adapters/vault-provider.d.ts +114 -0
- package/dist/src/adapters/vault-provider.js +3 -3
- package/dist/src/adapters/zzz.d.ts +66 -0
- package/dist/src/adapters/zzz.js +299 -0
- package/dist/src/adapters/zzz.js.map +1 -0
- package/dist/src/channels/batch.d.ts +109 -0
- package/dist/src/channels/cli.d.ts +193 -0
- package/dist/src/channels/conformance.d.ts +92 -0
- package/dist/src/channels/contract.d.ts +656 -0
- package/dist/src/channels/contract.js +200 -7
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/payload-view.d.ts +35 -0
- package/dist/src/channels/render-queue.d.ts +149 -0
- package/dist/src/channels/tagging.d.ts +196 -0
- package/dist/src/channels/telegram.d.ts +1944 -0
- package/dist/src/channels/telegram.js +218 -23
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/channels/web.d.ts +350 -0
- package/dist/src/channels/web.js +17 -0
- package/dist/src/channels/web.js.map +1 -1
- package/dist/src/cli/adapter.d.ts +90 -0
- package/dist/src/cli/adapter.js +25 -15
- package/dist/src/cli/adapter.js.map +1 -1
- package/dist/src/cli/amend.d.ts +59 -0
- package/dist/src/cli/amend.js +214 -30
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/args.d.ts +43 -0
- package/dist/src/cli/attest.d.ts +50 -0
- package/dist/src/cli/attest.js +134 -7
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/audit-card.d.ts +62 -0
- package/dist/src/cli/audit.d.ts +59 -0
- package/dist/src/cli/channel-telegram.d.ts +879 -0
- package/dist/src/cli/channel-telegram.js +311 -13
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel-web.d.ts +131 -0
- package/dist/src/cli/channel.d.ts +80 -0
- package/dist/src/cli/channel.js +9 -0
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/checkpoint-tap.d.ts +169 -0
- package/dist/src/cli/codex-bridge.d.ts +819 -0
- package/dist/src/cli/codex-bridge.js +1607 -0
- package/dist/src/cli/codex-bridge.js.map +1 -0
- package/dist/src/cli/codex.d.ts +2 -0
- package/dist/src/cli/codex.js +469 -0
- package/dist/src/cli/codex.js.map +1 -0
- package/dist/src/cli/coverage.d.ts +61 -0
- package/dist/src/cli/daemon.d.ts +120 -0
- package/dist/src/cli/daemon.js +4 -1
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +129 -0
- package/dist/src/cli/doctor.js +586 -17
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/env.d.ts +65 -0
- package/dist/src/cli/execute.d.ts +202 -0
- package/dist/src/cli/execute.js +25 -2
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +73 -0
- package/dist/src/cli/feedback.d.ts +60 -0
- package/dist/src/cli/gate-window.d.ts +40 -0
- package/dist/src/cli/gate.d.ts +68 -0
- package/dist/src/cli/git-scope.d.ts +190 -0
- package/dist/src/cli/gloss-attach.d.ts +85 -0
- package/dist/src/cli/gloss-codex-child.d.ts +9 -0
- package/dist/src/cli/gloss-codex.d.ts +24 -0
- package/dist/src/cli/gloss-options.d.ts +42 -0
- package/dist/src/cli/gloss.d.ts +265 -0
- package/dist/src/cli/help.d.ts +107 -0
- package/dist/src/cli/help.js +320 -93
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook-codex.d.ts +126 -0
- package/dist/src/cli/hook-codex.js +226 -0
- package/dist/src/cli/hook-codex.js.map +1 -0
- package/dist/src/cli/hook.d.ts +787 -0
- package/dist/src/cli/hook.js +1235 -181
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/import.d.ts +35 -0
- package/dist/src/cli/import.js +1 -1
- package/dist/src/cli/import.js.map +1 -1
- package/dist/src/cli/init.d.ts +84 -0
- package/dist/src/cli/init.js +2 -2
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.d.ts +23 -0
- package/dist/src/cli/journal.d.ts +41 -0
- package/dist/src/cli/log-advance.d.ts +287 -0
- package/dist/src/cli/log-advance.js +102 -11
- package/dist/src/cli/log-advance.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +176 -0
- package/dist/src/cli/log-checkpoint.d.ts +22 -0
- package/dist/src/cli/log-sync.d.ts +243 -0
- package/dist/src/cli/log-verbs.d.ts +16 -0
- package/dist/src/cli/log-verbs.js +7 -1
- package/dist/src/cli/log-verbs.js.map +1 -1
- package/dist/src/cli/long-help.d.ts +70 -0
- package/dist/src/cli/main.d.ts +77 -0
- package/dist/src/cli/main.js +159 -7
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/mcp.d.ts +52 -0
- package/dist/src/cli/paths.d.ts +56 -0
- package/dist/src/cli/payload.d.ts +58 -0
- package/dist/src/cli/policy-apply.d.ts +195 -0
- package/dist/src/cli/policy-apply.js +573 -0
- package/dist/src/cli/policy-apply.js.map +1 -0
- package/dist/src/cli/policy.d.ts +43 -0
- package/dist/src/cli/policy.js +14 -1
- package/dist/src/cli/policy.js.map +1 -1
- package/dist/src/cli/preflight.d.ts +501 -0
- package/dist/src/cli/preflight.js +689 -45
- package/dist/src/cli/preflight.js.map +1 -1
- package/dist/src/cli/progress.d.ts +78 -0
- package/dist/src/cli/prompt.d.ts +209 -0
- package/dist/src/cli/quickstart.d.ts +46 -0
- package/dist/src/cli/quickstart.js +297 -0
- package/dist/src/cli/quickstart.js.map +1 -0
- package/dist/src/cli/records.d.ts +34 -0
- package/dist/src/cli/render.d.ts +22 -0
- package/dist/src/cli/sandbox.d.ts +51 -0
- package/dist/src/cli/sandbox.js +17 -1
- package/dist/src/cli/sandbox.js.map +1 -1
- package/dist/src/cli/scaffold.d.ts +79 -0
- package/dist/src/cli/scaffold.js +1 -1
- package/dist/src/cli/setup-adapter.d.ts +137 -0
- package/dist/src/cli/setup-adapter.js +38 -4
- package/dist/src/cli/setup-adapter.js.map +1 -1
- package/dist/src/cli/setup-channel.d.ts +126 -0
- package/dist/src/cli/setup-channel.js +28 -1
- package/dist/src/cli/setup-channel.js.map +1 -1
- package/dist/src/cli/setup-checkpoint.d.ts +57 -0
- package/dist/src/cli/setup-common.d.ts +277 -0
- package/dist/src/cli/setup-common.js +3 -2
- package/dist/src/cli/setup-common.js.map +1 -1
- package/dist/src/cli/setup-flow.d.ts +287 -0
- package/dist/src/cli/setup-service.d.ts +96 -0
- package/dist/src/cli/setup.d.ts +204 -0
- package/dist/src/cli/setup.js +94 -2
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/style.d.ts +320 -0
- package/dist/src/cli/token.d.ts +39 -0
- package/dist/src/cli/up.d.ts +155 -0
- package/dist/src/cli/up.js +119 -53
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/usage.d.ts +37 -0
- package/dist/src/cli/values.d.ts +40 -0
- package/dist/src/cli/values.js +3 -4
- package/dist/src/cli/values.js.map +1 -1
- package/dist/src/cli/vault.d.ts +59 -0
- package/dist/src/cli/vault.js +2 -2
- package/dist/src/cli/vault.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +76 -0
- package/dist/src/cli/verb-registry.js +344 -11
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +31 -0
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/codex/broker.d.ts +229 -0
- package/dist/src/codex/broker.js +548 -0
- package/dist/src/codex/broker.js.map +1 -0
- package/dist/src/codex/doctor.d.ts +13 -0
- package/dist/src/codex/doctor.js +41 -0
- package/dist/src/codex/doctor.js.map +1 -0
- package/dist/src/codex/manifest.d.ts +49 -0
- package/dist/src/codex/manifest.js +103 -0
- package/dist/src/codex/manifest.js.map +1 -0
- package/dist/src/codex/runner.d.ts +178 -0
- package/dist/src/codex/runner.js +231 -0
- package/dist/src/codex/runner.js.map +1 -0
- package/dist/src/codex/serve.d.ts +56 -0
- package/dist/src/codex/serve.js +98 -0
- package/dist/src/codex/serve.js.map +1 -0
- package/dist/src/codex/templates.d.ts +41 -0
- package/dist/src/codex/templates.js +319 -0
- package/dist/src/codex/templates.js.map +1 -0
- package/dist/src/codex/trust.d.ts +19 -0
- package/dist/src/codex/trust.js +183 -0
- package/dist/src/codex/trust.js.map +1 -0
- package/dist/src/codex/workspace-commit.d.ts +219 -0
- package/dist/src/codex/workspace-commit.js +549 -0
- package/dist/src/codex/workspace-commit.js.map +1 -0
- package/dist/src/codex/workspace-plan.d.ts +131 -0
- package/dist/src/codex/workspace-plan.js +561 -0
- package/dist/src/codex/workspace-plan.js.map +1 -0
- package/dist/src/core/actor.d.ts +2 -0
- package/dist/src/core/actor.js +5 -0
- package/dist/src/core/actor.js.map +1 -0
- package/dist/src/core/advance-cycle.d.ts +221 -0
- package/dist/src/core/advance-cycle.js +66 -2
- package/dist/src/core/advance-cycle.js.map +1 -1
- package/dist/src/core/agents-md.d.ts +278 -0
- package/dist/src/core/agents-md.js +33 -31
- package/dist/src/core/agents-md.js.map +1 -1
- package/dist/src/core/apply-patch.d.ts +49 -0
- package/dist/src/core/apply-patch.js +266 -0
- package/dist/src/core/apply-patch.js.map +1 -0
- package/dist/src/core/attest.d.ts +635 -0
- package/dist/src/core/attest.js +326 -4
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +510 -0
- package/dist/src/core/audit.js +13 -0
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/budgets.d.ts +238 -0
- package/dist/src/core/channel-owner.d.ts +213 -0
- package/dist/src/core/channel-owner.js +358 -0
- package/dist/src/core/channel-owner.js.map +1 -0
- package/dist/src/core/checkpoint.d.ts +500 -0
- package/dist/src/core/child-env.d.ts +88 -0
- package/dist/src/core/clock.d.ts +52 -0
- package/dist/src/core/command-class.d.ts +697 -0
- package/dist/src/core/command-class.js +713 -25
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/commit-guard.d.ts +272 -0
- package/dist/src/core/commit-guard.js +424 -0
- package/dist/src/core/commit-guard.js.map +1 -0
- package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
- package/dist/src/core/coverage-sources/gh.d.ts +48 -0
- package/dist/src/core/coverage-sources/git.d.ts +101 -0
- package/dist/src/core/coverage.d.ts +217 -0
- package/dist/src/core/credential-spec.d.ts +72 -0
- package/dist/src/core/daemon-actor.d.ts +45 -0
- package/dist/src/core/daemon-actor.js +54 -0
- package/dist/src/core/daemon-actor.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +432 -0
- package/dist/src/core/dark-session.js +266 -82
- package/dist/src/core/dark-session.js.map +1 -1
- package/dist/src/core/decision-refusal.d.ts +206 -0
- package/dist/src/core/decision-refusal.js +24 -2
- package/dist/src/core/decision-refusal.js.map +1 -1
- package/dist/src/core/env-file.d.ts +455 -0
- package/dist/src/core/env-file.js +60 -1
- package/dist/src/core/env-file.js.map +1 -1
- package/dist/src/core/execute.d.ts +871 -0
- package/dist/src/core/execute.js +59 -8
- package/dist/src/core/execute.js.map +1 -1
- package/dist/src/core/frontmatter.d.ts +78 -0
- package/dist/src/core/gate-window.d.ts +312 -0
- package/dist/src/core/gate.d.ts +1449 -0
- package/dist/src/core/gate.js +149 -14
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/gesture-refusal.d.ts +166 -0
- package/dist/src/core/gesture-refusal.js +188 -0
- package/dist/src/core/gesture-refusal.js.map +1 -0
- package/dist/src/core/git-run.d.ts +73 -0
- package/dist/src/core/harness-version.d.ts +157 -0
- package/dist/src/core/harness-version.js +4 -1
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +55 -0
- package/dist/src/core/head-retry.d.ts +107 -0
- package/dist/src/core/instance.d.ts +310 -0
- package/dist/src/core/instance.js +113 -0
- package/dist/src/core/instance.js.map +1 -1
- package/dist/src/core/intake-limits.d.ts +247 -0
- package/dist/src/core/jcs.d.ts +52 -0
- package/dist/src/core/journal.d.ts +144 -0
- package/dist/src/core/live-draw.d.ts +436 -0
- package/dist/src/core/log-reconcile.d.ts +89 -0
- package/dist/src/core/log-subscribe.d.ts +36 -0
- package/dist/src/core/log-subscribe.js +162 -0
- package/dist/src/core/log-subscribe.js.map +1 -0
- package/dist/src/core/log.d.ts +316 -0
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/loop.d.ts +274 -0
- package/dist/src/core/loop.js +11 -0
- package/dist/src/core/loop.js.map +1 -1
- package/dist/src/core/md-fence.d.ts +41 -0
- package/dist/src/core/money.d.ts +147 -0
- package/dist/src/core/payload-census.d.ts +74 -0
- package/dist/src/core/payload-store.d.ts +175 -0
- package/dist/src/core/payload.d.ts +71 -0
- package/dist/src/core/policy-diff.d.ts +292 -0
- package/dist/src/core/policy-diff.js +27 -4
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-expectations.d.ts +199 -0
- package/dist/src/core/policy-explain.d.ts +160 -0
- package/dist/src/core/policy-explain.js +63 -3
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +567 -0
- package/dist/src/core/policy-load.js +36 -6
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +324 -0
- package/dist/src/core/policy-match.js +72 -9
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +317 -0
- package/dist/src/core/policy-proposal.js +102 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/prompt-layout.d.ts +221 -0
- package/dist/src/core/protected-path-guard.d.ts +566 -0
- package/dist/src/core/protected-path-guard.js +848 -55
- package/dist/src/core/protected-path-guard.js.map +1 -1
- package/dist/src/core/question-preempted.d.ts +141 -0
- package/dist/src/core/question-preempted.js +152 -0
- package/dist/src/core/question-preempted.js.map +1 -0
- package/dist/src/core/read-scope.d.ts +172 -0
- package/dist/src/core/read-scope.js +252 -0
- package/dist/src/core/read-scope.js.map +1 -0
- package/dist/src/core/registration.d.ts +25 -0
- package/dist/src/core/reindex.d.ts +99 -0
- package/dist/src/core/sampler.d.ts +313 -0
- package/dist/src/core/sandbox.d.ts +371 -0
- package/dist/src/core/sandbox.js +190 -1
- package/dist/src/core/sandbox.js.map +1 -1
- package/dist/src/core/seal.d.ts +165 -0
- package/dist/src/core/sender-identity.d.ts +476 -0
- package/dist/src/core/sender-identity.js +572 -0
- package/dist/src/core/sender-identity.js.map +1 -0
- package/dist/src/core/shlex.d.ts +102 -0
- package/dist/src/core/shlex.js +159 -0
- package/dist/src/core/shlex.js.map +1 -0
- package/dist/src/core/state.d.ts +505 -0
- package/dist/src/core/task-file.d.ts +185 -0
- package/dist/src/core/telegram-config.d.ts +93 -0
- package/dist/src/core/token.d.ts +409 -0
- package/dist/src/core/token.js +21 -38
- package/dist/src/core/token.js.map +1 -1
- package/dist/src/core/validate.d.ts +138 -0
- package/dist/src/core/values.d.ts +147 -0
- package/dist/src/core/values.js +36 -1
- package/dist/src/core/values.js.map +1 -1
- package/dist/src/core/vault.d.ts +291 -0
- package/dist/src/core/verified-snapshot.d.ts +204 -0
- package/dist/src/core/verify.d.ts +336 -0
- package/dist/src/core/version.d.ts +8 -0
- package/dist/src/core/wysiwys.d.ts +370 -0
- package/dist/src/daemon/advance-child.d.ts +39 -0
- package/dist/src/daemon/advance.d.ts +476 -0
- package/dist/src/daemon/advance.js +25 -4
- package/dist/src/daemon/advance.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +87 -0
- package/dist/src/daemon/daemon.d.ts +1180 -0
- package/dist/src/daemon/daemon.js +9 -0
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/dark-session.d.ts +64 -0
- package/dist/src/daemon/draw-child.d.ts +36 -0
- package/dist/src/daemon/draw.d.ts +154 -0
- package/dist/src/daemon/git-evidence.d.ts +173 -0
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +180 -0
- package/dist/src/daemon/prune.d.ts +207 -0
- package/dist/src/mcp/http.d.ts +113 -0
- package/dist/src/mcp/server.d.ts +265 -0
- package/dist/src/mcp/server.js +17 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/docs/adapter-api.md +106 -0
- package/docs/cli-reference.md +1316 -63
- package/docs/codex-enforced-session.md +103 -0
- package/docs/codex-workspace-broker.md +118 -0
- package/package.json +14 -2
- package/schema/codex-instance.schema.json +82 -0
- package/schema/event.schema.json +539 -9
- package/schema/fixtures/codex-instance/invalid/unpinned-codex-version.json +40 -0
- package/schema/fixtures/codex-instance/valid/canonical.json +40 -0
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
- package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
- package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
- package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
- package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
- package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
- package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
- package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
- package/schema/fixtures/policy/valid/canonical.json +1 -1
- package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
- package/schema/fixtures/policy-md/valid/canonical.md +1 -1
- package/schema/fixtures/policy-md/valid/with-values.md +5 -7
- package/schema/fixtures/values/invalid/class-shaped.json +1 -1
- package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
- package/schema/fixtures/values/invalid/non-string-item.json +1 -1
- package/schema/fixtures/values/invalid/over-cap.json +1 -1
- package/schema/fixtures/values/invalid/unknown-key.json +1 -1
- package/schema/fixtures/values/invalid/version-float.json +1 -0
- package/schema/fixtures/values/invalid/version-integer.json +1 -0
- package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +2 -3
- package/schema/fixtures/values/valid/full.json +5 -7
- package/schema/fixtures/values/valid/minimal.json +1 -1
- package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
- package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
- package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
- package/schema/fixtures/values-md/invalid/version-1.md +69 -0
- package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
- package/schema/fixtures/values-md/valid/absent.md +1 -1
- package/schema/fixtures/values-md/valid/with-values.md +5 -7
- package/schema/policy.schema.json +75 -3
- package/schema/values.schema.json +7 -11
- package/templates/codex/README.md +9 -0
- package/schema/fixtures/values/invalid/version-string.json +0 -1
|
@@ -17,20 +17,32 @@
|
|
|
17
17
|
* party under oversight to report its own oversight, which SPEC.md §11 rules
|
|
18
18
|
* out on principle. So this one never talks to a session. It takes two commits,
|
|
19
19
|
* asks git which protected paths changed between them, and requires — for each
|
|
20
|
-
* one — evidence in the committed hash-chained log that a human
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* one — exact evidence in the committed hash-chained log that either a human
|
|
21
|
+
* granted that edit or the runtime recorded that policy authorized its
|
|
22
|
+
* execution. A change with no evidence fails the pull request. Session wiring
|
|
23
|
+
* is not an input.
|
|
24
|
+
*
|
|
25
|
+
* ## The unit of judgment: one commit (APRV-375)
|
|
26
|
+
*
|
|
27
|
+
* This file answers about ONE PAIR OF BLOBS, whichever pair the caller hands
|
|
28
|
+
* it, so the caller's choice of pair is where the question is really settled.
|
|
29
|
+
* Both callers choose the same one: for every commit of the range, base is that
|
|
30
|
+
* commit's first parent and head is the commit, because a grant binds one edit
|
|
31
|
+
* and the combined diff of a branch is a change nobody made. `core/commit-guard.ts`
|
|
32
|
+
* enumerates the commits, builds the per-commit inputs, states the argument
|
|
33
|
+
* that nothing goes unjudged, and is shared by the CI script and by
|
|
34
|
+
* `approval doctor`'s dark-session arm A.
|
|
23
35
|
*
|
|
24
36
|
* ## What counts as evidence
|
|
25
37
|
*
|
|
26
38
|
* Evidence is about the CHANGE, not about the path (APRV-202). The guard reads
|
|
27
39
|
* the blob at both commits, reduces the difference to the lines this pull
|
|
28
|
-
* request adds and removes, and requires every one of them to trace back to
|
|
29
|
-
* bound material
|
|
30
|
-
* again today has
|
|
31
|
-
* that fails. Naming remains necessary; it stopped being sufficient.
|
|
40
|
+
* request adds and removes, and requires every one of them to trace back to
|
|
41
|
+
* bound material in an authorization record. A path authorized last Tuesday
|
|
42
|
+
* and edited again today has evidence naming it and none covering today's
|
|
43
|
+
* lines, and that fails. Naming remains necessary; it stopped being sufficient.
|
|
32
44
|
*
|
|
33
|
-
*
|
|
45
|
+
* Four verdicts pass:
|
|
34
46
|
*
|
|
35
47
|
* 1. `attested` — the policy file, and the gate's ORGANS. `approval policy
|
|
36
48
|
* amend --commit` appends `policy.updated` carrying `{policy_path, sha256}`,
|
|
@@ -52,7 +64,41 @@
|
|
|
52
64
|
* match: a digest attested for one organ is not evidence for another, which
|
|
53
65
|
* is why the organ record carries a whole relative path where the policy
|
|
54
66
|
* record carries a basename.
|
|
55
|
-
*
|
|
67
|
+
*
|
|
68
|
+
* Since APRV-338 the verdict ALSO covers an ordinary `policy.edit` path on a
|
|
69
|
+
* `gate.path.signed_off` record carrying that path and the digest at head —
|
|
70
|
+
* and it is reached LAST, after every grant search below has failed. That
|
|
71
|
+
* ordering is the whole design. A sign-off is whole-file evidence: it says a
|
|
72
|
+
* human read this file at these bytes, not that they saw a particular line
|
|
73
|
+
* change. A grant binds the hunk. So a change a grant covers passes on the
|
|
74
|
+
* grant and prints the grant as its reason, and the sign-off answers only
|
|
75
|
+
* the case the pending-sign-off suffix was invented for — text a human has
|
|
76
|
+
* read and agrees with, for which no grant was ever taken. The organ's
|
|
77
|
+
* verdict stays first because an organ can have no grant at all, so for one
|
|
78
|
+
* there is nothing weaker to prefer.
|
|
79
|
+
* 2. `policy-authorized-file` — an exact Edit or Write whose verified
|
|
80
|
+
* `execution.started` is preceded by its unique matching registration and no
|
|
81
|
+
* approval request. The registration, start and recomputed stored payload
|
|
82
|
+
* must agree on task, action, class and hash. The start must precede the
|
|
83
|
+
* change and the recorded class must be the class this path is routed to.
|
|
84
|
+
* This records authorization to execute, not successful completion; the
|
|
85
|
+
* exact hunk checks below establish whether those bytes landed.
|
|
86
|
+
*
|
|
87
|
+
* The payload's `file` is the path the hook bound, and the hook binds an
|
|
88
|
+
* ABSOLUTE one: `fileToolGate` resolves the declared target against the
|
|
89
|
+
* session's `cwd`, so an Edit of SPEC.md in a worktree is recorded as
|
|
90
|
+
* `/Users/carter/dev/approval-md/.claude/worktrees/<name>/SPEC.md`. This
|
|
91
|
+
* tier accepts that shape and the bare repository-relative path, matching
|
|
92
|
+
* an absolute one by its trailing segments (see
|
|
93
|
+
* {@link namesProtectedFile}). Until APRV-337 it accepted only the relative
|
|
94
|
+
* shape, which no hook has ever written, so every unsampled
|
|
95
|
+
* supervised-live edit to SPEC.md failed CI (PR #393). Tail matching is
|
|
96
|
+
* sound HERE because the bytes carry the proof: `before` has to occur in
|
|
97
|
+
* the blob at base, `after` in the blob at head, and the replay has to
|
|
98
|
+
* reach HEAD byte-identical, so a scratch copy holding other bytes covers
|
|
99
|
+
* nothing whatever its path says. Verdict 4 below keeps the stricter
|
|
100
|
+
* cwd-join rule, because a command payload describes no bytes.
|
|
101
|
+
* 3. `granted-file` — a file-tool edit. The hook binds the CHANGE rather than
|
|
56
102
|
* the touch (APRV-124), so the bound material carries `file` plus the exact
|
|
57
103
|
* edit: `{before, after}` for an Edit, `{content}` for a Write. That is
|
|
58
104
|
* HUNK-level evidence, and it is used as such. The granted `after` bytes
|
|
@@ -62,7 +108,7 @@
|
|
|
62
108
|
* in head is a grant for something that did not land, and covers nothing.
|
|
63
109
|
* Some payloads carry the `{input}` fallback shape instead, which describes
|
|
64
110
|
* no bytes; those name the path and cover nothing.
|
|
65
|
-
*
|
|
111
|
+
* 4. `granted-command` — a shell edit. The bound material is `{command, cwd}`
|
|
66
112
|
* or `{argv, cwd}`, and the guard re-runs the runtime's own
|
|
67
113
|
* {@link classifyCommand} over it, requiring a segment that classifies as a
|
|
68
114
|
* granting class BECAUSE of a word naming this path. A mention is not a
|
|
@@ -126,6 +172,22 @@
|
|
|
126
172
|
* against the head commit would have been theatre. The finding says which of
|
|
127
173
|
* the two it got, every time.
|
|
128
174
|
*
|
|
175
|
+
* ## Two anchors, because git keeps two dates (APRV-339)
|
|
176
|
+
*
|
|
177
|
+
* A commit carries an author date and a committer date, and they answer
|
|
178
|
+
* different questions. ORDERING — could this record have written these bytes,
|
|
179
|
+
* or does it come after them — is measured against the COMMITTER date, the
|
|
180
|
+
* moment those bytes were committed: an amend or a rebase only ever moves it
|
|
181
|
+
* later, so it cannot turn a genuine earlier start into a post-hoc one.
|
|
182
|
+
* STALENESS — is this evidence about this change or about some edit months ago
|
|
183
|
+
* — is measured against the AUTHOR date, which a rebase does not move, so
|
|
184
|
+
* replaying history does not expire evidence. Measuring both against the author
|
|
185
|
+
* date is what refused PR #393: `git commit --amend` kept the first author date
|
|
186
|
+
* while the bytes were committed 98 seconds later, and the unattended start
|
|
187
|
+
* that wrote them sat between the two and was read as having happened after the
|
|
188
|
+
* change. A caller that has only one date supplies it alone, and it answers
|
|
189
|
+
* both questions, which is where every caller stood before this.
|
|
190
|
+
*
|
|
129
191
|
* ## How a hunk is decided to be covered
|
|
130
192
|
*
|
|
131
193
|
* The unit is a line of text. `added` is the multiset of lines the head blob
|
|
@@ -137,6 +199,18 @@
|
|
|
137
199
|
* several approved edits to one file; the finding names every contributing
|
|
138
200
|
* grant and puts the strongest and nearest at the head.
|
|
139
201
|
*
|
|
202
|
+
* A bound Edit may describe LESS than a line, because the hook binds exactly
|
|
203
|
+
* what the tool replaced: rewriting part of a long paragraph binds a fragment
|
|
204
|
+
* and covers neither the line it removed nor the line it added. So before the
|
|
205
|
+
* replay below there is a line-local step (APRV-340). A fragment that occurs
|
|
206
|
+
* exactly once in exactly one line of the blob at base has one possible
|
|
207
|
+
* effect — that line, rewritten — and when the line it rewrites is one this
|
|
208
|
+
* change removes and the line it produces is one this change adds, both lines
|
|
209
|
+
* are credited to it. It is a one-step replay that needs no search, and it
|
|
210
|
+
* carries every eligibility condition the replay carries; what it does not
|
|
211
|
+
* carry is the replay's budget, which a 200 KB file with forty naming
|
|
212
|
+
* candidates exhausts before reaching a proof (PR #393).
|
|
213
|
+
*
|
|
140
214
|
* Three properties of that choice are worth stating, because each is a limit:
|
|
141
215
|
*
|
|
142
216
|
* - A blob that differs while its line multiset does not is a REORDERING, and
|
|
@@ -190,8 +264,10 @@
|
|
|
190
264
|
* This module appends nothing, reads no clock, and performs no IO of its own —
|
|
191
265
|
* git plumbing and file reads live in the caller.
|
|
192
266
|
*/
|
|
193
|
-
import { organAttestationOf } from "./attest.js";
|
|
194
|
-
import {
|
|
267
|
+
import { organAttestationOf, pathSignOffOf } from "./attest.js";
|
|
268
|
+
import { parseApplyPatch } from "./apply-patch.js";
|
|
269
|
+
import { classifyCommand, isGateOrganPath, isProtectedPath, normalizePathSpelling, POLICY_EDIT_SUBCLASS, protectedPathClass, } from "./command-class.js";
|
|
270
|
+
import { payloadHash } from "./payload.js";
|
|
195
271
|
/**
|
|
196
272
|
* Classes whose grant authorizes a protected-path write.
|
|
197
273
|
*
|
|
@@ -288,6 +364,10 @@ export const DEFAULT_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
288
364
|
* fresh approval, which costs one tap.
|
|
289
365
|
*/
|
|
290
366
|
export const DEFAULT_COMMAND_ATTRIBUTION_MS = 6 * 60 * 60 * 1000;
|
|
367
|
+
/** Fail-closed resource bounds for exact protected-edit reconstruction. */
|
|
368
|
+
export const EXACT_REPLAY_MAX_CANDIDATES = 128;
|
|
369
|
+
export const EXACT_REPLAY_MAX_STATES = 2_048;
|
|
370
|
+
export const EXACT_REPLAY_MAX_EXAMINED_BYTES = 64 * 1024 * 1024;
|
|
291
371
|
/** Split on either separator, dropping empties and `.` noise. */
|
|
292
372
|
function segmentsOf(candidate) {
|
|
293
373
|
return candidate.split(/[/\\]+/u).filter((part) => part.length > 0 && part !== ".");
|
|
@@ -301,6 +381,50 @@ function endsWithSegments(candidate, want) {
|
|
|
301
381
|
const offset = have.length - tail.length;
|
|
302
382
|
return tail.every((segment, index) => segment === have[offset + index]);
|
|
303
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* Is this changed path the POLICY FILE, whichever end carries the longer
|
|
386
|
+
* spelling?
|
|
387
|
+
*
|
|
388
|
+
* The guard is handed a repository-relative path from git and a policy location
|
|
389
|
+
* from its caller, and either may be the more qualified of the two, so the test
|
|
390
|
+
* is run both ways. One named predicate rather than the comparison written out
|
|
391
|
+
* at each of the two sites that ask it.
|
|
392
|
+
*/
|
|
393
|
+
function namesPolicyFile(path, policyPath) {
|
|
394
|
+
return endsWithSegments(policyPath, path) || endsWithSegments(path, policyPath);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Does a bound payload's `file` name this checkout's copy of `path`?
|
|
398
|
+
*
|
|
399
|
+
* The hook writes the ABSOLUTE path: `fileToolGate` resolves the declared
|
|
400
|
+
* target against the session's `cwd`, so a real Edit of SPEC.md in a worktree
|
|
401
|
+
* binds `/Users/carter/dev/approval-md/.claude/worktrees/<name>/SPEC.md`. Two
|
|
402
|
+
* shapes pass: the repository-relative path itself, and an absolute path whose
|
|
403
|
+
* TRAILING segments are that path. A relative path carrying extra leading
|
|
404
|
+
* directories (`dry/SPEC.md`) is refused, as is any `..` segment or backslash,
|
|
405
|
+
* either of which would make the tail test claim something it cannot know.
|
|
406
|
+
*
|
|
407
|
+
* Matching an absolute path by its tail is safe for Edit and Write material,
|
|
408
|
+
* and only for that material, because there the bytes are the proof: `before`
|
|
409
|
+
* has to occur in the blob at base, `after` in the blob at head, and the
|
|
410
|
+
* replay has to reach HEAD byte-identical. A payload naming another checkout's
|
|
411
|
+
* copy of SPEC.md carries that copy's bytes, which are not these, so it covers
|
|
412
|
+
* nothing. Command material describes no bytes at all, which is why
|
|
413
|
+
* {@link commandTargetsPath} keeps the stricter cwd-join rule for it.
|
|
414
|
+
*/
|
|
415
|
+
function namesProtectedFile(file, path) {
|
|
416
|
+
if (file.includes("\\"))
|
|
417
|
+
return false;
|
|
418
|
+
const named = segmentsOf(file);
|
|
419
|
+
if (named.includes(".."))
|
|
420
|
+
return false;
|
|
421
|
+
const wanted = segmentsOf(path);
|
|
422
|
+
if (wanted.length === 0)
|
|
423
|
+
return false;
|
|
424
|
+
if (file.startsWith("/"))
|
|
425
|
+
return endsWithSegments(file, path);
|
|
426
|
+
return named.length === wanted.length && named.every((part, index) => part === wanted[index]);
|
|
427
|
+
}
|
|
304
428
|
/**
|
|
305
429
|
* Does this command line WRITE `path`, as the runtime's own classifier reads it?
|
|
306
430
|
*
|
|
@@ -413,6 +537,31 @@ function evidenceFor(material, path, policyProtectedPaths) {
|
|
|
413
537
|
if (typeof material !== "object" || material === null || Array.isArray(material))
|
|
414
538
|
return null;
|
|
415
539
|
const map = material;
|
|
540
|
+
// Codex apply_patch is tagged explicitly. Parse it before the generic
|
|
541
|
+
// command fallback so patch text is never mistaken for a shell command and
|
|
542
|
+
// never receives time-based command attribution. Until a patch-to-blob
|
|
543
|
+
// proof exists, this is deliberately naming-only evidence and covers no
|
|
544
|
+
// bytes; it remains useful diagnosis without weakening the guard.
|
|
545
|
+
if (map["tool"] === "apply_patch" && typeof map["command"] === "string") {
|
|
546
|
+
const parsed = parseApplyPatch(map["command"]);
|
|
547
|
+
if (parsed.ok) {
|
|
548
|
+
const operation = parsed.operations.find((candidate) => endsWithSegments(candidate.path, path) ||
|
|
549
|
+
(candidate.kind === "update" &&
|
|
550
|
+
candidate.moveTo !== undefined &&
|
|
551
|
+
endsWithSegments(candidate.moveTo, path)));
|
|
552
|
+
if (operation !== undefined) {
|
|
553
|
+
return {
|
|
554
|
+
kind: "granted-file",
|
|
555
|
+
detail: `the granted material is a Codex apply_patch operation naming ${path}; patch bytes are not yet guard coverage`,
|
|
556
|
+
after: null,
|
|
557
|
+
before: null,
|
|
558
|
+
whole: false,
|
|
559
|
+
command: false,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
416
565
|
const file = map["file"];
|
|
417
566
|
if (typeof file === "string" && endsWithSegments(file, path)) {
|
|
418
567
|
const tool = typeof map["tool"] === "string" ? map["tool"] : "a file tool";
|
|
@@ -476,6 +625,412 @@ function evidenceFor(material, path, policyProtectedPaths) {
|
|
|
476
625
|
}
|
|
477
626
|
return null;
|
|
478
627
|
}
|
|
628
|
+
/** Every declaration of this action key that existed before the start. */
|
|
629
|
+
function precedingBindings(records, start) {
|
|
630
|
+
const actionKey = start.action_key;
|
|
631
|
+
if (actionKey === undefined)
|
|
632
|
+
return [];
|
|
633
|
+
const found = [];
|
|
634
|
+
for (const record of records) {
|
|
635
|
+
if (record.seq >= start.seq || record.event !== "task.registered")
|
|
636
|
+
continue;
|
|
637
|
+
const actions = payloadOf(record)["actions"];
|
|
638
|
+
if (!Array.isArray(actions))
|
|
639
|
+
continue;
|
|
640
|
+
for (const raw of actions) {
|
|
641
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
642
|
+
continue;
|
|
643
|
+
const action = raw;
|
|
644
|
+
if (action["idempotency_key"] !== actionKey)
|
|
645
|
+
continue;
|
|
646
|
+
const cls = action["class"];
|
|
647
|
+
const hash = action["payload_hash"];
|
|
648
|
+
// Count malformed declarations too. A malformed entry sharing the key is
|
|
649
|
+
// ambiguity, never something a later well-formed entry can hide.
|
|
650
|
+
found.push({
|
|
651
|
+
record,
|
|
652
|
+
task: record.task ?? null,
|
|
653
|
+
actionKey,
|
|
654
|
+
cls: typeof cls === "string" ? cls : null,
|
|
655
|
+
payloadHash: typeof hash === "string" ? hash : null,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return found;
|
|
660
|
+
}
|
|
661
|
+
/** Exact file bytes, excluding fallback, command and apply_patch material. */
|
|
662
|
+
function policyFileEvidence(material, path, policyProtectedPaths) {
|
|
663
|
+
if (typeof material !== "object" || material === null || Array.isArray(material))
|
|
664
|
+
return null;
|
|
665
|
+
const map = material;
|
|
666
|
+
const tool = map["tool"];
|
|
667
|
+
const file = map["file"];
|
|
668
|
+
if (typeof file !== "string" || !namesProtectedFile(file, path))
|
|
669
|
+
return null;
|
|
670
|
+
const keys = Object.keys(map);
|
|
671
|
+
const hasOnly = (allowed) => keys.every((key) => allowed.includes(key));
|
|
672
|
+
if (tool === "Edit") {
|
|
673
|
+
if (!hasOnly(["tool", "rule", "file", "before", "after", "replace_all"]) ||
|
|
674
|
+
(map["rule"] !== undefined && typeof map["rule"] !== "string") ||
|
|
675
|
+
typeof map["before"] !== "string" ||
|
|
676
|
+
typeof map["after"] !== "string" ||
|
|
677
|
+
(map["replace_all"] !== undefined && typeof map["replace_all"] !== "boolean"))
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
else if (tool === "Write") {
|
|
681
|
+
if (!hasOnly(["tool", "rule", "file", "content"]) ||
|
|
682
|
+
(map["rule"] !== undefined && typeof map["rule"] !== "string") ||
|
|
683
|
+
typeof map["content"] !== "string")
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
const match = evidenceFor(material, path, policyProtectedPaths);
|
|
690
|
+
if (match === null || match.command || (match.after === null && match.before === null))
|
|
691
|
+
return null;
|
|
692
|
+
return {
|
|
693
|
+
...match,
|
|
694
|
+
kind: "policy-authorized-file",
|
|
695
|
+
detail: match.detail.replace(/^the granted material is/u, "the policy-authorized material is"),
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
/** Differing applicable routes in an unlabeled policy union carry no single class. */
|
|
699
|
+
function hasAmbiguousRoute(path, policyProtectedPaths) {
|
|
700
|
+
const candidate = segmentsOf(path);
|
|
701
|
+
const classes = new Set();
|
|
702
|
+
for (const entry of policyProtectedPaths) {
|
|
703
|
+
const raw = typeof entry === "string" ? entry : entry.path;
|
|
704
|
+
const trimmed = raw.trim();
|
|
705
|
+
if (trimmed.length === 0 || trimmed.startsWith("/"))
|
|
706
|
+
continue;
|
|
707
|
+
const wanted = segmentsOf(trimmed);
|
|
708
|
+
if (wanted.includes("..") || wanted.length === 0 || wanted.length > candidate.length)
|
|
709
|
+
continue;
|
|
710
|
+
const directory = /[/\\]$/u.test(trimmed);
|
|
711
|
+
const matches = directory
|
|
712
|
+
? candidate.some((_, start) => start + wanted.length <= candidate.length &&
|
|
713
|
+
wanted.every((segment, offset) => candidate[start + offset] === segment))
|
|
714
|
+
: wanted.every((segment, offset) => candidate[candidate.length - wanted.length + offset] === segment);
|
|
715
|
+
if (matches)
|
|
716
|
+
classes.add(typeof entry === "string" ? "policy.edit" : entry.class);
|
|
717
|
+
}
|
|
718
|
+
return classes.size > 1;
|
|
719
|
+
}
|
|
720
|
+
/** Normalize whatever the caller supplied into one {@link ChangeAnchor}. */
|
|
721
|
+
function changeAnchorOf(supplied) {
|
|
722
|
+
const pair = supplied === null || typeof supplied === "string"
|
|
723
|
+
? { author: supplied, committer: supplied }
|
|
724
|
+
: supplied;
|
|
725
|
+
const authorTs = pair.author ?? pair.committer;
|
|
726
|
+
const committerTs = pair.committer ?? pair.author;
|
|
727
|
+
const parse = (ts) => {
|
|
728
|
+
if (ts === null)
|
|
729
|
+
return null;
|
|
730
|
+
const ms = Date.parse(ts);
|
|
731
|
+
return Number.isNaN(ms) ? null : ms;
|
|
732
|
+
};
|
|
733
|
+
const authorMs = parse(authorTs);
|
|
734
|
+
const committerMs = parse(committerTs);
|
|
735
|
+
return {
|
|
736
|
+
authorTs,
|
|
737
|
+
committerTs,
|
|
738
|
+
authorMs: authorMs ?? committerMs,
|
|
739
|
+
committerMs: committerMs ?? authorMs,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Exact-file evidence from an unattended execution authorized by policy.
|
|
744
|
+
*
|
|
745
|
+
* The start must reproduce the one declaration that preceded it, and the
|
|
746
|
+
* committed payload must hash back to that declaration. A prior request means
|
|
747
|
+
* the action entered the human-decision path, so this tier cannot carry it.
|
|
748
|
+
*/
|
|
749
|
+
function policyAuthorizedEvidence(records, start, path, policyProtectedPaths, payloadFor, anchor, lookbackMs) {
|
|
750
|
+
if (start.event !== "execution.started")
|
|
751
|
+
return null;
|
|
752
|
+
const task = start.task;
|
|
753
|
+
const actionKey = start.action_key;
|
|
754
|
+
if (task === undefined || actionKey === undefined)
|
|
755
|
+
return null;
|
|
756
|
+
const started = payloadOf(start);
|
|
757
|
+
const harnessStart = started["execution"] === "harness";
|
|
758
|
+
const coreStart = !Object.hasOwn(started, "execution");
|
|
759
|
+
if ((!harnessStart && !coreStart) ||
|
|
760
|
+
Object.hasOwn(started, "grant_seq") ||
|
|
761
|
+
Object.hasOwn(started, "token_sha256"))
|
|
762
|
+
return null;
|
|
763
|
+
const cls = started["class"];
|
|
764
|
+
const hash = started["payload_hash"];
|
|
765
|
+
if (typeof cls !== "string" || typeof hash !== "string")
|
|
766
|
+
return null;
|
|
767
|
+
// The exact routed class is authority. Merely living in policy.edit.* is not.
|
|
768
|
+
if (hasAmbiguousRoute(path, policyProtectedPaths))
|
|
769
|
+
return null;
|
|
770
|
+
const routed = protectedPathClass(path, policyProtectedPaths);
|
|
771
|
+
if (routed === null || routed === "policy.core" || routed === "log.mutate" || cls !== routed) {
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
const bindings = precedingBindings(records, start);
|
|
775
|
+
if (bindings.length !== 1)
|
|
776
|
+
return null;
|
|
777
|
+
const binding = bindings[0];
|
|
778
|
+
if (binding.task !== task ||
|
|
779
|
+
binding.actionKey !== actionKey ||
|
|
780
|
+
binding.cls !== cls ||
|
|
781
|
+
binding.payloadHash !== hash)
|
|
782
|
+
return null;
|
|
783
|
+
if (records.some((record) => record.seq < start.seq &&
|
|
784
|
+
record.action_key === actionKey &&
|
|
785
|
+
record.event === "approval.requested"))
|
|
786
|
+
return null;
|
|
787
|
+
// A start cannot authorize a change that already happened, and a start from
|
|
788
|
+
// months ago is about some earlier edit. Ordering is asked of the COMMITTER
|
|
789
|
+
// date and staleness of the AUTHOR date (APRV-339). This tier also requires a
|
|
790
|
+
// usable commit timestamp; missing temporal evidence fails closed.
|
|
791
|
+
const { authorMs, committerMs } = anchor;
|
|
792
|
+
if (authorMs === null || committerMs === null)
|
|
793
|
+
return null;
|
|
794
|
+
const at = Date.parse(start.ts);
|
|
795
|
+
if (Number.isNaN(at) || at > committerMs || authorMs - at > lookbackMs)
|
|
796
|
+
return null;
|
|
797
|
+
const material = payloadFor(hash);
|
|
798
|
+
if (material === null)
|
|
799
|
+
return null;
|
|
800
|
+
try {
|
|
801
|
+
if (payloadHash(material) !== hash)
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
return policyFileEvidence(material, path, policyProtectedPaths);
|
|
808
|
+
}
|
|
809
|
+
/** The closed Edit shape, naming this path, eligible for byte replay. */
|
|
810
|
+
function exactReplayEdit(material, path) {
|
|
811
|
+
if (typeof material !== "object" || material === null || Array.isArray(material))
|
|
812
|
+
return null;
|
|
813
|
+
const map = material;
|
|
814
|
+
const keys = Object.keys(map);
|
|
815
|
+
const allowed = ["tool", "rule", "file", "before", "after", "replace_all"];
|
|
816
|
+
if (keys.some((key) => !allowed.includes(key)))
|
|
817
|
+
return null;
|
|
818
|
+
if (map["tool"] !== "Edit" ||
|
|
819
|
+
typeof map["file"] !== "string" ||
|
|
820
|
+
!namesProtectedFile(map["file"], path) ||
|
|
821
|
+
(map["rule"] !== undefined && typeof map["rule"] !== "string") ||
|
|
822
|
+
typeof map["before"] !== "string" ||
|
|
823
|
+
map["before"].length === 0 ||
|
|
824
|
+
typeof map["after"] !== "string" ||
|
|
825
|
+
map["before"] === map["after"] ||
|
|
826
|
+
(map["replace_all"] !== undefined && map["replace_all"] !== false))
|
|
827
|
+
return null;
|
|
828
|
+
return { before: map["before"], after: map["after"] };
|
|
829
|
+
}
|
|
830
|
+
/** The one token or harness spend that proves this manual grant actually ran. */
|
|
831
|
+
function startForReplayGrant(candidate, records, anchor, lookbackMs, path, policyProtectedPaths) {
|
|
832
|
+
const { authorMs, committerMs } = anchor;
|
|
833
|
+
if (authorMs === null || committerMs === null)
|
|
834
|
+
return null;
|
|
835
|
+
const grant = candidate.record;
|
|
836
|
+
const task = grant.task;
|
|
837
|
+
const actionKey = grant.action_key;
|
|
838
|
+
if (task === undefined || actionKey === undefined)
|
|
839
|
+
return null;
|
|
840
|
+
const granted = payloadOf(grant);
|
|
841
|
+
const cls = granted["class"];
|
|
842
|
+
const hash = granted["payload_hash"];
|
|
843
|
+
if (typeof cls !== "string" || typeof hash !== "string" || hash !== candidate.payloadHash)
|
|
844
|
+
return null;
|
|
845
|
+
if (hasAmbiguousRoute(path, policyProtectedPaths))
|
|
846
|
+
return null;
|
|
847
|
+
const routed = protectedPathClass(path, policyProtectedPaths);
|
|
848
|
+
if (routed === null || routed === "policy.core" || routed === "log.mutate" || cls !== routed) {
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
try {
|
|
852
|
+
if (payloadHash(candidate.material) !== hash)
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
const grantToken = granted["token_sha256"];
|
|
859
|
+
const starts = records.filter((record) => {
|
|
860
|
+
if (record.event !== "execution.started" ||
|
|
861
|
+
record.seq <= grant.seq ||
|
|
862
|
+
record.task !== task ||
|
|
863
|
+
record.action_key !== actionKey)
|
|
864
|
+
return false;
|
|
865
|
+
const started = payloadOf(record);
|
|
866
|
+
if (started["class"] !== cls || started["payload_hash"] !== hash)
|
|
867
|
+
return false;
|
|
868
|
+
if (Object.hasOwn(started, "execution") && started["execution"] !== "harness")
|
|
869
|
+
return false;
|
|
870
|
+
const hasGrantLink = Object.hasOwn(started, "grant_seq");
|
|
871
|
+
const hasTokenLink = Object.hasOwn(started, "token_sha256");
|
|
872
|
+
if (!hasGrantLink && !hasTokenLink)
|
|
873
|
+
return false;
|
|
874
|
+
if (hasGrantLink && started["grant_seq"] !== grant.seq)
|
|
875
|
+
return false;
|
|
876
|
+
if (hasTokenLink &&
|
|
877
|
+
(typeof grantToken !== "string" || started["token_sha256"] !== grantToken))
|
|
878
|
+
return false;
|
|
879
|
+
// Ordering against the committer date, staleness against the author date
|
|
880
|
+
// (APRV-339), exactly as the policy-authorized tier asks them.
|
|
881
|
+
const at = Date.parse(record.ts);
|
|
882
|
+
return !Number.isNaN(at) && at <= committerMs && authorMs - at <= lookbackMs;
|
|
883
|
+
});
|
|
884
|
+
if (starts.length !== 1)
|
|
885
|
+
return null;
|
|
886
|
+
const start = starts[0];
|
|
887
|
+
const bindings = precedingBindings(records, start);
|
|
888
|
+
if (bindings.length !== 1)
|
|
889
|
+
return null;
|
|
890
|
+
const binding = bindings[0];
|
|
891
|
+
if (binding.record.seq >= grant.seq ||
|
|
892
|
+
binding.task !== task ||
|
|
893
|
+
binding.actionKey !== actionKey ||
|
|
894
|
+
binding.cls !== cls ||
|
|
895
|
+
binding.payloadHash !== hash)
|
|
896
|
+
return null;
|
|
897
|
+
const requested = records.some((record) => {
|
|
898
|
+
if (record.event !== "approval.requested" ||
|
|
899
|
+
record.seq <= binding.record.seq ||
|
|
900
|
+
record.seq >= grant.seq ||
|
|
901
|
+
record.task !== task ||
|
|
902
|
+
record.action_key !== actionKey)
|
|
903
|
+
return false;
|
|
904
|
+
const payload = payloadOf(record);
|
|
905
|
+
return payload["class"] === cls && payload["payload_hash"] === hash;
|
|
906
|
+
});
|
|
907
|
+
return requested ? start : null;
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Replay authorized exact edits in execution order and demand byte equality.
|
|
911
|
+
*
|
|
912
|
+
* The log may contain older edits whose anchors survive in BASE but which did
|
|
913
|
+
* not produce this change. Search deterministic ordered subsequences: skip a
|
|
914
|
+
* candidate first, and apply it only when its anchor occurs exactly once in the
|
|
915
|
+
* current state. An ambiguous candidate cannot be applied, but it does not make
|
|
916
|
+
* an independent later proof ambiguous. Only a non-empty sequence that turns
|
|
917
|
+
* BASE into HEAD byte for byte is evidence; there is no reordering, substring
|
|
918
|
+
* credit, or partial result.
|
|
919
|
+
*
|
|
920
|
+
* Search is bounded by candidate count, distinct states, and cumulative bytes
|
|
921
|
+
* examined. Crossing any bound refuses the fallback rather than pruning a
|
|
922
|
+
* potentially valid branch and claiming the remaining search was complete.
|
|
923
|
+
*/
|
|
924
|
+
function exactEditReplay(candidates, records, base, head, anchor, lookbackMs, path, policyProtectedPaths) {
|
|
925
|
+
if (base === null ||
|
|
926
|
+
head === null ||
|
|
927
|
+
base.includes("\uFFFD") ||
|
|
928
|
+
head.includes("\uFFFD"))
|
|
929
|
+
return { steps: null, bound: null };
|
|
930
|
+
const gathered = [];
|
|
931
|
+
for (const candidate of candidates) {
|
|
932
|
+
try {
|
|
933
|
+
if (payloadHash(candidate.material) !== candidate.payloadHash)
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
catch {
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
const edit = exactReplayEdit(candidate.material, path);
|
|
940
|
+
if (edit === null)
|
|
941
|
+
continue;
|
|
942
|
+
const start = candidate.source === "policy"
|
|
943
|
+
? candidate.record
|
|
944
|
+
: startForReplayGrant(candidate, records, anchor, lookbackMs, path, policyProtectedPaths);
|
|
945
|
+
if (start !== null)
|
|
946
|
+
gathered.push({ candidate, start, ...edit });
|
|
947
|
+
}
|
|
948
|
+
gathered.sort((left, right) => left.start.seq - right.start.seq);
|
|
949
|
+
// A verified execution record can authorize at most one application. Collapse
|
|
950
|
+
// duplicate evidence views of the same binding. If two views give one start
|
|
951
|
+
// conflicting edit bytes, neither representation of that start is usable.
|
|
952
|
+
const byStart = new Map();
|
|
953
|
+
const conflictingStarts = new Set();
|
|
954
|
+
for (const step of gathered) {
|
|
955
|
+
const prior = byStart.get(step.start.seq);
|
|
956
|
+
if (prior === undefined) {
|
|
957
|
+
byStart.set(step.start.seq, step);
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
if (prior.before !== step.before || prior.after !== step.after) {
|
|
961
|
+
conflictingStarts.add(step.start.seq);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
const steps = [...byStart.values()].filter((step) => !conflictingStarts.has(step.start.seq));
|
|
965
|
+
if (steps.length > EXACT_REPLAY_MAX_CANDIDATES) {
|
|
966
|
+
return { steps: null, bound: "candidate-limit" };
|
|
967
|
+
}
|
|
968
|
+
const headBytes = Buffer.byteLength(head, "utf8");
|
|
969
|
+
let examinedBytes = Buffer.byteLength(base, "utf8") + headBytes;
|
|
970
|
+
if (examinedBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
|
|
971
|
+
return { steps: null, bound: "byte-limit" };
|
|
972
|
+
}
|
|
973
|
+
let visitedStates = 0;
|
|
974
|
+
let exceeded = null;
|
|
975
|
+
// State equality is exact string equality, not a digest or lossy cache key.
|
|
976
|
+
// Empty/non-empty histories stay distinct because an empty replay is never
|
|
977
|
+
// evidence even when BASE already equals HEAD.
|
|
978
|
+
const visited = Array.from({ length: steps.length + 1 }, () => [new Set(), new Set()]);
|
|
979
|
+
const search = (index, replayed, applied) => {
|
|
980
|
+
if (exceeded !== null)
|
|
981
|
+
return null;
|
|
982
|
+
const replayedBytes = Buffer.byteLength(replayed, "utf8");
|
|
983
|
+
// Charge exact-state hashing/equality and the HEAD comparison before either
|
|
984
|
+
// can retain or accept this state.
|
|
985
|
+
const stateBytes = replayedBytes + headBytes;
|
|
986
|
+
if (examinedBytes + stateBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
|
|
987
|
+
exceeded = "byte-limit";
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
examinedBytes += stateBytes;
|
|
991
|
+
const seen = visited[index]?.[applied.length === 0 ? 0 : 1];
|
|
992
|
+
if (seen === undefined || seen.has(replayed))
|
|
993
|
+
return null;
|
|
994
|
+
seen.add(replayed);
|
|
995
|
+
visitedStates += 1;
|
|
996
|
+
if (visitedStates > EXACT_REPLAY_MAX_STATES) {
|
|
997
|
+
exceeded = "state-limit";
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
if (applied.length > 0 && replayed === head)
|
|
1001
|
+
return [...applied];
|
|
1002
|
+
if (index === steps.length)
|
|
1003
|
+
return null;
|
|
1004
|
+
// Deterministic skip-first DFS finds a late witness quickly and
|
|
1005
|
+
// prevents irrelevant historical edits from polluting a valid proof.
|
|
1006
|
+
const skipped = search(index + 1, replayed, applied);
|
|
1007
|
+
if (skipped !== null || exceeded !== null)
|
|
1008
|
+
return skipped;
|
|
1009
|
+
const step = steps[index];
|
|
1010
|
+
const scanBytes = replayedBytes * 2 +
|
|
1011
|
+
Buffer.byteLength(step.before, "utf8");
|
|
1012
|
+
if (examinedBytes + scanBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
|
|
1013
|
+
exceeded = "byte-limit";
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
examinedBytes += scanBytes;
|
|
1017
|
+
const first = replayed.indexOf(step.before);
|
|
1018
|
+
if (first === -1 || replayed.indexOf(step.before, first + 1) !== -1)
|
|
1019
|
+
return null;
|
|
1020
|
+
// Bound the next allocation before constructing it. `replayed + after` is
|
|
1021
|
+
// a conservative upper bound because the replacement removes `before`.
|
|
1022
|
+
const prospectiveBytes = replayedBytes + Buffer.byteLength(step.after, "utf8");
|
|
1023
|
+
if (examinedBytes + prospectiveBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
|
|
1024
|
+
exceeded = "byte-limit";
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
examinedBytes += prospectiveBytes;
|
|
1028
|
+
const next = `${replayed.slice(0, first)}${step.after}${replayed.slice(first + step.before.length)}`;
|
|
1029
|
+
return search(index + 1, next, [...applied, step]);
|
|
1030
|
+
};
|
|
1031
|
+
const proof = search(0, base, []);
|
|
1032
|
+
return { steps: proof, bound: exceeded };
|
|
1033
|
+
}
|
|
479
1034
|
/** A blob's lines, with the empty tail a trailing newline leaves dropped. */
|
|
480
1035
|
function linesOf(text) {
|
|
481
1036
|
const parts = text.split("\n");
|
|
@@ -483,6 +1038,38 @@ function linesOf(text) {
|
|
|
483
1038
|
parts.pop();
|
|
484
1039
|
return parts;
|
|
485
1040
|
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Where a fragment anchors in the blob at base, and what replacing it yields.
|
|
1043
|
+
*
|
|
1044
|
+
* `null` unless the fragment sits inside ONE line and occurs exactly once in
|
|
1045
|
+
* the whole file: a fragment with two homes does not say which occurrence the
|
|
1046
|
+
* human approved, and a guess is not evidence. A fragment carrying a newline is
|
|
1047
|
+
* not line-local at all and belongs to the global replay.
|
|
1048
|
+
*
|
|
1049
|
+
* Applying a uniquely-anchored edit to base can only produce base with that one
|
|
1050
|
+
* line rewritten, so this is a one-step replay stated as the line it changes —
|
|
1051
|
+
* the arithmetic the caller then checks against the hunks (APRV-340).
|
|
1052
|
+
*/
|
|
1053
|
+
function lineLocalReplacement(baseLines, edit) {
|
|
1054
|
+
if (edit.before.includes("\n"))
|
|
1055
|
+
return null;
|
|
1056
|
+
let found = null;
|
|
1057
|
+
for (const line of baseLines) {
|
|
1058
|
+
const first = line.indexOf(edit.before);
|
|
1059
|
+
if (first === -1)
|
|
1060
|
+
continue;
|
|
1061
|
+
if (found !== null || line.indexOf(edit.before, first + 1) !== -1)
|
|
1062
|
+
return null;
|
|
1063
|
+
found = { line, at: first };
|
|
1064
|
+
}
|
|
1065
|
+
if (found === null)
|
|
1066
|
+
return null;
|
|
1067
|
+
const { line, at } = found;
|
|
1068
|
+
return {
|
|
1069
|
+
removed: line,
|
|
1070
|
+
added: `${line.slice(0, at)}${edit.after}${line.slice(at + edit.before.length)}`,
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
486
1073
|
/** A line that carries content. Blank lines neither need coverage nor give it. */
|
|
487
1074
|
function substantive(line) {
|
|
488
1075
|
return line.trim().length > 0;
|
|
@@ -555,8 +1142,8 @@ function windowText(window) {
|
|
|
555
1142
|
}
|
|
556
1143
|
/**
|
|
557
1144
|
* How far past the change commit a run may still start and be its cause: five
|
|
558
|
-
* minutes, for clock disagreement between the log and git's
|
|
559
|
-
* a skew allowance, not an ordering allowance.
|
|
1145
|
+
* minutes, for clock disagreement between the log and git's committer date. It
|
|
1146
|
+
* is a skew allowance, not an ordering allowance.
|
|
560
1147
|
*/
|
|
561
1148
|
const SKEW_GRACE_MS = 5 * 60 * 1000;
|
|
562
1149
|
/** Milliseconds as something a failure message can say out loud. */
|
|
@@ -580,7 +1167,12 @@ function spanText(ms) {
|
|
|
580
1167
|
* interval it actually occupied: a batch that started before the commit and
|
|
581
1168
|
* finished after it brackets the commit, which is the strongest form of this.
|
|
582
1169
|
*/
|
|
583
|
-
function attributeRun(grant, runs,
|
|
1170
|
+
function attributeRun(grant, runs, anchor, attributionMs) {
|
|
1171
|
+
// The same split the other two tiers make (APRV-339): "did this run start
|
|
1172
|
+
// after the bytes were committed" is asked of the COMMITTER date, which an
|
|
1173
|
+
// amend may only move later, and "how far from the change is it" of the
|
|
1174
|
+
// AUTHOR date, which a rebase does not move.
|
|
1175
|
+
const { authorMs, committerMs } = anchor;
|
|
584
1176
|
const key = grant.action_key;
|
|
585
1177
|
if (key === undefined) {
|
|
586
1178
|
return {
|
|
@@ -598,7 +1190,7 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
|
|
|
598
1190
|
}
|
|
599
1191
|
const completed = tied.filter((record) => record.event === "execution.completed");
|
|
600
1192
|
const endOf = (record) => completed.find((done) => done.seq > record.seq);
|
|
601
|
-
if (
|
|
1193
|
+
if (authorMs === null || committerMs === null) {
|
|
602
1194
|
const first = started[0];
|
|
603
1195
|
return {
|
|
604
1196
|
ok: true,
|
|
@@ -611,7 +1203,7 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
|
|
|
611
1203
|
// the record is appended before the process is spawned. The real log shows
|
|
612
1204
|
// what the symmetric window costs — a SPEC.md batch run four hours AFTER
|
|
613
1205
|
// PR #187's commit would otherwise have carried that commit's changes.
|
|
614
|
-
// `SKEW_GRACE_MS` is for the two clocks (the log's and git's
|
|
1206
|
+
// `SKEW_GRACE_MS` is for the two clocks (the log's and git's committer date)
|
|
615
1207
|
// disagreeing, not for ordering.
|
|
616
1208
|
let best = null;
|
|
617
1209
|
let laterOnly = null;
|
|
@@ -619,7 +1211,7 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
|
|
|
619
1211
|
const from = Date.parse(record.ts);
|
|
620
1212
|
if (Number.isNaN(from))
|
|
621
1213
|
continue;
|
|
622
|
-
if (from >
|
|
1214
|
+
if (from > committerMs + SKEW_GRACE_MS) {
|
|
623
1215
|
if (laterOnly === null)
|
|
624
1216
|
laterOnly = record;
|
|
625
1217
|
continue;
|
|
@@ -627,9 +1219,9 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
|
|
|
627
1219
|
const end = endOf(record);
|
|
628
1220
|
const to = end === undefined ? from : Date.parse(end.ts);
|
|
629
1221
|
const upper = Number.isNaN(to) ? from : to;
|
|
630
|
-
const distance =
|
|
1222
|
+
const distance = authorMs >= Math.min(from, upper) && authorMs <= Math.max(from, upper)
|
|
631
1223
|
? 0
|
|
632
|
-
: Math.min(Math.abs(from -
|
|
1224
|
+
: Math.min(Math.abs(from - authorMs), Math.abs(upper - authorMs));
|
|
633
1225
|
if (best === null || distance < best.distance)
|
|
634
1226
|
best = { record, end, distance };
|
|
635
1227
|
}
|
|
@@ -667,7 +1259,41 @@ function attributeRun(grant, runs, anchorMs, attributionMs) {
|
|
|
667
1259
|
* spelled: two different (path, digest) pairs cannot collide into one key.
|
|
668
1260
|
*/
|
|
669
1261
|
function organKey(organPath, sha256) {
|
|
670
|
-
return `${normalizePathSpelling(organPath)}
|
|
1262
|
+
return `${normalizePathSpelling(organPath)}\0${sha256}`;
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* May this path be evidenced by a `gate.path.signed_off` record? (APRV-338.)
|
|
1266
|
+
*
|
|
1267
|
+
* `policy.edit` and its sub-classes, and nothing else. The organs and the
|
|
1268
|
+
* approval home are `policy.core` and answered by their own record; the log
|
|
1269
|
+
* directory is `log.mutate` and answered by nobody. Asked with the policy's own
|
|
1270
|
+
* entries, so a path a project routed to `policy.edit.design` is signable while
|
|
1271
|
+
* a path no list protects is not.
|
|
1272
|
+
*/
|
|
1273
|
+
function signOffEligible(path, extra) {
|
|
1274
|
+
const routed = protectedPathClass(path, extra);
|
|
1275
|
+
return routed === "policy.edit" || (routed !== null && POLICY_EDIT_SUBCLASS.test(routed));
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* The sentence a failing `policy.edit` path gets about the sign-off route, or
|
|
1279
|
+
* nothing at all.
|
|
1280
|
+
*
|
|
1281
|
+
* Deliberately last in every message it appears in, and deliberately hedged.
|
|
1282
|
+
* The first repair for an uncovered change is to take it to the gate, which
|
|
1283
|
+
* binds the hunk; a sign-off stands for the whole file and is what the
|
|
1284
|
+
* pending-sign-off suffix was invented for — text a human has read at this
|
|
1285
|
+
* commit and agrees with. Printing it first would read as an invitation to
|
|
1286
|
+
* route around the gate, which is the one thing this guard exists to notice.
|
|
1287
|
+
*/
|
|
1288
|
+
function signOffRepair(path, digest) {
|
|
1289
|
+
if (digest === null)
|
|
1290
|
+
return "";
|
|
1291
|
+
// The two flags are named because the digest is the thing that has to match,
|
|
1292
|
+
// and the bytes under review are on the BRANCH while the log lives in the
|
|
1293
|
+
// primary checkout. `--dir` says which bytes to hash and `--log` says where
|
|
1294
|
+
// the record goes, so one command can span both without the log ever being
|
|
1295
|
+
// written from a worktree.
|
|
1296
|
+
return ` If a human has READ this change at this commit and stands behind the file as it now is, they may ratify it with \`approval policy attest --path ${path} --dir <a checkout at this commit> --log <the primary checkout's log> --as human:<id>\`, which must hash to ${digest}, followed by a log advance carrying that record; that is whole-file evidence and weaker than a grant, so prefer the gate wherever the edit can still go through it.`;
|
|
671
1297
|
}
|
|
672
1298
|
/** The ordering rule, stated identically on every failure that could be lag. */
|
|
673
1299
|
const ORDERING_RULE = "the committed log trails the primary checkout's live log, so if this edit WAS granted, " +
|
|
@@ -733,6 +1359,18 @@ export function evaluateProtectedPaths(input) {
|
|
|
733
1359
|
continue;
|
|
734
1360
|
organAttestations.set(organKey(fields.organPath, fields.sha256), record);
|
|
735
1361
|
}
|
|
1362
|
+
// The SIGN-OFF index (APRV-338), keyed the same way and fed by its own event
|
|
1363
|
+
// type, so a sign-off can never answer an organ's question or the policy
|
|
1364
|
+
// file's and neither can answer a sign-off's. Built here and consulted at the
|
|
1365
|
+
// END of each path's evidence search: see the module note on why hunk
|
|
1366
|
+
// evidence has to be preferred over whole-file evidence.
|
|
1367
|
+
const signOffs = new Map();
|
|
1368
|
+
for (const record of records) {
|
|
1369
|
+
const fields = pathSignOffOf(record);
|
|
1370
|
+
if (fields === null)
|
|
1371
|
+
continue;
|
|
1372
|
+
signOffs.set(organKey(fields.path, fields.sha256), record);
|
|
1373
|
+
}
|
|
736
1374
|
// The grants of a class that can authorize a protected write.
|
|
737
1375
|
const grants = records.filter((record) => record.event === "approval.granted" &&
|
|
738
1376
|
isGrantingClass(String(payloadOf(record)["class"] ?? "")));
|
|
@@ -757,8 +1395,7 @@ export function evaluateProtectedPaths(input) {
|
|
|
757
1395
|
const findings = [];
|
|
758
1396
|
for (const path of guarded) {
|
|
759
1397
|
// 1. The policy file, by attestation.
|
|
760
|
-
if (
|
|
761
|
-
endsWithSegments(path, input.policyPath)) {
|
|
1398
|
+
if (namesPolicyFile(path, input.policyPath)) {
|
|
762
1399
|
if (input.policySha256AtHead !== null) {
|
|
763
1400
|
const attested = attestations.get(input.policySha256AtHead);
|
|
764
1401
|
if (attested !== undefined) {
|
|
@@ -806,18 +1443,24 @@ export function evaluateProtectedPaths(input) {
|
|
|
806
1443
|
const lookbackMs = input.lookbackMs ?? DEFAULT_LOOKBACK_MS;
|
|
807
1444
|
// ONE derivation of the anchor, so the bound that is enforced and the bound
|
|
808
1445
|
// that is reported cannot disagree. An unparseable timestamp is no anchor,
|
|
809
|
-
// exactly as a missing one is not: both land in `anchorMs === null`.
|
|
810
|
-
|
|
811
|
-
|
|
1446
|
+
// exactly as a missing one is not: both land in `anchorMs === null`. Two
|
|
1447
|
+
// instants since APRV-339, and the one asked here is the AUTHOR date: this
|
|
1448
|
+
// is the staleness pre-filter, and ordering belongs to the tiers that can
|
|
1449
|
+
// say which record wrote which bytes.
|
|
1450
|
+
const anchor = changeAnchorOf(changeTs);
|
|
1451
|
+
const anchorMs = anchor.authorMs;
|
|
812
1452
|
const inWindow = (ts) => {
|
|
813
1453
|
if (anchorMs === null)
|
|
814
1454
|
return true;
|
|
815
1455
|
const at = Date.parse(ts);
|
|
816
1456
|
return !Number.isNaN(at) && Math.abs(at - anchorMs) <= lookbackMs;
|
|
817
1457
|
};
|
|
1458
|
+
const datedText = anchor.committerTs === null || anchor.committerTs === anchor.authorTs
|
|
1459
|
+
? `${anchor.authorTs}`
|
|
1460
|
+
: `authored ${anchor.authorTs}, committed ${anchor.committerTs}`;
|
|
818
1461
|
const boundText = anchorMs === null
|
|
819
|
-
? `no usable commit timestamp for this path (${
|
|
820
|
-
: `within ${Math.round(lookbackMs / 86_400_000)}d of the commit that changed it (${
|
|
1462
|
+
? `no usable commit timestamp for this path (${anchor.authorTs === null ? "git named none" : `git named ${JSON.stringify(datedText)}, which does not parse`}), so NO recency bound was applied and this evidence rests on the path match alone`
|
|
1463
|
+
: `within ${Math.round(lookbackMs / 86_400_000)}d of the commit that changed it (${datedText})`;
|
|
821
1464
|
// EVERY qualifying grant is collected and the best one is reported, rather
|
|
822
1465
|
// than the first one found. The first-match version passed commit 41d2c9f
|
|
823
1466
|
// on a `cp SPEC.md <dir>/` granted four days earlier while the grant that
|
|
@@ -844,8 +1487,21 @@ export function evaluateProtectedPaths(input) {
|
|
|
844
1487
|
stale.push(grant);
|
|
845
1488
|
continue;
|
|
846
1489
|
}
|
|
847
|
-
candidates.push({ grant, match: found });
|
|
1490
|
+
candidates.push({ record: grant, match: found, source: "grant", material, payloadHash: hash });
|
|
848
1491
|
}
|
|
1492
|
+
for (const start of records) {
|
|
1493
|
+
const found = policyAuthorizedEvidence(records, start, path, input.policyProtectedPaths, input.payloadFor, anchor, lookbackMs);
|
|
1494
|
+
const hash = payloadOf(start)["payload_hash"];
|
|
1495
|
+
if (found !== null && typeof hash === "string") {
|
|
1496
|
+
const material = input.payloadFor(hash);
|
|
1497
|
+
if (material !== null) {
|
|
1498
|
+
candidates.push({ record: start, match: found, source: "policy", material, payloadHash: hash });
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
const evidenceNoun = candidates.every((candidate) => candidate.source === "grant")
|
|
1503
|
+
? `grant${candidates.length === 1 ? "" : "s"}`
|
|
1504
|
+
: `evidence record${candidates.length === 1 ? "" : "s"}`;
|
|
849
1505
|
/** Distance from the change commit, or 0 when there is no anchor to measure from. */
|
|
850
1506
|
const distance = (grant) => {
|
|
851
1507
|
if (anchorMs === null)
|
|
@@ -853,9 +1509,9 @@ export function evaluateProtectedPaths(input) {
|
|
|
853
1509
|
const at = Date.parse(grant.ts);
|
|
854
1510
|
return Number.isNaN(at) ? Number.POSITIVE_INFINITY : Math.abs(at - anchorMs);
|
|
855
1511
|
};
|
|
856
|
-
const rank = (kind) =>
|
|
1512
|
+
const rank = (kind) => kind === "granted-file" ? 0 : kind === "policy-authorized-file" ? 1 : 2;
|
|
857
1513
|
candidates.sort((left, right) => rank(left.match.kind) - rank(right.match.kind) ||
|
|
858
|
-
distance(left.
|
|
1514
|
+
distance(left.record) - distance(right.record));
|
|
859
1515
|
// The change this pull request makes to the path. Read before any coverage
|
|
860
1516
|
// is attempted, and a failure to read it is a failure: a change the guard
|
|
861
1517
|
// cannot see is not a change it has checked (APRV-202).
|
|
@@ -865,7 +1521,7 @@ export function evaluateProtectedPaths(input) {
|
|
|
865
1521
|
path,
|
|
866
1522
|
ok: false,
|
|
867
1523
|
code: "change-unreadable",
|
|
868
|
-
detail: `${path} is a protected path and changed between ${input.window.base} and ${input.window.head}, and its bytes could not be read at both commits (git could not show the blob, or it is binary), so no
|
|
1524
|
+
detail: `${path} is a protected path and changed between ${input.window.base} and ${input.window.head}, and its bytes could not be read at both commits (git could not show the blob, or it is binary), so no evidence could be checked against the change. ${candidates.length} ${evidenceNoun} name this path in the window, and naming is not coverage. ${ORDERING_RULE}.`,
|
|
869
1525
|
});
|
|
870
1526
|
continue;
|
|
871
1527
|
}
|
|
@@ -881,14 +1537,21 @@ export function evaluateProtectedPaths(input) {
|
|
|
881
1537
|
const removedCover = new Set();
|
|
882
1538
|
let whole = false;
|
|
883
1539
|
for (const candidate of candidates) {
|
|
884
|
-
const {
|
|
885
|
-
|
|
1540
|
+
const { record, match, source } = candidate;
|
|
1541
|
+
// An execution start says an exact file action was authorized, not that a
|
|
1542
|
+
// mode-only or whitespace-only change happened. Legacy human grants retain
|
|
1543
|
+
// their narrow no-substantive-hunk behavior; policy starts do not gain it.
|
|
1544
|
+
if (hunks.identical && source === "policy")
|
|
1545
|
+
continue;
|
|
1546
|
+
const at = source === "grant"
|
|
1547
|
+
? `the grant at seq ${record.seq}`
|
|
1548
|
+
: `the policy-authorized execution at seq ${record.seq}`;
|
|
886
1549
|
if (match.command) {
|
|
887
1550
|
if (!commandTargetsPath(match.target ?? "", match.cwd, path)) {
|
|
888
1551
|
rejected.push(`${at} writes ${JSON.stringify(match.target ?? "")} from ${JSON.stringify(match.cwd ?? "(no cwd recorded)")}, which is not this checkout's ${path}: a granted write to a copy of the file elsewhere (a dry run into a scratch directory, another worktree) authorizes nothing here`);
|
|
889
1552
|
continue;
|
|
890
1553
|
}
|
|
891
|
-
const run = attributeRun(
|
|
1554
|
+
const run = attributeRun(record, runs, anchor, attributionMs);
|
|
892
1555
|
if (!run.ok) {
|
|
893
1556
|
rejected.push(run.why);
|
|
894
1557
|
continue;
|
|
@@ -896,7 +1559,7 @@ export function evaluateProtectedPaths(input) {
|
|
|
896
1559
|
// A command payload describes no bytes, so attribution is all or
|
|
897
1560
|
// nothing: the run that wrote this file wrote whatever is in it.
|
|
898
1561
|
whole = true;
|
|
899
|
-
contributors.push({
|
|
1562
|
+
contributors.push({ record, kind: match.kind, why: `${match.detail}, and ${run.detail}`, whole: true, source });
|
|
900
1563
|
continue;
|
|
901
1564
|
}
|
|
902
1565
|
if (match.after === null && match.before === null) {
|
|
@@ -947,8 +1610,99 @@ export function evaluateProtectedPaths(input) {
|
|
|
947
1610
|
if (parts.length > 0)
|
|
948
1611
|
contributed = `${match.detail}, and ${parts.join(" and ")}`;
|
|
949
1612
|
}
|
|
950
|
-
if (contributed !== null)
|
|
951
|
-
contributors.push({
|
|
1613
|
+
if (contributed !== null) {
|
|
1614
|
+
contributors.push({ record, kind: match.kind, why: contributed, whole: wholeHere, source });
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
// One fragment inside one line, credited by bytes alone (APRV-340).
|
|
1618
|
+
//
|
|
1619
|
+
// The hook binds exactly what the Edit tool replaced, so an edit that
|
|
1620
|
+
// rewrites part of a long paragraph binds a fragment and whole-line set
|
|
1621
|
+
// membership cannot credit either line. That used to leave only the global
|
|
1622
|
+
// replay, which on a 200 KB file with forty naming candidates refuses on
|
|
1623
|
+
// its byte limit before it can reach a proof (PR #393, the fragment inside
|
|
1624
|
+
// SPEC.md line 139). A uniquely-anchored fragment needs no search: applying
|
|
1625
|
+
// it to base can only produce base with that ONE line rewritten, so if the
|
|
1626
|
+
// line it rewrites is a line this change removes and the line it produces
|
|
1627
|
+
// is a line this change adds, the bytes have proved both lines at the cost
|
|
1628
|
+
// of one scan. Every eligibility condition is the global replay's own — the
|
|
1629
|
+
// material rehashed, the Edit shape naming this path, and the same start
|
|
1630
|
+
// resolution, which for a grant is the registration, the request, the
|
|
1631
|
+
// class, the spend and the timing. A line-local step substitutes for none
|
|
1632
|
+
// of them; it only spends less to ask the same question.
|
|
1633
|
+
//
|
|
1634
|
+
// Bytes that did not decode are not bytes anyone can prove anything about,
|
|
1635
|
+
// so a blob carrying U+FFFD is refused here exactly as the replay refuses
|
|
1636
|
+
// it: byte equality against a lossy decoding says nothing.
|
|
1637
|
+
if (!whole &&
|
|
1638
|
+
!hunks.identical &&
|
|
1639
|
+
!baseText.includes("\uFFFD") &&
|
|
1640
|
+
!headText.includes("\uFFFD")) {
|
|
1641
|
+
const baseLines = linesOf(baseText);
|
|
1642
|
+
const addsLine = new Set(hunks.added);
|
|
1643
|
+
const removesLine = new Set(hunks.removed);
|
|
1644
|
+
for (const candidate of candidates) {
|
|
1645
|
+
const edit = exactReplayEdit(candidate.material, path);
|
|
1646
|
+
if (edit === null)
|
|
1647
|
+
continue;
|
|
1648
|
+
try {
|
|
1649
|
+
if (payloadHash(candidate.material) !== candidate.payloadHash)
|
|
1650
|
+
continue;
|
|
1651
|
+
}
|
|
1652
|
+
catch {
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
const start = candidate.source === "policy"
|
|
1656
|
+
? candidate.record
|
|
1657
|
+
: startForReplayGrant(candidate, records, anchor, lookbackMs, path, input.policyProtectedPaths);
|
|
1658
|
+
if (start === null)
|
|
1659
|
+
continue;
|
|
1660
|
+
const local = lineLocalReplacement(baseLines, edit);
|
|
1661
|
+
if (local === null)
|
|
1662
|
+
continue;
|
|
1663
|
+
if (!removesLine.has(local.removed) || !addsLine.has(local.added))
|
|
1664
|
+
continue;
|
|
1665
|
+
addedCover.add(local.added);
|
|
1666
|
+
removedCover.add(local.removed);
|
|
1667
|
+
const why = `replacing that fragment in the one line of the blob at base that carries it yields a line this change adds, and that base line is one this change removes (line-local replay at execution.started seq ${start.seq})`;
|
|
1668
|
+
const already = contributors.find((one) => one.record.seq === candidate.record.seq);
|
|
1669
|
+
if (already === undefined) {
|
|
1670
|
+
contributors.push({
|
|
1671
|
+
record: candidate.record,
|
|
1672
|
+
kind: candidate.match.kind,
|
|
1673
|
+
why: `${candidate.match.detail}, and ${why}`,
|
|
1674
|
+
whole: false,
|
|
1675
|
+
source: candidate.source,
|
|
1676
|
+
});
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
already.why = `${already.why}, and ${why}`;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
// Exact Edit payloads may bind fragments within a line, including several
|
|
1683
|
+
// independent fragments of the same long line. Whole-line set membership
|
|
1684
|
+
// cannot express that safely. Replay is the bounded fallback: genuine
|
|
1685
|
+
// starts, execution order, unique anchors, and final byte equality.
|
|
1686
|
+
const needsReplay = !whole &&
|
|
1687
|
+
!hunks.identical &&
|
|
1688
|
+
(hunks.reordered ||
|
|
1689
|
+
hunks.added.some((line) => !addedCover.has(line)) ||
|
|
1690
|
+
hunks.removed.some((line) => !removedCover.has(line)));
|
|
1691
|
+
const replay = needsReplay
|
|
1692
|
+
? exactEditReplay(candidates, records, blobs.base, blobs.head, anchor, lookbackMs, path, input.policyProtectedPaths)
|
|
1693
|
+
: { steps: null, bound: null };
|
|
1694
|
+
if (replay.bound !== null) {
|
|
1695
|
+
rejected.unshift(`exact BASE-to-HEAD replay refused after reaching its ${replay.bound.replace("-", " ")}`);
|
|
1696
|
+
}
|
|
1697
|
+
if (replay.steps !== null) {
|
|
1698
|
+
whole = true;
|
|
1699
|
+
contributors.splice(0, contributors.length, ...replay.steps.map((step) => ({
|
|
1700
|
+
record: step.candidate.record,
|
|
1701
|
+
kind: step.candidate.match.kind,
|
|
1702
|
+
why: `${step.candidate.match.detail}, applied at execution.started seq ${step.start.seq} in an exact BASE-to-HEAD replay`,
|
|
1703
|
+
whole: true,
|
|
1704
|
+
source: step.candidate.source,
|
|
1705
|
+
})));
|
|
952
1706
|
}
|
|
953
1707
|
// What the bytes alone cover, before any whole-file attribution. If this is
|
|
954
1708
|
// non-empty the change RESTS on the attribution, and the finding has to
|
|
@@ -976,12 +1730,14 @@ export function evaluateProtectedPaths(input) {
|
|
|
976
1730
|
const lead = restsOnAttribution
|
|
977
1731
|
? (contributors.find((one) => one.whole) ?? contributors[0])
|
|
978
1732
|
: contributors[0];
|
|
979
|
-
const quiet = hunks.identical
|
|
1733
|
+
const quiet = hunks.identical
|
|
1734
|
+
? candidates.find((candidate) => candidate.source === "grant")
|
|
1735
|
+
: undefined;
|
|
980
1736
|
const passing = lead !== undefined
|
|
981
|
-
? { record: lead.
|
|
1737
|
+
? { record: lead.record, kind: lead.kind, why: lead.why }
|
|
982
1738
|
: quiet !== undefined
|
|
983
1739
|
? {
|
|
984
|
-
record: quiet.
|
|
1740
|
+
record: quiet.record,
|
|
985
1741
|
kind: quiet.match.kind,
|
|
986
1742
|
why: `${quiet.match.detail}, and this change alters no substantive line (whitespace, mode or metadata only), so there is no hunk to cover`,
|
|
987
1743
|
}
|
|
@@ -997,17 +1753,48 @@ export function evaluateProtectedPaths(input) {
|
|
|
997
1753
|
seq: record.seq,
|
|
998
1754
|
ts: record.ts,
|
|
999
1755
|
actor: record.actor,
|
|
1000
|
-
coveredBy: contributors.map((one) => one.
|
|
1001
|
-
detail:
|
|
1002
|
-
? "
|
|
1003
|
-
:
|
|
1004
|
-
? "
|
|
1005
|
-
:
|
|
1006
|
-
|
|
1007
|
-
|
|
1756
|
+
coveredBy: contributors.map((one) => one.record.seq),
|
|
1757
|
+
detail: replay.steps !== null
|
|
1758
|
+
? `${path}'s full change was reconstructed byte-for-byte from ${contributors.length} replayed authorization record${contributors.length === 1 ? "" : "s"} in execution order, ${boundText}. Evidence records in that order: seq ${contributors.map((one) => one.record.seq).join(", ")}. The first record, ${record.actor} at seq ${record.seq} (${record.ts}), leads the finding: ${why}.`
|
|
1759
|
+
: `${path} was ${kind === "policy-authorized-file" ? "authorized by policy for execution" : "granted"} by ${record.actor} at seq ${record.seq} (${record.ts}), ${boundText}: ${why}. ${hunks.identical
|
|
1760
|
+
? "there are no substantive hunks to cover"
|
|
1761
|
+
: whole
|
|
1762
|
+
? "the whole change is attributed to that authorization record"
|
|
1763
|
+
: `${hunks.added.length} added and ${hunks.removed.length} removed line(s) all trace to authorized material`}${contributors.length > 1
|
|
1764
|
+
? ` (assembled from ${contributors.length} ${contributors.every((one) => one.source === "grant") ? "grants" : "evidence records"}: seq ${contributors.map((one) => one.record.seq).join(", ")}; the strongest and nearest leads)`
|
|
1765
|
+
: ""}`,
|
|
1008
1766
|
});
|
|
1009
1767
|
continue;
|
|
1010
1768
|
}
|
|
1769
|
+
// The LAST thing tried for this path, and deliberately last: a human's
|
|
1770
|
+
// whole-file sign-off (APRV-338).
|
|
1771
|
+
//
|
|
1772
|
+
// Everything above is hunk evidence, and everything above has now failed
|
|
1773
|
+
// to cover this change. Only here does the guard ask the weaker question —
|
|
1774
|
+
// did a human read this file at exactly these bytes and say so — and the
|
|
1775
|
+
// finding says in words that this is what it rests on, so a reader can
|
|
1776
|
+
// never mistake it for a grant. Running it here rather than beside the
|
|
1777
|
+
// organ verdict is what keeps that true: consulted first, a sign-off would
|
|
1778
|
+
// have silently answered for every change a grant already covered, and the
|
|
1779
|
+
// reasons this guard prints are half its value.
|
|
1780
|
+
const signOffDigest = signOffEligible(path, input.policyProtectedPaths)
|
|
1781
|
+
? (input.pathSha256AtHead?.(path) ?? null)
|
|
1782
|
+
: null;
|
|
1783
|
+
if (signOffDigest !== null) {
|
|
1784
|
+
const signed = signOffs.get(organKey(path, signOffDigest));
|
|
1785
|
+
if (signed !== undefined) {
|
|
1786
|
+
findings.push({
|
|
1787
|
+
path,
|
|
1788
|
+
ok: true,
|
|
1789
|
+
evidence: "attested",
|
|
1790
|
+
seq: signed.seq,
|
|
1791
|
+
ts: signed.ts,
|
|
1792
|
+
actor: signed.actor,
|
|
1793
|
+
detail: `${path} at ${input.window.head} hashes to ${signOffDigest}, which ${signed.actor} signed off FOR THAT PATH at seq ${signed.seq}. This is WHOLE-FILE evidence and weaker than a grant: it says a human read this file at these exact bytes, not that they saw this hunk. It was read only because no grant covers this change — ${candidates.length} ${evidenceNoun} name this path ${boundText}${contributors.length > 0 ? `, and ${contributors.length} of them covered part of it (seq ${contributors.map((one) => one.record.seq).join(", ")})` : ""}`,
|
|
1794
|
+
});
|
|
1795
|
+
continue;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1011
1798
|
// Naming grants exist and the change is not made of them: the repeat-edit
|
|
1012
1799
|
// shape. Its own code, because the reader's next move differs from
|
|
1013
1800
|
// `no-evidence` — take THIS change to the gate, rather than hunt for a
|
|
@@ -1019,11 +1806,11 @@ export function evaluateProtectedPaths(input) {
|
|
|
1019
1806
|
ok: false,
|
|
1020
1807
|
code: "uncovered-hunk",
|
|
1021
1808
|
uncovered: sample,
|
|
1022
|
-
detail: `${path} is a protected path and ${uncovered.length} line(s) of this change trace to no
|
|
1023
|
-
? `${contributors.length}
|
|
1809
|
+
detail: `${path} is a protected path and ${uncovered.length} line(s) of this change trace to no authorized material. ${candidates.length} ${evidenceNoun} name this path ${boundText}, and naming is not coverage: evidence authorizes only the bytes it binds, and a later edit to the same file is a different decision. ${contributors.length > 0
|
|
1810
|
+
? `${contributors.length} evidence record${contributors.length === 1 ? "" : "s"} covered part of it (seq ${contributors.map((one) => one.record.seq).join(", ")}); `
|
|
1024
1811
|
: ""}${rejected.length > 0
|
|
1025
1812
|
? `${rejected.slice(0, 6).join("; ")}${rejected.length > 6 ? `; … and ${rejected.length - 6} other naming grants set aside for the same kinds of reason` : ""}. `
|
|
1026
|
-
: ""}uncovered: ${sample.map((line) => JSON.stringify(line)).join(", ")}${uncovered.length > sample.length ? `, … ${uncovered.length - sample.length} more` : ""}. ${windowText(input.window)}. ${ORDERING_RULE}
|
|
1813
|
+
: ""}uncovered: ${sample.map((line) => JSON.stringify(line)).join(", ")}${uncovered.length > sample.length ? `, … ${uncovered.length - sample.length} more` : ""}. ${windowText(input.window)}. ${ORDERING_RULE}.${signOffRepair(path, signOffDigest)}`,
|
|
1027
1814
|
});
|
|
1028
1815
|
continue;
|
|
1029
1816
|
}
|
|
@@ -1037,10 +1824,16 @@ export function evaluateProtectedPaths(input) {
|
|
|
1037
1824
|
if (unresolved.length > 0) {
|
|
1038
1825
|
diagnosis.push(`${unresolved.length} grant payload${unresolved.length === 1 ? "" : "s"} could not be resolved from the committed payload store (${unresolved.slice(0, 3).join(", ")}${unresolved.length > 3 ? ", …" : ""}), and a grant whose bytes cannot be read is not evidence for any path`);
|
|
1039
1826
|
}
|
|
1040
|
-
if ((
|
|
1041
|
-
input.policySha256AtHead !== null) {
|
|
1827
|
+
if (namesPolicyFile(path, input.policyPath) && input.policySha256AtHead !== null) {
|
|
1042
1828
|
diagnosis.push(`no policy.updated record attests the bytes this pull request would install (${input.policySha256AtHead}); an amendment lands through \`approval policy amend --commit\`, whose attestation record is the evidence`);
|
|
1043
1829
|
}
|
|
1830
|
+
if (signOffDigest !== null) {
|
|
1831
|
+
// A protected path that COULD have been signed off and was not. Named
|
|
1832
|
+
// second to the grant advice above, not first: taking the edit to the
|
|
1833
|
+
// gate binds the hunk, and a sign-off stands for the whole file
|
|
1834
|
+
// (APRV-338).
|
|
1835
|
+
diagnosis.push(`no gate.path.signed_off record signs off ${path} at ${signOffDigest}, and a digest signed for some OTHER path is not evidence for this one`);
|
|
1836
|
+
}
|
|
1044
1837
|
if (isGateOrganPath(path)) {
|
|
1045
1838
|
// The one failure in this guard whose repair is NOT "take the change to
|
|
1046
1839
|
// the gate": there is no gate for it. `policy.core` is human-only, the
|
|
@@ -1056,7 +1849,7 @@ export function evaluateProtectedPaths(input) {
|
|
|
1056
1849
|
path,
|
|
1057
1850
|
ok: false,
|
|
1058
1851
|
code: "no-evidence",
|
|
1059
|
-
detail: `${path} is a protected path (edits classify policy.edit) and changed between ${input.window.base} and ${input.window.head}, and the committed log carries no evidence that a human decided it. ${windowText(input.window)}. ${diagnosis.join("; ")}. ${ORDERING_RULE}
|
|
1852
|
+
detail: `${path} is a protected path (edits classify policy.edit) and changed between ${input.window.base} and ${input.window.head}, and the committed log carries no evidence that a human decided it. ${windowText(input.window)}. ${diagnosis.join("; ")}. ${ORDERING_RULE}.${signOffRepair(path, signOffDigest)}`,
|
|
1060
1853
|
});
|
|
1061
1854
|
}
|
|
1062
1855
|
return {
|