approval-md 0.0.1 → 0.2.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/LICENSE +176 -0
- package/NOTICE +5 -0
- package/README.md +940 -4
- package/SPEC.md +476 -34
- package/cli.js +29 -3
- package/dist/src/adapters/agentmail.d.ts +426 -0
- package/dist/src/adapters/agentmail.js +1200 -0
- package/dist/src/adapters/agentmail.js.map +1 -0
- package/dist/src/adapters/conformance.d.ts +149 -0
- package/dist/src/adapters/conformance.js +461 -0
- package/dist/src/adapters/conformance.js.map +1 -0
- package/dist/src/adapters/contract.d.ts +628 -0
- package/dist/src/adapters/contract.js +1035 -0
- package/dist/src/adapters/contract.js.map +1 -0
- package/dist/src/adapters/email.d.ts +324 -0
- package/dist/src/adapters/email.js +749 -0
- package/dist/src/adapters/email.js.map +1 -0
- package/dist/src/adapters/env-passphrase.d.ts +93 -0
- package/dist/src/adapters/env-passphrase.js +132 -0
- package/dist/src/adapters/env-passphrase.js.map +1 -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 +77 -0
- package/dist/src/adapters/registry.js.map +1 -0
- package/dist/src/adapters/smtp.d.ts +213 -0
- package/dist/src/adapters/smtp.js +499 -0
- package/dist/src/adapters/smtp.js.map +1 -0
- package/dist/src/adapters/vault-provider.d.ts +114 -0
- package/dist/src/adapters/vault-provider.js +161 -0
- package/dist/src/adapters/vault-provider.js.map +1 -0
- 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/batch.js +121 -0
- package/dist/src/channels/batch.js.map +1 -0
- package/dist/src/channels/cli.d.ts +193 -0
- package/dist/src/channels/cli.js +468 -0
- package/dist/src/channels/cli.js.map +1 -0
- package/dist/src/channels/conformance.d.ts +92 -0
- package/dist/src/channels/conformance.js +445 -0
- package/dist/src/channels/conformance.js.map +1 -0
- package/dist/src/channels/contract.d.ts +623 -0
- package/dist/src/channels/contract.js +494 -0
- package/dist/src/channels/contract.js.map +1 -0
- package/dist/src/channels/payload-view.d.ts +35 -0
- package/dist/src/channels/payload-view.js +43 -0
- package/dist/src/channels/payload-view.js.map +1 -0
- package/dist/src/channels/render-queue.d.ts +149 -0
- package/dist/src/channels/render-queue.js +564 -0
- package/dist/src/channels/render-queue.js.map +1 -0
- package/dist/src/channels/tagging.d.ts +196 -0
- package/dist/src/channels/tagging.js +723 -0
- package/dist/src/channels/tagging.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +1832 -0
- package/dist/src/channels/telegram.js +3190 -0
- package/dist/src/channels/telegram.js.map +1 -0
- package/dist/src/channels/web.d.ts +341 -0
- package/dist/src/channels/web.js +903 -0
- package/dist/src/channels/web.js.map +1 -0
- package/dist/src/cli/adapter.d.ts +90 -0
- package/dist/src/cli/adapter.js +288 -0
- package/dist/src/cli/adapter.js.map +1 -0
- package/dist/src/cli/amend.d.ts +59 -0
- package/dist/src/cli/amend.js +2171 -0
- package/dist/src/cli/amend.js.map +1 -0
- package/dist/src/cli/args.d.ts +43 -0
- package/dist/src/cli/args.js +86 -0
- package/dist/src/cli/args.js.map +1 -0
- package/dist/src/cli/attest.d.ts +41 -0
- package/dist/src/cli/attest.js +307 -0
- package/dist/src/cli/attest.js.map +1 -0
- package/dist/src/cli/audit-card.d.ts +62 -0
- package/dist/src/cli/audit-card.js +201 -0
- package/dist/src/cli/audit-card.js.map +1 -0
- package/dist/src/cli/audit.d.ts +59 -0
- package/dist/src/cli/audit.js +460 -0
- package/dist/src/cli/audit.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +806 -0
- package/dist/src/cli/channel-telegram.js +2063 -0
- package/dist/src/cli/channel-telegram.js.map +1 -0
- package/dist/src/cli/channel-web.d.ts +131 -0
- package/dist/src/cli/channel-web.js +357 -0
- package/dist/src/cli/channel-web.js.map +1 -0
- package/dist/src/cli/channel.d.ts +71 -0
- package/dist/src/cli/channel.js +438 -0
- package/dist/src/cli/channel.js.map +1 -0
- package/dist/src/cli/checkpoint-tap.d.ts +169 -0
- package/dist/src/cli/checkpoint-tap.js +238 -0
- package/dist/src/cli/checkpoint-tap.js.map +1 -0
- package/dist/src/cli/codex.d.ts +2 -0
- package/dist/src/cli/codex.js +172 -0
- package/dist/src/cli/codex.js.map +1 -0
- package/dist/src/cli/coverage.d.ts +61 -0
- package/dist/src/cli/coverage.js +343 -0
- package/dist/src/cli/coverage.js.map +1 -0
- package/dist/src/cli/daemon.d.ts +120 -0
- package/dist/src/cli/daemon.js +631 -0
- package/dist/src/cli/daemon.js.map +1 -0
- package/dist/src/cli/doctor.d.ts +129 -0
- package/dist/src/cli/doctor.js +2762 -0
- package/dist/src/cli/doctor.js.map +1 -0
- package/dist/src/cli/env.d.ts +65 -0
- package/dist/src/cli/env.js +302 -0
- package/dist/src/cli/env.js.map +1 -0
- package/dist/src/cli/execute.d.ts +202 -0
- package/dist/src/cli/execute.js +1682 -0
- package/dist/src/cli/execute.js.map +1 -0
- package/dist/src/cli/exit-codes.d.ts +73 -0
- package/dist/src/cli/exit-codes.js +82 -0
- package/dist/src/cli/exit-codes.js.map +1 -0
- package/dist/src/cli/feedback.d.ts +60 -0
- package/dist/src/cli/feedback.js +205 -0
- package/dist/src/cli/feedback.js.map +1 -0
- package/dist/src/cli/gate-window.d.ts +40 -0
- package/dist/src/cli/gate-window.js +294 -0
- package/dist/src/cli/gate-window.js.map +1 -0
- package/dist/src/cli/gate.d.ts +68 -0
- package/dist/src/cli/gate.js +557 -0
- package/dist/src/cli/gate.js.map +1 -0
- package/dist/src/cli/git-scope.d.ts +190 -0
- package/dist/src/cli/git-scope.js +295 -0
- package/dist/src/cli/git-scope.js.map +1 -0
- package/dist/src/cli/gloss-attach.d.ts +85 -0
- package/dist/src/cli/gloss-attach.js +107 -0
- package/dist/src/cli/gloss-attach.js.map +1 -0
- package/dist/src/cli/gloss-codex-child.d.ts +9 -0
- package/dist/src/cli/gloss-codex-child.js +149 -0
- package/dist/src/cli/gloss-codex-child.js.map +1 -0
- package/dist/src/cli/gloss-codex.d.ts +24 -0
- package/dist/src/cli/gloss-codex.js +255 -0
- package/dist/src/cli/gloss-codex.js.map +1 -0
- package/dist/src/cli/gloss-options.d.ts +42 -0
- package/dist/src/cli/gloss-options.js +79 -0
- package/dist/src/cli/gloss-options.js.map +1 -0
- package/dist/src/cli/gloss.d.ts +265 -0
- package/dist/src/cli/gloss.js +362 -0
- package/dist/src/cli/gloss.js.map +1 -0
- package/dist/src/cli/help.d.ts +103 -0
- package/dist/src/cli/help.js +2339 -0
- package/dist/src/cli/help.js.map +1 -0
- package/dist/src/cli/hook-codex.d.ts +78 -0
- package/dist/src/cli/hook-codex.js +167 -0
- package/dist/src/cli/hook-codex.js.map +1 -0
- package/dist/src/cli/hook.d.ts +331 -0
- package/dist/src/cli/hook.js +2849 -0
- package/dist/src/cli/hook.js.map +1 -0
- package/dist/src/cli/import.d.ts +35 -0
- package/dist/src/cli/import.js +175 -0
- package/dist/src/cli/import.js.map +1 -0
- package/dist/src/cli/init.d.ts +84 -0
- package/dist/src/cli/init.js +336 -0
- package/dist/src/cli/init.js.map +1 -0
- package/dist/src/cli/instructions.d.ts +23 -0
- package/dist/src/cli/instructions.js +262 -0
- package/dist/src/cli/instructions.js.map +1 -0
- package/dist/src/cli/journal.d.ts +41 -0
- package/dist/src/cli/journal.js +238 -0
- package/dist/src/cli/journal.js.map +1 -0
- package/dist/src/cli/log-advance.d.ts +287 -0
- package/dist/src/cli/log-advance.js +840 -0
- package/dist/src/cli/log-advance.js.map +1 -0
- package/dist/src/cli/log-anchor.d.ts +176 -0
- package/dist/src/cli/log-anchor.js +387 -0
- package/dist/src/cli/log-anchor.js.map +1 -0
- package/dist/src/cli/log-checkpoint.d.ts +22 -0
- package/dist/src/cli/log-checkpoint.js +128 -0
- package/dist/src/cli/log-checkpoint.js.map +1 -0
- package/dist/src/cli/log-sync.d.ts +243 -0
- package/dist/src/cli/log-sync.js +849 -0
- package/dist/src/cli/log-sync.js.map +1 -0
- package/dist/src/cli/log-verbs.d.ts +16 -0
- package/dist/src/cli/log-verbs.js +360 -0
- package/dist/src/cli/log-verbs.js.map +1 -0
- package/dist/src/cli/long-help.d.ts +70 -0
- package/dist/src/cli/long-help.js +148 -0
- package/dist/src/cli/long-help.js.map +1 -0
- package/dist/src/cli/main.d.ts +77 -0
- package/dist/src/cli/main.js +1206 -0
- package/dist/src/cli/main.js.map +1 -0
- package/dist/src/cli/mcp.d.ts +52 -0
- package/dist/src/cli/mcp.js +306 -0
- package/dist/src/cli/mcp.js.map +1 -0
- package/dist/src/cli/paths.d.ts +56 -0
- package/dist/src/cli/paths.js +79 -0
- package/dist/src/cli/paths.js.map +1 -0
- package/dist/src/cli/payload.d.ts +58 -0
- package/dist/src/cli/payload.js +253 -0
- package/dist/src/cli/payload.js.map +1 -0
- package/dist/src/cli/policy.d.ts +43 -0
- package/dist/src/cli/policy.js +229 -0
- package/dist/src/cli/policy.js.map +1 -0
- package/dist/src/cli/preflight.d.ts +363 -0
- package/dist/src/cli/preflight.js +1175 -0
- package/dist/src/cli/preflight.js.map +1 -0
- package/dist/src/cli/progress.d.ts +78 -0
- package/dist/src/cli/progress.js +112 -0
- package/dist/src/cli/progress.js.map +1 -0
- package/dist/src/cli/prompt.d.ts +209 -0
- package/dist/src/cli/prompt.js +312 -0
- package/dist/src/cli/prompt.js.map +1 -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/records.js +66 -0
- package/dist/src/cli/records.js.map +1 -0
- package/dist/src/cli/render.d.ts +22 -0
- package/dist/src/cli/render.js +132 -0
- package/dist/src/cli/render.js.map +1 -0
- package/dist/src/cli/sandbox.d.ts +51 -0
- package/dist/src/cli/sandbox.js +150 -0
- package/dist/src/cli/sandbox.js.map +1 -0
- package/dist/src/cli/scaffold.d.ts +79 -0
- package/dist/src/cli/scaffold.js +137 -0
- package/dist/src/cli/scaffold.js.map +1 -0
- package/dist/src/cli/setup-adapter.d.ts +137 -0
- package/dist/src/cli/setup-adapter.js +509 -0
- package/dist/src/cli/setup-adapter.js.map +1 -0
- package/dist/src/cli/setup-channel.d.ts +117 -0
- package/dist/src/cli/setup-channel.js +635 -0
- package/dist/src/cli/setup-channel.js.map +1 -0
- package/dist/src/cli/setup-checkpoint.d.ts +57 -0
- package/dist/src/cli/setup-checkpoint.js +196 -0
- package/dist/src/cli/setup-checkpoint.js.map +1 -0
- package/dist/src/cli/setup-common.d.ts +275 -0
- package/dist/src/cli/setup-common.js +376 -0
- package/dist/src/cli/setup-common.js.map +1 -0
- package/dist/src/cli/setup-flow.d.ts +287 -0
- package/dist/src/cli/setup-flow.js +476 -0
- package/dist/src/cli/setup-flow.js.map +1 -0
- package/dist/src/cli/setup-service.d.ts +96 -0
- package/dist/src/cli/setup-service.js +308 -0
- package/dist/src/cli/setup-service.js.map +1 -0
- package/dist/src/cli/setup.d.ts +202 -0
- package/dist/src/cli/setup.js +473 -0
- package/dist/src/cli/setup.js.map +1 -0
- package/dist/src/cli/style.d.ts +320 -0
- package/dist/src/cli/style.js +469 -0
- package/dist/src/cli/style.js.map +1 -0
- package/dist/src/cli/token.d.ts +39 -0
- package/dist/src/cli/token.js +274 -0
- package/dist/src/cli/token.js.map +1 -0
- package/dist/src/cli/up.d.ts +155 -0
- package/dist/src/cli/up.js +849 -0
- package/dist/src/cli/up.js.map +1 -0
- package/dist/src/cli/usage.d.ts +37 -0
- package/dist/src/cli/usage.js +91 -0
- package/dist/src/cli/usage.js.map +1 -0
- package/dist/src/cli/values.d.ts +40 -0
- package/dist/src/cli/values.js +189 -0
- package/dist/src/cli/values.js.map +1 -0
- package/dist/src/cli/vault.d.ts +59 -0
- package/dist/src/cli/vault.js +362 -0
- package/dist/src/cli/vault.js.map +1 -0
- package/dist/src/cli/verb-registry.d.ts +76 -0
- package/dist/src/cli/verb-registry.js +2341 -0
- package/dist/src/cli/verb-registry.js.map +1 -0
- package/dist/src/cli/wordmark.d.ts +31 -0
- package/dist/src/cli/wordmark.js +52 -0
- package/dist/src/cli/wordmark.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/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-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 +170 -0
- package/dist/src/core/advance-cycle.js +200 -0
- package/dist/src/core/advance-cycle.js.map +1 -0
- package/dist/src/core/agents-md.d.ts +276 -0
- package/dist/src/core/agents-md.js +747 -0
- package/dist/src/core/agents-md.js.map +1 -0
- 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 +420 -0
- package/dist/src/core/attest.js +589 -0
- package/dist/src/core/attest.js.map +1 -0
- package/dist/src/core/audit.d.ts +492 -0
- package/dist/src/core/audit.js +882 -0
- package/dist/src/core/audit.js.map +1 -0
- package/dist/src/core/budgets.d.ts +238 -0
- package/dist/src/core/budgets.js +449 -0
- package/dist/src/core/budgets.js.map +1 -0
- package/dist/src/core/checkpoint.d.ts +500 -0
- package/dist/src/core/checkpoint.js +738 -0
- package/dist/src/core/checkpoint.js.map +1 -0
- package/dist/src/core/child-env.d.ts +88 -0
- package/dist/src/core/child-env.js +86 -0
- package/dist/src/core/child-env.js.map +1 -0
- package/dist/src/core/clock.d.ts +52 -0
- package/dist/src/core/clock.js +43 -0
- package/dist/src/core/clock.js.map +1 -0
- package/dist/src/core/command-class.d.ts +543 -0
- package/dist/src/core/command-class.js +2356 -0
- package/dist/src/core/command-class.js.map +1 -0
- package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
- package/dist/src/core/coverage-sources/adapter.js +71 -0
- package/dist/src/core/coverage-sources/adapter.js.map +1 -0
- package/dist/src/core/coverage-sources/gh.d.ts +48 -0
- package/dist/src/core/coverage-sources/gh.js +136 -0
- package/dist/src/core/coverage-sources/gh.js.map +1 -0
- package/dist/src/core/coverage-sources/git.d.ts +101 -0
- package/dist/src/core/coverage-sources/git.js +269 -0
- package/dist/src/core/coverage-sources/git.js.map +1 -0
- package/dist/src/core/coverage.d.ts +217 -0
- package/dist/src/core/coverage.js +337 -0
- package/dist/src/core/coverage.js.map +1 -0
- package/dist/src/core/credential-spec.d.ts +72 -0
- package/dist/src/core/credential-spec.js +23 -0
- package/dist/src/core/credential-spec.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +331 -0
- package/dist/src/core/dark-session.js +714 -0
- package/dist/src/core/dark-session.js.map +1 -0
- package/dist/src/core/decision-refusal.d.ts +185 -0
- package/dist/src/core/decision-refusal.js +265 -0
- package/dist/src/core/decision-refusal.js.map +1 -0
- package/dist/src/core/env-file.d.ts +450 -0
- package/dist/src/core/env-file.js +837 -0
- package/dist/src/core/env-file.js.map +1 -0
- package/dist/src/core/execute.d.ts +858 -0
- package/dist/src/core/execute.js +1271 -0
- package/dist/src/core/execute.js.map +1 -0
- package/dist/src/core/frontmatter.d.ts +78 -0
- package/dist/src/core/frontmatter.js +100 -0
- package/dist/src/core/frontmatter.js.map +1 -0
- package/dist/src/core/gate-window.d.ts +312 -0
- package/dist/src/core/gate-window.js +506 -0
- package/dist/src/core/gate-window.js.map +1 -0
- package/dist/src/core/gate.d.ts +1364 -0
- package/dist/src/core/gate.js +3002 -0
- package/dist/src/core/gate.js.map +1 -0
- package/dist/src/core/git-run.d.ts +73 -0
- package/dist/src/core/git-run.js +93 -0
- package/dist/src/core/git-run.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +157 -0
- package/dist/src/core/harness-version.js +211 -0
- package/dist/src/core/harness-version.js.map +1 -0
- package/dist/src/core/harness-wait.d.ts +55 -0
- package/dist/src/core/harness-wait.js +58 -0
- package/dist/src/core/harness-wait.js.map +1 -0
- package/dist/src/core/head-retry.d.ts +107 -0
- package/dist/src/core/head-retry.js +121 -0
- package/dist/src/core/head-retry.js.map +1 -0
- package/dist/src/core/instance.d.ts +253 -0
- package/dist/src/core/instance.js +319 -0
- package/dist/src/core/instance.js.map +1 -0
- package/dist/src/core/intake-limits.d.ts +247 -0
- package/dist/src/core/intake-limits.js +350 -0
- package/dist/src/core/intake-limits.js.map +1 -0
- package/dist/src/core/jcs.d.ts +52 -0
- package/dist/src/core/jcs.js +132 -0
- package/dist/src/core/jcs.js.map +1 -0
- package/dist/src/core/journal.d.ts +144 -0
- package/dist/src/core/journal.js +200 -0
- package/dist/src/core/journal.js.map +1 -0
- package/dist/src/core/live-draw.d.ts +436 -0
- package/dist/src/core/live-draw.js +703 -0
- package/dist/src/core/live-draw.js.map +1 -0
- package/dist/src/core/log-reconcile.d.ts +89 -0
- package/dist/src/core/log-reconcile.js +136 -0
- package/dist/src/core/log-reconcile.js.map +1 -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 +278 -0
- package/dist/src/core/log.js +546 -0
- package/dist/src/core/log.js.map +1 -0
- package/dist/src/core/loop.d.ts +274 -0
- package/dist/src/core/loop.js +487 -0
- package/dist/src/core/loop.js.map +1 -0
- package/dist/src/core/md-fence.d.ts +41 -0
- package/dist/src/core/md-fence.js +74 -0
- package/dist/src/core/md-fence.js.map +1 -0
- package/dist/src/core/money.d.ts +147 -0
- package/dist/src/core/money.js +195 -0
- package/dist/src/core/money.js.map +1 -0
- package/dist/src/core/payload-census.d.ts +74 -0
- package/dist/src/core/payload-census.js +146 -0
- package/dist/src/core/payload-census.js.map +1 -0
- package/dist/src/core/payload-store.d.ts +175 -0
- package/dist/src/core/payload-store.js +340 -0
- package/dist/src/core/payload-store.js.map +1 -0
- package/dist/src/core/payload.d.ts +71 -0
- package/dist/src/core/payload.js +80 -0
- package/dist/src/core/payload.js.map +1 -0
- package/dist/src/core/policy-diff.d.ts +292 -0
- package/dist/src/core/policy-diff.js +588 -0
- package/dist/src/core/policy-diff.js.map +1 -0
- package/dist/src/core/policy-expectations.d.ts +199 -0
- package/dist/src/core/policy-expectations.js +394 -0
- package/dist/src/core/policy-expectations.js.map +1 -0
- package/dist/src/core/policy-explain.d.ts +150 -0
- package/dist/src/core/policy-explain.js +258 -0
- package/dist/src/core/policy-explain.js.map +1 -0
- package/dist/src/core/policy-load.d.ts +527 -0
- package/dist/src/core/policy-load.js +536 -0
- package/dist/src/core/policy-load.js.map +1 -0
- package/dist/src/core/policy-match.d.ts +281 -0
- package/dist/src/core/policy-match.js +478 -0
- package/dist/src/core/policy-match.js.map +1 -0
- package/dist/src/core/policy-proposal.d.ts +265 -0
- package/dist/src/core/policy-proposal.js +458 -0
- package/dist/src/core/policy-proposal.js.map +1 -0
- package/dist/src/core/prompt-layout.d.ts +221 -0
- package/dist/src/core/prompt-layout.js +422 -0
- package/dist/src/core/prompt-layout.js.map +1 -0
- package/dist/src/core/protected-path-guard.d.ts +453 -0
- package/dist/src/core/protected-path-guard.js +1566 -0
- package/dist/src/core/protected-path-guard.js.map +1 -0
- package/dist/src/core/registration.d.ts +25 -0
- package/dist/src/core/registration.js +39 -0
- package/dist/src/core/registration.js.map +1 -0
- package/dist/src/core/reindex.d.ts +99 -0
- package/dist/src/core/reindex.js +336 -0
- package/dist/src/core/reindex.js.map +1 -0
- package/dist/src/core/sampler.d.ts +313 -0
- package/dist/src/core/sampler.js +388 -0
- package/dist/src/core/sampler.js.map +1 -0
- package/dist/src/core/sandbox.d.ts +290 -0
- package/dist/src/core/sandbox.js +424 -0
- package/dist/src/core/sandbox.js.map +1 -0
- package/dist/src/core/seal.d.ts +165 -0
- package/dist/src/core/seal.js +290 -0
- package/dist/src/core/seal.js.map +1 -0
- package/dist/src/core/state.d.ts +505 -0
- package/dist/src/core/state.js +1009 -0
- package/dist/src/core/state.js.map +1 -0
- package/dist/src/core/task-file.d.ts +185 -0
- package/dist/src/core/task-file.js +464 -0
- package/dist/src/core/task-file.js.map +1 -0
- package/dist/src/core/telegram-config.d.ts +93 -0
- package/dist/src/core/telegram-config.js +114 -0
- package/dist/src/core/telegram-config.js.map +1 -0
- package/dist/src/core/token.d.ts +409 -0
- package/dist/src/core/token.js +561 -0
- package/dist/src/core/token.js.map +1 -0
- package/dist/src/core/validate.d.ts +138 -0
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.js.map +1 -0
- package/dist/src/core/values.d.ts +137 -0
- package/dist/src/core/values.js +153 -0
- package/dist/src/core/values.js.map +1 -0
- package/dist/src/core/vault.d.ts +291 -0
- package/dist/src/core/vault.js +612 -0
- package/dist/src/core/vault.js.map +1 -0
- package/dist/src/core/verified-snapshot.d.ts +204 -0
- package/dist/src/core/verified-snapshot.js +506 -0
- package/dist/src/core/verified-snapshot.js.map +1 -0
- package/dist/src/core/verify.d.ts +336 -0
- package/dist/src/core/verify.js +549 -0
- package/dist/src/core/verify.js.map +1 -0
- package/dist/src/core/version.d.ts +8 -0
- package/dist/src/core/version.js +9 -0
- package/dist/src/core/version.js.map +1 -0
- package/dist/src/core/wysiwys.d.ts +370 -0
- package/dist/src/core/wysiwys.js +728 -0
- package/dist/src/core/wysiwys.js.map +1 -0
- package/dist/src/daemon/advance-child.d.ts +39 -0
- package/dist/src/daemon/advance-child.js +78 -0
- package/dist/src/daemon/advance-child.js.map +1 -0
- package/dist/src/daemon/advance.d.ts +466 -0
- package/dist/src/daemon/advance.js +849 -0
- package/dist/src/daemon/advance.js.map +1 -0
- package/dist/src/daemon/audit.d.ts +87 -0
- package/dist/src/daemon/audit.js +90 -0
- package/dist/src/daemon/audit.js.map +1 -0
- package/dist/src/daemon/daemon.d.ts +1180 -0
- package/dist/src/daemon/daemon.js +1988 -0
- package/dist/src/daemon/daemon.js.map +1 -0
- package/dist/src/daemon/dark-session.d.ts +64 -0
- package/dist/src/daemon/dark-session.js +119 -0
- package/dist/src/daemon/dark-session.js.map +1 -0
- package/dist/src/daemon/draw-child.d.ts +36 -0
- package/dist/src/daemon/draw-child.js +132 -0
- package/dist/src/daemon/draw-child.js.map +1 -0
- package/dist/src/daemon/draw.d.ts +154 -0
- package/dist/src/daemon/draw.js +458 -0
- package/dist/src/daemon/draw.js.map +1 -0
- package/dist/src/daemon/git-evidence.d.ts +173 -0
- package/dist/src/daemon/git-evidence.js +345 -0
- package/dist/src/daemon/git-evidence.js.map +1 -0
- package/dist/src/daemon/projection.d.ts +180 -0
- package/dist/src/daemon/projection.js +233 -0
- package/dist/src/daemon/projection.js.map +1 -0
- package/dist/src/daemon/prune.d.ts +207 -0
- package/dist/src/daemon/prune.js +376 -0
- package/dist/src/daemon/prune.js.map +1 -0
- package/dist/src/mcp/http.d.ts +113 -0
- package/dist/src/mcp/http.js +343 -0
- package/dist/src/mcp/http.js.map +1 -0
- package/dist/src/mcp/server.d.ts +265 -0
- package/dist/src/mcp/server.js +602 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/docs/adapter-api.md +106 -0
- package/docs/cli-reference.md +5716 -0
- package/docs/codex-enforced-session.md +30 -0
- package/package.json +53 -4
- package/schema/.gitkeep +0 -0
- package/schema/LICENSE +117 -0
- package/schema/codex-instance.schema.json +82 -0
- package/schema/envelope.schema.json +137 -0
- package/schema/event.schema.json +1811 -0
- 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/envelope/invalid/action-missing-idempotency-key.json +15 -0
- package/schema/fixtures/envelope/invalid/action-unknown-class-format.json +14 -0
- package/schema/fixtures/envelope/invalid/confidence-out-of-range.json +11 -0
- package/schema/fixtures/envelope/invalid/est-cost-bare-number.json +14 -0
- package/schema/fixtures/envelope/invalid/est-cost-noncanonical-string.json +14 -0
- package/schema/fixtures/envelope/invalid/malformed-assignee.json +10 -0
- package/schema/fixtures/envelope/invalid/malformed-created-by.json +7 -0
- package/schema/fixtures/envelope/invalid/malformed-max-latency.json +11 -0
- package/schema/fixtures/envelope/invalid/malformed-payload-hash.json +14 -0
- package/schema/fixtures/envelope/invalid/max-cost-bare-number.json +11 -0
- package/schema/fixtures/envelope/invalid/missing-origin.json +3 -0
- package/schema/fixtures/envelope/invalid/negative-est-cost.json +14 -0
- package/schema/fixtures/envelope/invalid/unknown-state.json +7 -0
- package/schema/fixtures/envelope/invalid/unknown-top-level-field.json +8 -0
- package/schema/fixtures/envelope/valid/action-payload-hash.json +17 -0
- package/schema/fixtures/envelope/valid/actions-without-budget.json +18 -0
- package/schema/fixtures/envelope/valid/canonical.json +25 -0
- package/schema/fixtures/envelope/valid/minimal.json +7 -0
- package/schema/fixtures/envelope/valid/multi-action-executed.json +30 -0
- package/schema/fixtures/envelope/valid/record-write-stage.json +22 -0
- package/schema/fixtures/event/invalid/approval-granted-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/approval-granted-empty-batch-delivery-id.json +17 -0
- package/schema/fixtures/event/invalid/approval-granted-fifth-reaction.json +16 -0
- package/schema/fixtures/event/invalid/approval-granted-missing-actor.json +14 -0
- package/schema/fixtures/event/invalid/approval-requested-missing-action-key.json +14 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-agent-policy-drift.json +15 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-missing-reason.json +15 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-system-actor.json +15 -0
- package/schema/fixtures/event/invalid/audit-decision-refused-human-actor.json +17 -0
- package/schema/fixtures/event/invalid/audit-decision-refused-missing-code.json +16 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +16 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +15 -0
- package/schema/fixtures/event/invalid/bad-actor-prefix.json +15 -0
- package/schema/fixtures/event/invalid/est-cost-bare-number.json +17 -0
- package/schema/fixtures/event/invalid/execution-completed-fabricated-exit-code.json +16 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-empty-id.json +18 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-extra-field.json +19 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-id-not-string.json +18 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-missing-adapter.json +17 -0
- package/schema/fixtures/event/invalid/execution-failed-open-reported-by.json +16 -0
- package/schema/fixtures/event/invalid/execution-indeterminate-open-reason.json +14 -0
- package/schema/fixtures/event/invalid/execution-reconciled-agent-actor.json +17 -0
- package/schema/fixtures/event/invalid/execution-started-negative-env-stripped.json +16 -0
- package/schema/fixtures/event/invalid/gate-bypassed-missing-opened-seq.json +15 -0
- package/schema/fixtures/event/invalid/gate-closed-non-integer-opened-seq.json +13 -0
- package/schema/fixtures/event/invalid/gate-opened-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-missing-organ-path.json +13 -0
- package/schema/fixtures/event/invalid/harness-unknown-kind.json +18 -0
- package/schema/fixtures/event/invalid/harness-version-multiline.json +16 -0
- package/schema/fixtures/event/invalid/log-checkpoint-agent-actor.json +17 -0
- package/schema/fixtures/event/invalid/log-checkpoint-missing-signature.json +16 -0
- package/schema/fixtures/event/invalid/log-checkpoint-short-signed-hash.json +17 -0
- package/schema/fixtures/event/invalid/log-checkpoint-unknown-signature-alg.json +17 -0
- package/schema/fixtures/event/invalid/malformed-ts.json +15 -0
- package/schema/fixtures/event/invalid/missing-alg.json +14 -0
- package/schema/fixtures/event/invalid/missing-hash.json +14 -0
- package/schema/fixtures/event/invalid/non-integer-seq.json +15 -0
- package/schema/fixtures/event/invalid/payload-pruned-human-actor.json +14 -0
- package/schema/fixtures/event/invalid/payload-pruned-missing-hash.json +14 -0
- package/schema/fixtures/event/invalid/policy-declined-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/policy-proposed-missing-diff.json +19 -0
- package/schema/fixtures/event/invalid/policy-proposed-system-actor.json +26 -0
- package/schema/fixtures/event/invalid/short-hash.json +15 -0
- package/schema/fixtures/event/invalid/unknown-alg.json +15 -0
- package/schema/fixtures/event/invalid/unknown-event-type.json +15 -0
- package/schema/fixtures/event/invalid/unknown-top-level-field.json +16 -0
- package/schema/fixtures/event/valid/approval-expired.json +15 -0
- package/schema/fixtures/event/valid/approval-granted-batch.json +19 -0
- package/schema/fixtures/event/valid/approval-granted-reaction.json +16 -0
- package/schema/fixtures/event/valid/approval-granted.json +15 -0
- package/schema/fixtures/event/valid/approval-rejected.json +15 -0
- package/schema/fixtures/event/valid/approval-requested.json +19 -0
- package/schema/fixtures/event/valid/approval-revoked.json +15 -0
- package/schema/fixtures/event/valid/approval-withdrawn-policy-drift.json +17 -0
- package/schema/fixtures/event/valid/approval-withdrawn.json +16 -0
- package/schema/fixtures/event/valid/audit-decision-refused.json +20 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +17 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +15 -0
- package/schema/fixtures/event/valid/audit-sampled.json +14 -0
- package/schema/fixtures/event/valid/budget-exceeded.json +21 -0
- package/schema/fixtures/event/valid/envelope-drift.json +16 -0
- package/schema/fixtures/event/valid/execution-completed-harness-report.json +16 -0
- package/schema/fixtures/event/valid/execution-completed-provider-ref.json +18 -0
- package/schema/fixtures/event/valid/execution-completed.json +15 -0
- package/schema/fixtures/event/valid/execution-failed-harness-report.json +16 -0
- package/schema/fixtures/event/valid/execution-failed.json +15 -0
- package/schema/fixtures/event/valid/execution-indeterminate.json +15 -0
- package/schema/fixtures/event/valid/execution-reconciled.json +17 -0
- package/schema/fixtures/event/valid/execution-started-env-stripped.json +17 -0
- package/schema/fixtures/event/valid/execution-started.json +14 -0
- package/schema/fixtures/event/valid/gate-bypassed-harness-version.json +18 -0
- package/schema/fixtures/event/valid/gate-bypassed.json +19 -0
- package/schema/fixtures/event/valid/gate-closed.json +14 -0
- package/schema/fixtures/event/valid/gate-opened.json +16 -0
- package/schema/fixtures/event/valid/gate-organ-attested.json +14 -0
- package/schema/fixtures/event/valid/genesis-null-prev.json +14 -0
- package/schema/fixtures/event/valid/log-checkpoint.json +17 -0
- package/schema/fixtures/event/valid/payload-pruned-orphan.json +13 -0
- package/schema/fixtures/event/valid/payload-pruned.json +17 -0
- package/schema/fixtures/event/valid/policy-declined.json +16 -0
- package/schema/fixtures/event/valid/policy-proposed.json +35 -0
- package/schema/fixtures/event/valid/policy-updated.json +14 -0
- package/schema/fixtures/event/valid/reconciliation-required.json +18 -0
- package/schema/fixtures/event/valid/reconciliation-satisfied.json +17 -0
- package/schema/fixtures/event/valid/route-accepted.json +15 -0
- package/schema/fixtures/event/valid/route-proposed.json +16 -0
- package/schema/fixtures/event/valid/spec-example.json +15 -0
- package/schema/fixtures/event/valid/task-registered-harness-version.json +23 -0
- package/schema/fixtures/event/valid/task-registered.json +14 -0
- package/schema/fixtures/hash/known-answer-pre-121.json +74 -0
- package/schema/fixtures/hash/known-answer.json +74 -0
- package/schema/fixtures/policy/invalid/bad-approval-ttl.json +7 -0
- package/schema/fixtures/policy/invalid/bad-web-port.json +4 -0
- package/schema/fixtures/policy/invalid/checkpoint-key-not-base64.json +6 -0
- package/schema/fixtures/policy/invalid/class-rule-missing-autonomy.json +9 -0
- package/schema/fixtures/policy/invalid/empty-class-key.json +6 -0
- package/schema/fixtures/policy/invalid/live-rate-on-human-only.json +7 -0
- package/schema/fixtures/policy/invalid/malformed-class-key.json +6 -0
- package/schema/fixtures/policy/invalid/missing-version.json +8 -0
- package/schema/fixtures/policy/invalid/negative-limit.json +9 -0
- package/schema/fixtures/policy/invalid/non-numeric-limit.json +9 -0
- package/schema/fixtures/policy/invalid/non-positive-max-pending.json +9 -0
- package/schema/fixtures/policy/invalid/on-expiry-grant.json +8 -0
- package/schema/fixtures/policy/invalid/payload-retention-bare-number.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-compound.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-fractional.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-zero.json +4 -0
- package/schema/fixtures/policy/invalid/protected-paths-escape.json +4 -0
- package/schema/fixtures/policy/invalid/protected-paths-glob.json +4 -0
- package/schema/fixtures/policy/invalid/retro-rate-on-human-only.json +7 -0
- package/schema/fixtures/policy/invalid/retro-rate-on-manual.json +7 -0
- package/schema/fixtures/policy/invalid/retro-rate-zero.json +7 -0
- package/schema/fixtures/policy/invalid/sample-rate-too-high.json +5 -0
- package/schema/fixtures/policy/invalid/sampling-secret-env-empty.json +7 -0
- package/schema/fixtures/policy/invalid/sampling-secret-env-not-string.json +6 -0
- package/schema/fixtures/policy/invalid/skew-tolerance-compound.json +6 -0
- package/schema/fixtures/policy/invalid/unknown-autonomy.json +7 -0
- package/schema/fixtures/policy/invalid/unknown-class-rule-key.json +6 -0
- package/schema/fixtures/policy/invalid/unknown-top-level-key.json +7 -0
- package/schema/fixtures/policy/invalid/vault-passphrase-env-empty.json +6 -0
- package/schema/fixtures/policy/invalid/vault-passphrase-literal.json +6 -0
- package/schema/fixtures/policy/invalid/version-not-string.json +4 -0
- package/schema/fixtures/policy/valid/canonical.json +47 -0
- package/schema/fixtures/policy/valid/checkpoint-keys.json +18 -0
- package/schema/fixtures/policy/valid/class-approvers-limits.json +25 -0
- package/schema/fixtures/policy/valid/class-retro-rate.json +17 -0
- package/schema/fixtures/policy/valid/global-budgets.json +19 -0
- package/schema/fixtures/policy/valid/human-only.json +9 -0
- package/schema/fixtures/policy/valid/minimal.json +6 -0
- package/schema/fixtures/policy/valid/protected-paths.json +10 -0
- package/schema/fixtures/policy/valid/record-namespace.json +13 -0
- package/schema/fixtures/policy/valid/request-volume-limits.json +26 -0
- package/schema/fixtures/policy/valid/retention-and-sampling-secret.json +16 -0
- package/schema/fixtures/policy/valid/skew-tolerance.json +15 -0
- package/schema/fixtures/policy/valid/vault-passphrase-env.json +14 -0
- package/schema/fixtures/policy/valid/wildcards.json +15 -0
- package/schema/fixtures/policy-md/invalid/alias-bomb.md +15 -0
- package/schema/fixtures/policy-md/invalid/no-fence.md +7 -0
- package/schema/fixtures/policy-md/invalid/protected-route-not-a-subclass.md +16 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-autonomy.md +16 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-read-proof.md +17 -0
- package/schema/fixtures/policy-md/invalid/two-fences.md +19 -0
- package/schema/fixtures/policy-md/invalid/unclosed-fence.md +11 -0
- package/schema/fixtures/policy-md/invalid/wrong-info-string.md +11 -0
- package/schema/fixtures/policy-md/invalid/yaml-syntax-error.md +13 -0
- package/schema/fixtures/policy-md/precedence/both/APPROVAL.md +7 -0
- package/schema/fixtures/policy-md/precedence/both/APPROVALS.md +7 -0
- package/schema/fixtures/policy-md/precedence/fallback-only/APPROVALS.md +7 -0
- package/schema/fixtures/policy-md/valid/canonical.md +50 -0
- package/schema/fixtures/policy-md/valid/daemon-read-proof.md +18 -0
- package/schema/fixtures/policy-md/valid/minimal.md +3 -0
- package/schema/fixtures/policy-md/valid/prose-lookalikes.md +54 -0
- package/schema/fixtures/policy-md/valid/routed-protected-paths.md +49 -0
- package/schema/fixtures/policy-md/valid/with-values.md +79 -0
- package/schema/fixtures/sample-record/invalid/bad-date-time.json +4 -0
- package/schema/fixtures/sample-record/invalid/missing-required-field.json +3 -0
- package/schema/fixtures/sample-record/invalid/unknown-top-level-field.json +5 -0
- package/schema/fixtures/sample-record/invalid/wrong-type.json +4 -0
- package/schema/fixtures/sample-record/valid/minimal.json +4 -0
- package/schema/fixtures/sample-record/valid/with-note.json +5 -0
- package/schema/fixtures/values/invalid/class-shaped.json +9 -0
- package/schema/fixtures/values/invalid/duplicate-entry.json +4 -0
- package/schema/fixtures/values/invalid/non-string-item.json +4 -0
- package/schema/fixtures/values/invalid/over-cap.json +26 -0
- package/schema/fixtures/values/invalid/unknown-key.json +5 -0
- package/schema/fixtures/values/invalid/version-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +7 -0
- package/schema/fixtures/values/valid/full.json +20 -0
- package/schema/fixtures/values/valid/minimal.json +1 -0
- package/schema/fixtures/values-md/invalid/schema-invalid.md +62 -0
- package/schema/fixtures/values-md/invalid/two-blocks.md +69 -0
- package/schema/fixtures/values-md/invalid/unterminated.md +61 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +63 -0
- package/schema/fixtures/values-md/valid/absent.md +50 -0
- package/schema/fixtures/values-md/valid/with-values.md +79 -0
- package/schema/policy.schema.json +501 -0
- package/schema/sample-record.schema.json +26 -0
- package/schema/values.schema.json +55 -0
- package/templates/codex/README.md +9 -0
|
@@ -0,0 +1,1566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The protected-path guard (APRV-151): CI asks the log, not the session.
|
|
3
|
+
*
|
|
4
|
+
* ## The hole this closes
|
|
5
|
+
*
|
|
6
|
+
* `approval hook claude-code` classifies a file-tool call before it applies,
|
|
7
|
+
* and when the target is a protected path the answer is `policy.edit` and the
|
|
8
|
+
* phone buzzes. That control is only as good as the wiring: a session whose
|
|
9
|
+
* PreToolUse hook never loaded edits SPEC.md with no prompt, no denial, and —
|
|
10
|
+
* this is the part that hurts — no refused-request record either. Nothing in
|
|
11
|
+
* the log says the edit happened, because the thing that would have written
|
|
12
|
+
* the record is the thing that did not run. Two incidents (2026-08-29 SPEC.md
|
|
13
|
+
* in worktree `aprv-145-land`, 2026-08-30 `.github/workflows/ci.yml` in
|
|
14
|
+
* `agent-a3f5d255372d43ac0`) are the same shape.
|
|
15
|
+
*
|
|
16
|
+
* A detector that asked the session whether it was hooked would be asking the
|
|
17
|
+
* party under oversight to report its own oversight, which SPEC.md §11 rules
|
|
18
|
+
* out on principle. So this one never talks to a session. It takes two commits,
|
|
19
|
+
* asks git which protected paths changed between them, and requires — for each
|
|
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
|
+
* ## What counts as evidence
|
|
26
|
+
*
|
|
27
|
+
* Evidence is about the CHANGE, not about the path (APRV-202). The guard reads
|
|
28
|
+
* the blob at both commits, reduces the difference to the lines this pull
|
|
29
|
+
* request adds and removes, and requires every one of them to trace back to
|
|
30
|
+
* bound material in an authorization record. A path authorized last Tuesday
|
|
31
|
+
* and edited again today has evidence naming it and none covering today's
|
|
32
|
+
* lines, and that fails. Naming remains necessary; it stopped being sufficient.
|
|
33
|
+
*
|
|
34
|
+
* Four verdicts pass:
|
|
35
|
+
*
|
|
36
|
+
* 1. `attested` — the policy file, and the gate's ORGANS. `approval policy
|
|
37
|
+
* amend --commit` appends `policy.updated` carrying `{policy_path, sha256}`,
|
|
38
|
+
* the SHA-256 of the policy bytes a human attested. So for `APPROVAL.md` the
|
|
39
|
+
* guard does not look for a grant at all: it hashes the file's bytes AT THE
|
|
40
|
+
* HEAD COMMIT and requires that exact digest in the log. This is the
|
|
41
|
+
* strongest match in the system — content-level, not path-level — and it is
|
|
42
|
+
* why amendment PRs pass without a `policy.edit` grant, which they would
|
|
43
|
+
* never have.
|
|
44
|
+
*
|
|
45
|
+
* Since APRV-272 the same verdict covers the gate's organs, the harness
|
|
46
|
+
* files that install the hook (`.claude/settings.json` and kin), on a
|
|
47
|
+
* `gate.organ.attested` record carrying that path and that digest. They need
|
|
48
|
+
* it for a stronger reason than the policy file does: an organ is
|
|
49
|
+
* `policy.core`, `policy.core` is human-only, and the gate mints NOTHING for
|
|
50
|
+
* a human-only class — so `granted-file` and `granted-command` cannot exist
|
|
51
|
+
* for one however correctly a human edited it, and before this the guard
|
|
52
|
+
* could only ever fail such a change (PR #300). The path is part of the
|
|
53
|
+
* match: a digest attested for one organ is not evidence for another, which
|
|
54
|
+
* is why the organ record carries a whole relative path where the policy
|
|
55
|
+
* record carries a basename.
|
|
56
|
+
* 2. `policy-authorized-file` — an exact Edit or Write whose verified
|
|
57
|
+
* `execution.started` is preceded by its unique matching registration and no
|
|
58
|
+
* approval request. The registration, start and recomputed stored payload
|
|
59
|
+
* must agree on task, action, class and hash. The start must precede the
|
|
60
|
+
* change and the recorded class must be the class this path is routed to.
|
|
61
|
+
* This records authorization to execute, not successful completion; the
|
|
62
|
+
* exact hunk checks below establish whether those bytes landed.
|
|
63
|
+
* 3. `granted-file` — a file-tool edit. The hook binds the CHANGE rather than
|
|
64
|
+
* the touch (APRV-124), so the bound material carries `file` plus the exact
|
|
65
|
+
* edit: `{before, after}` for an Edit, `{content}` for a Write. That is
|
|
66
|
+
* HUNK-level evidence, and it is used as such. The granted `after` bytes
|
|
67
|
+
* have to occur verbatim in the blob at head before any added line is
|
|
68
|
+
* credited to them, and the granted `before` bytes have to occur in the blob
|
|
69
|
+
* at base before any removed line is. A granted edit whose after-state is not
|
|
70
|
+
* in head is a grant for something that did not land, and covers nothing.
|
|
71
|
+
* Some payloads carry the `{input}` fallback shape instead, which describes
|
|
72
|
+
* no bytes; those name the path and cover nothing.
|
|
73
|
+
* 4. `granted-command` — a shell edit. The bound material is `{command, cwd}`
|
|
74
|
+
* or `{argv, cwd}`, and the guard re-runs the runtime's own
|
|
75
|
+
* {@link classifyCommand} over it, requiring a segment that classifies as a
|
|
76
|
+
* granting class BECAUSE of a word naming this path. A mention is not a
|
|
77
|
+
* grant: `cat SPEC.md` is `read.shell` and proves nothing, and the first
|
|
78
|
+
* draft of this module, which substring-matched, accepted `hook classify --
|
|
79
|
+
* vi SPEC.md` as evidence for a later SPEC.md edit.
|
|
80
|
+
*
|
|
81
|
+
* A command payload cannot describe hunks: `node scripts/apply.mjs` names
|
|
82
|
+
* the file it will rewrite and says nothing about the bytes. So this kind is
|
|
83
|
+
* attributed rather than covered, and three things all have to hold:
|
|
84
|
+
*
|
|
85
|
+
* - The write lands on THIS checkout's copy of the path. The payload's `cwd`
|
|
86
|
+
* joined with the repository-relative path has to be exactly the word the
|
|
87
|
+
* classifier matched (see {@link commandTargetsPath}). Three of the grants
|
|
88
|
+
* that would otherwise have carried PR #187's SPEC.md change were dry runs
|
|
89
|
+
* into `$SCRATCH/dry/SPEC.md`.
|
|
90
|
+
* - The grant was SPENT: an `execution.started` for its `action_key`, and
|
|
91
|
+
* its `execution.completed` when the log has one. A grant nobody spent
|
|
92
|
+
* authorized a command that never ran.
|
|
93
|
+
* - That run sits within {@link DEFAULT_COMMAND_ATTRIBUTION_MS} of the
|
|
94
|
+
* commit AND does not start after it. A command's effect follows its own
|
|
95
|
+
* `execution.started`, so a run four hours after the commit did not write
|
|
96
|
+
* it — a real batch, on 2026-09-02, that a symmetric window would have
|
|
97
|
+
* credited with PR #187's changes.
|
|
98
|
+
*
|
|
99
|
+
* The finding names the run it attributed the change to, so a reader can
|
|
100
|
+
* check the attribution rather than take it. It stays weaker than
|
|
101
|
+
* `granted-file` because time is a weaker link than bytes: everything the
|
|
102
|
+
* approved run wrote to that path in its window is carried by it.
|
|
103
|
+
*
|
|
104
|
+
* There is deliberately no class-level pass. A `policy.edit` grant that exists
|
|
105
|
+
* in the window but names some other file is not evidence that anybody saw
|
|
106
|
+
* THIS edit, and accepting it would let one approved edit launder every other
|
|
107
|
+
* edit in the same window. Class-level grants appear in the failure detail as
|
|
108
|
+
* diagnosis, never as a verdict.
|
|
109
|
+
*
|
|
110
|
+
* ## Grants go stale
|
|
111
|
+
*
|
|
112
|
+
* Naming the path is necessary and not sufficient, because grants accumulate
|
|
113
|
+
* forever. Run without a recency rule against the real log, this guard passed a
|
|
114
|
+
* SPEC.md edit made on 2026-08-29 on the strength of a `git add SPEC.md`
|
|
115
|
+
* granted on 2026-08-20: once any edit to a path has ever been approved, every
|
|
116
|
+
* later edit to that path would inherit the approval. So evidence must also sit
|
|
117
|
+
* within {@link DEFAULT_LOOKBACK_MS} of the commit that introduced the change,
|
|
118
|
+
* on either side of it. Either side, because both orderings are real: a grant
|
|
119
|
+
* shortly BEFORE the commit is the ordinary case, and a grant shortly after is
|
|
120
|
+
* the grant-follows-write anomaly (APRV-117/150 adjacent) — a defect in its own
|
|
121
|
+
* right, but a complete consent trail all the same, and not this guard's to
|
|
122
|
+
* adjudicate.
|
|
123
|
+
*
|
|
124
|
+
* That bound used to be the guard's weakest joint: a repeat edit to the same
|
|
125
|
+
* path inside the window inherited the earlier grant, and the guard passed
|
|
126
|
+
* PR #187, #196 and #207 on grants that authorized some earlier edit. APRV-202
|
|
127
|
+
* closes it, and the window is now the cheap pre-filter in front of the real
|
|
128
|
+
* question. A grant inside the window still has to cover the lines: an
|
|
129
|
+
* uncovered change fails `uncovered-hunk` however fresh the grants naming its
|
|
130
|
+
* path are. Attestation is exempt from the bound, because it matches CONTENT: bytes that
|
|
131
|
+
* hash to an attested digest are the attested bytes whenever they were signed.
|
|
132
|
+
* And when git cannot date the change at all, no bound is applied rather than a
|
|
133
|
+
* weaker one invented: see `changeTsFor` on {@link GuardInput} for why a bound
|
|
134
|
+
* against the head commit would have been theatre. The finding says which of
|
|
135
|
+
* the two it got, every time.
|
|
136
|
+
*
|
|
137
|
+
* ## How a hunk is decided to be covered
|
|
138
|
+
*
|
|
139
|
+
* The unit is a line of text. `added` is the multiset of lines the head blob
|
|
140
|
+
* has and the base blob does not; `removed` is the converse. A line is covered
|
|
141
|
+
* when its exact text appears in the granted material of some in-window grant
|
|
142
|
+
* (in `after`/`content` for an added line, in `before` for a removed one) and
|
|
143
|
+
* that material is anchored to the blob it claims, as described above. Coverage
|
|
144
|
+
* may be assembled from several grants, because one pull request may carry
|
|
145
|
+
* several approved edits to one file; the finding names every contributing
|
|
146
|
+
* grant and puts the strongest and nearest at the head.
|
|
147
|
+
*
|
|
148
|
+
* Three properties of that choice are worth stating, because each is a limit:
|
|
149
|
+
*
|
|
150
|
+
* - A blob that differs while its line multiset does not is a REORDERING, and
|
|
151
|
+
* it is reported as one uncovered hunk rather than as no change. A rule that
|
|
152
|
+
* compared multisets alone would pass a rewrite that only moved paragraphs.
|
|
153
|
+
* - Blank and whitespace-only lines neither need coverage nor give it. They
|
|
154
|
+
* carry no content, and treating them as material would let one granted edit
|
|
155
|
+
* containing an empty line cover every blank line added anywhere.
|
|
156
|
+
* - Coverage is by line text, not by position. An added line whose exact text
|
|
157
|
+
* appears in some granted edit counts as covered even if it landed somewhere
|
|
158
|
+
* else in the file. Tightening that to positions would trade a narrow
|
|
159
|
+
* laundering channel (repeating a line the human already approved) for false
|
|
160
|
+
* failures on every rebase and re-indent, and the first is the cheaper loss.
|
|
161
|
+
*
|
|
162
|
+
* ## The evidence surface is not a protected write surface
|
|
163
|
+
*
|
|
164
|
+
* `.approval/` is protected wherever it sits, and the daemon appends to it
|
|
165
|
+
* every time anything is approved — so a records / log-advance pull request
|
|
166
|
+
* changes `.approval/log/events.jsonl`, the payload store beside it, and the
|
|
167
|
+
* regenerated `QUEUE.md`. Requiring a grant for those would require a grant for
|
|
168
|
+
* the evidence, which is circular and would make it impossible to land the very
|
|
169
|
+
* commits this guard reads. {@link EXEMPT_PREFIXES} names that surface, and
|
|
170
|
+
* nothing else under `.approval/` is exempt: the vault, the environment map and
|
|
171
|
+
* anything else that lands there is still a protected write.
|
|
172
|
+
*
|
|
173
|
+
* ## The lag, and the ordering rule it implies
|
|
174
|
+
*
|
|
175
|
+
* The log on `main` trails the primary checkout's live log; advances land
|
|
176
|
+
* periodically as records pull requests. A grant made this morning may not be
|
|
177
|
+
* on `main` yet, and this module can only see the records its caller hands it.
|
|
178
|
+
* That is not a bug to paper over, it is an ordering rule, and every failure
|
|
179
|
+
* states it: **the log advance carrying the grant must be pushed to a records
|
|
180
|
+
* branch or merged to main before or with the protected-path pull request.**
|
|
181
|
+
* Each failure also names the window it searched (the seq and timestamp range
|
|
182
|
+
* of the records it was given) so the reader can tell "the grant is not there"
|
|
183
|
+
* from "the grant is newer than this log".
|
|
184
|
+
*
|
|
185
|
+
* A records branch counts because the caller may read further along the same
|
|
186
|
+
* chain than the head commit does (APRV-260): `scripts/protected-path-guard.mjs`
|
|
187
|
+
* takes the freshest committed copy that carries head's own last record at
|
|
188
|
+
* head's index, so an advance that is pushed but not yet merged is already
|
|
189
|
+
* evidence. Nothing here changes: this module still reads only the verified
|
|
190
|
+
* records it was handed, and the window it reports is the window it searched.
|
|
191
|
+
*
|
|
192
|
+
* ## Fail closed
|
|
193
|
+
*
|
|
194
|
+
* A missing log, a log that does not pass chain verification, and a protected
|
|
195
|
+
* path with no evidence are all failures, each with its own code. Records that
|
|
196
|
+
* have not passed verification are never read for evidence (SPEC.md §11.1
|
|
197
|
+
* invariant 1): the caller hands this module the verified records or none.
|
|
198
|
+
* This module appends nothing, reads no clock, and performs no IO of its own —
|
|
199
|
+
* git plumbing and file reads live in the caller.
|
|
200
|
+
*/
|
|
201
|
+
import { organAttestationOf } from "./attest.js";
|
|
202
|
+
import { parseApplyPatch } from "./apply-patch.js";
|
|
203
|
+
import { classifyCommand, isGateOrganPath, isProtectedPath, normalizePathSpelling, POLICY_EDIT_SUBCLASS, protectedPathClass, } from "./command-class.js";
|
|
204
|
+
import { payloadHash } from "./payload.js";
|
|
205
|
+
/**
|
|
206
|
+
* Classes whose grant authorizes a protected-path write.
|
|
207
|
+
*
|
|
208
|
+
* `policy.edit` is what the hook and this repository's policy use.
|
|
209
|
+
* `policy.core` is accepted alongside it because SPEC.md §7's taxonomy admits
|
|
210
|
+
* a stricter sibling and a policy that routed the highest-value edits there
|
|
211
|
+
* should not lose its evidence.
|
|
212
|
+
*/
|
|
213
|
+
export const GRANTING_CLASSES = ["policy.edit", "policy.core"];
|
|
214
|
+
/**
|
|
215
|
+
* Does a grant of this class authorize a protected-path write? (APRV-266.)
|
|
216
|
+
*
|
|
217
|
+
* The named classes above, plus any `policy.edit` sub-class a policy routes a
|
|
218
|
+
* path to. Both directions of the cross-check matter and both are accepted:
|
|
219
|
+
*
|
|
220
|
+
* - A grant of the ROUTED class is the ordinary case once a policy adopts
|
|
221
|
+
* routing. `policy.edit.spec` is the class the hook asked about and the class
|
|
222
|
+
* the human decided, so it is the class the record carries.
|
|
223
|
+
* - A grant of `policy.edit` ITSELF is accepted for a path now routed, because
|
|
224
|
+
* a routing is a policy edit and the two are not synchronized: a grant taken
|
|
225
|
+
* under yesterday's string-only policy, or under the daemon's own fallback
|
|
226
|
+
* when the policy would not load, names `policy.edit` for a path today's
|
|
227
|
+
* policy routes. Refusing it would make adopting a routing retroactively
|
|
228
|
+
* invalidate evidence that was correct when it was taken.
|
|
229
|
+
*
|
|
230
|
+
* What this does NOT do is loosen the guard: the class only opens the door, and
|
|
231
|
+
* the naming test — the grant's own material has to name THIS path, and since
|
|
232
|
+
* APRV-202 has to cover the actual hunks — is unchanged and is what decides.
|
|
233
|
+
* A sub-class name is not authority over anything; the namespace is closed to
|
|
234
|
+
* `policy.edit.*` in the classifier, so no grant of `log.mutate` or
|
|
235
|
+
* `policy.core` can be manufactured by naming one (SPEC.md §11.1 invariant 9).
|
|
236
|
+
*/
|
|
237
|
+
export function isGrantingClass(actionClass) {
|
|
238
|
+
return GRANTING_CLASSES.includes(actionClass) || POLICY_EDIT_SUBCLASS.test(actionClass);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The daemon's own append surface: evidence, not a protected write.
|
|
242
|
+
*
|
|
243
|
+
* Repository-relative, `/`-separated prefixes. A changed path equal to one of
|
|
244
|
+
* these, or under one of the directory ones, is skipped before any evidence is
|
|
245
|
+
* sought. See the module note for why this carve-out is narrow on purpose.
|
|
246
|
+
*/
|
|
247
|
+
export const EXEMPT_PREFIXES = [
|
|
248
|
+
".approval/log/",
|
|
249
|
+
".approval/payloads/",
|
|
250
|
+
".approval/QUEUE.md",
|
|
251
|
+
];
|
|
252
|
+
/** Every way this guard can refuse, as stable codes. */
|
|
253
|
+
export const GUARD_FAILURE_CODES = [
|
|
254
|
+
/** The log blob does not exist at the head commit at all. */
|
|
255
|
+
"log-missing",
|
|
256
|
+
/** The log exists and does not pass chain verification. */
|
|
257
|
+
"log-unverified",
|
|
258
|
+
/** A protected path changed and nothing in the log is evidence for it. */
|
|
259
|
+
"no-evidence",
|
|
260
|
+
/**
|
|
261
|
+
* Grants DO name this path, inside the window, and some added or removed line
|
|
262
|
+
* of this change traces to none of their bound material (APRV-202).
|
|
263
|
+
*
|
|
264
|
+
* Distinct from `no-evidence` on purpose, because the two ask different
|
|
265
|
+
* things of the reader. `no-evidence` says nobody approved anything about
|
|
266
|
+
* this file and the question is whether the hook fired at all.
|
|
267
|
+
* `uncovered-hunk` says somebody approved something about this file and it
|
|
268
|
+
* was not this, which is the repeat-edit shape: the grant is real, the
|
|
269
|
+
* consent trail for THESE bytes is missing, and the fix is to take the
|
|
270
|
+
* change to the gate rather than to hunt for a lost record.
|
|
271
|
+
*/
|
|
272
|
+
"uncovered-hunk",
|
|
273
|
+
/**
|
|
274
|
+
* The blobs at base and head could not be read for this path (git could not
|
|
275
|
+
* show them, or they are binary), so no coverage could be established.
|
|
276
|
+
* Failing is the fail-closed direction: a change the guard cannot read is not
|
|
277
|
+
* a change it has checked.
|
|
278
|
+
*/
|
|
279
|
+
"change-unreadable",
|
|
280
|
+
];
|
|
281
|
+
/**
|
|
282
|
+
* How far from the commit that introduced a change a grant may sit and still be
|
|
283
|
+
* evidence for it: seven days, either side. See the module note.
|
|
284
|
+
*/
|
|
285
|
+
export const DEFAULT_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
286
|
+
/**
|
|
287
|
+
* How far from the change commit a granted command's RUN may sit and still be
|
|
288
|
+
* the run that produced it: six hours, either side.
|
|
289
|
+
*
|
|
290
|
+
* Deliberately much tighter than {@link DEFAULT_LOOKBACK_MS}, because it is
|
|
291
|
+
* carrying much more weight. A file grant is checked against the bytes, so the
|
|
292
|
+
* recency bound is only a sanity rail around a content match. A command grant
|
|
293
|
+
* has no bytes to check, so time is the whole attribution, and a week of it
|
|
294
|
+
* would re-open exactly the hole this closes: every later edit to a path some
|
|
295
|
+
* approved script once wrote would inherit that script's grant. Six hours is
|
|
296
|
+
* about a working session, which is the unit of "this run produced this
|
|
297
|
+
* commit"; a batch that legitimately takes longer than that is asked for a
|
|
298
|
+
* fresh approval, which costs one tap.
|
|
299
|
+
*/
|
|
300
|
+
export const DEFAULT_COMMAND_ATTRIBUTION_MS = 6 * 60 * 60 * 1000;
|
|
301
|
+
/** Fail-closed resource bounds for exact protected-edit reconstruction. */
|
|
302
|
+
export const EXACT_REPLAY_MAX_CANDIDATES = 128;
|
|
303
|
+
export const EXACT_REPLAY_MAX_STATES = 2_048;
|
|
304
|
+
export const EXACT_REPLAY_MAX_EXAMINED_BYTES = 64 * 1024 * 1024;
|
|
305
|
+
/** Split on either separator, dropping empties and `.` noise. */
|
|
306
|
+
function segmentsOf(candidate) {
|
|
307
|
+
return candidate.split(/[/\\]+/u).filter((part) => part.length > 0 && part !== ".");
|
|
308
|
+
}
|
|
309
|
+
/** Does `candidate` end with `want`, segment-wise? `a/b/c.md` ends with `b/c.md`. */
|
|
310
|
+
function endsWithSegments(candidate, want) {
|
|
311
|
+
const have = segmentsOf(candidate);
|
|
312
|
+
const tail = segmentsOf(want);
|
|
313
|
+
if (tail.length === 0 || tail.length > have.length)
|
|
314
|
+
return false;
|
|
315
|
+
const offset = have.length - tail.length;
|
|
316
|
+
return tail.every((segment, index) => segment === have[offset + index]);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Does this command line WRITE `path`, as the runtime's own classifier reads it?
|
|
320
|
+
*
|
|
321
|
+
* Substring matching was the first thing tried here and it is wrong: run
|
|
322
|
+
* against the real log it passed `node cli.js hook classify -- vi SPEC.md` and
|
|
323
|
+
* a heredoc whose body happened to contain the words `SPEC.md` as evidence for
|
|
324
|
+
* a later SPEC.md edit. A grant that merely MENTIONS a path authorizes nothing,
|
|
325
|
+
* and a guard that accepted mentions would let any approved command launder
|
|
326
|
+
* every edit to every path it happened to print.
|
|
327
|
+
*
|
|
328
|
+
* So the question is put to `classifyCommand`, the same deterministic
|
|
329
|
+
* classifier the hook gates with: the granted command is evidence for `path`
|
|
330
|
+
* only when one of its segments classifies as a granting class BECAUSE of a
|
|
331
|
+
* word that names this path (`ClassifiedSegment.path`, APRV-143). `cat SPEC.md`
|
|
332
|
+
* is `read.shell` and proves nothing; `cp draft.md /repo/SPEC.md` is
|
|
333
|
+
* `policy.edit` on `/repo/SPEC.md` and proves exactly the thing asked.
|
|
334
|
+
*
|
|
335
|
+
* A command the classifier refuses (opaque, unclassifiable) yields no evidence,
|
|
336
|
+
* which is the fail-closed direction: an unreadable command is not a licence.
|
|
337
|
+
*/
|
|
338
|
+
function commandWritesPath(command, path, policyProtectedPaths, cwd) {
|
|
339
|
+
const classified = classifyCommand(command, policyProtectedPaths);
|
|
340
|
+
if (!classified.ok)
|
|
341
|
+
return null;
|
|
342
|
+
for (const segment of classified.segments) {
|
|
343
|
+
if (!isGrantingClass(segment.class))
|
|
344
|
+
continue;
|
|
345
|
+
const named = segment.path;
|
|
346
|
+
if (typeof named === "string" && endsWithSegments(named, path)) {
|
|
347
|
+
return { text: segment.text, named };
|
|
348
|
+
}
|
|
349
|
+
// `ClassifiedSegment.path` holds ONE path, the word that selected the
|
|
350
|
+
// class, and a batch names several: PR #187's granted run is
|
|
351
|
+
// `node apply.mjs <root> SPEC.md .github/workflows/ci.yml tests/…`, which
|
|
352
|
+
// reports SPEC.md and would leave the workflow file unattributed. So the
|
|
353
|
+
// other words of a segment ALREADY classified as a protected write are
|
|
354
|
+
// considered too, and only on the strict test: the word has to resolve,
|
|
355
|
+
// against the recorded `cwd`, to exactly this checkout's copy of this
|
|
356
|
+
// path. A mention cannot pass that, and neither can a segment the
|
|
357
|
+
// classifier did not already call a protected write.
|
|
358
|
+
for (const word of segment.text.split(/[\s;|&()<>]+/u)) {
|
|
359
|
+
const bare = word.replace(/^['"]+|['"]+$/gu, "");
|
|
360
|
+
if (bare.length === 0)
|
|
361
|
+
continue;
|
|
362
|
+
if (commandTargetsPath(bare, cwd, path))
|
|
363
|
+
return { text: segment.text, named: bare };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
/** The payload map of a record, or an empty map. */
|
|
369
|
+
function payloadOf(record) {
|
|
370
|
+
const value = record.payload;
|
|
371
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
372
|
+
? value
|
|
373
|
+
: {};
|
|
374
|
+
}
|
|
375
|
+
/** Is this changed path the daemon's own evidence surface? */
|
|
376
|
+
export function isExemptPath(path) {
|
|
377
|
+
const normal = segmentsOf(path).join("/");
|
|
378
|
+
return EXEMPT_PREFIXES.some((prefix) => prefix.endsWith("/") ? normal.startsWith(prefix) : normal === prefix);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Is this changed path one whose edit requires a human decision?
|
|
382
|
+
*
|
|
383
|
+
* The guarded set is exactly the hook's ({@link isProtectedPath}, built-ins
|
|
384
|
+
* plus `policy.protected_paths`) minus the evidence surface. Sharing the
|
|
385
|
+
* predicate is the point: a CI guard whose idea of "protected" drifted from the
|
|
386
|
+
* hook's would fail the changes the hook already gated and pass the ones it
|
|
387
|
+
* would have caught.
|
|
388
|
+
*/
|
|
389
|
+
export function isGuardedPath(path, policyProtectedPaths) {
|
|
390
|
+
if (isExemptPath(path))
|
|
391
|
+
return false;
|
|
392
|
+
return isProtectedPath(path, policyProtectedPaths);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Does this granted command write THIS repository's copy of the path?
|
|
396
|
+
*
|
|
397
|
+
* The naming test is a path suffix, which cannot tell two checkouts apart and
|
|
398
|
+
* says so. Coverage cannot afford that, and the real log shows why: three of
|
|
399
|
+
* the grants that would have carried PR #187's SPEC.md change were DRY RUNS
|
|
400
|
+
* that wrote `$SCRATCH/dry/SPEC.md` in a temporary directory. Every one of them
|
|
401
|
+
* classifies `policy.edit` on a path ending in `SPEC.md`, and not one of them
|
|
402
|
+
* touched the file the pull request changed.
|
|
403
|
+
*
|
|
404
|
+
* So attribution anchors the write to the checkout the command ran in: the
|
|
405
|
+
* payload's `cwd` joined with the repository-relative path has to be exactly
|
|
406
|
+
* the path the segment names. A grant that wrote a copy somewhere else covers
|
|
407
|
+
* nothing here, and a command run from a subdirectory of the checkout is not
|
|
408
|
+
* anchored either, which is the fail-closed direction and costs one approval.
|
|
409
|
+
*/
|
|
410
|
+
function commandTargetsPath(target, cwd, path) {
|
|
411
|
+
if (cwd === undefined || cwd.length === 0)
|
|
412
|
+
return false;
|
|
413
|
+
const absolute = target.startsWith("/") || target.startsWith("\\");
|
|
414
|
+
const named = absolute ? segmentsOf(target) : [...segmentsOf(cwd), ...segmentsOf(target)];
|
|
415
|
+
const resolved = [];
|
|
416
|
+
for (const segment of named) {
|
|
417
|
+
if (segment === "..")
|
|
418
|
+
resolved.pop();
|
|
419
|
+
else
|
|
420
|
+
resolved.push(segment);
|
|
421
|
+
}
|
|
422
|
+
const want = [...segmentsOf(cwd), ...segmentsOf(path)];
|
|
423
|
+
return resolved.length === want.length && resolved.every((part, index) => part === want[index]);
|
|
424
|
+
}
|
|
425
|
+
/** The path a granted payload names, with the bytes it bound to it. */
|
|
426
|
+
function evidenceFor(material, path, policyProtectedPaths) {
|
|
427
|
+
if (typeof material !== "object" || material === null || Array.isArray(material))
|
|
428
|
+
return null;
|
|
429
|
+
const map = material;
|
|
430
|
+
// Codex apply_patch is tagged explicitly. Parse it before the generic
|
|
431
|
+
// command fallback so patch text is never mistaken for a shell command and
|
|
432
|
+
// never receives time-based command attribution. Until a patch-to-blob
|
|
433
|
+
// proof exists, this is deliberately naming-only evidence and covers no
|
|
434
|
+
// bytes; it remains useful diagnosis without weakening the guard.
|
|
435
|
+
if (map["tool"] === "apply_patch" && typeof map["command"] === "string") {
|
|
436
|
+
const parsed = parseApplyPatch(map["command"]);
|
|
437
|
+
if (parsed.ok) {
|
|
438
|
+
const operation = parsed.operations.find((candidate) => endsWithSegments(candidate.path, path) ||
|
|
439
|
+
(candidate.kind === "update" &&
|
|
440
|
+
candidate.moveTo !== undefined &&
|
|
441
|
+
endsWithSegments(candidate.moveTo, path)));
|
|
442
|
+
if (operation !== undefined) {
|
|
443
|
+
return {
|
|
444
|
+
kind: "granted-file",
|
|
445
|
+
detail: `the granted material is a Codex apply_patch operation naming ${path}; patch bytes are not yet guard coverage`,
|
|
446
|
+
after: null,
|
|
447
|
+
before: null,
|
|
448
|
+
whole: false,
|
|
449
|
+
command: false,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
const file = map["file"];
|
|
456
|
+
if (typeof file === "string" && endsWithSegments(file, path)) {
|
|
457
|
+
const tool = typeof map["tool"] === "string" ? map["tool"] : "a file tool";
|
|
458
|
+
const before = map["before"];
|
|
459
|
+
const after = map["after"];
|
|
460
|
+
const content = map["content"];
|
|
461
|
+
if (typeof before === "string" && typeof after === "string") {
|
|
462
|
+
return {
|
|
463
|
+
kind: "granted-file",
|
|
464
|
+
detail: `the granted material is a ${tool} edit of ${file} binding ${linesOf(before).length} line(s) out and ${linesOf(after).length} line(s) in`,
|
|
465
|
+
after: [after],
|
|
466
|
+
before: [before],
|
|
467
|
+
whole: false,
|
|
468
|
+
command: false,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
if (typeof content === "string") {
|
|
472
|
+
return {
|
|
473
|
+
kind: "granted-file",
|
|
474
|
+
detail: `the granted material is a ${tool} of ${file} binding the whole file (${linesOf(content).length} line(s))`,
|
|
475
|
+
after: [content],
|
|
476
|
+
before: null,
|
|
477
|
+
whole: true,
|
|
478
|
+
command: false,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
// The `{input}` fallback shape (APRV-124): the tool call was recorded, the
|
|
482
|
+
// bytes were not. It names the path and binds nothing, so it is diagnosis.
|
|
483
|
+
return {
|
|
484
|
+
kind: "granted-file",
|
|
485
|
+
detail: `the granted material is a ${tool} call on ${file} recorded in the fallback \`input\` shape, which binds no before/after bytes`,
|
|
486
|
+
after: null,
|
|
487
|
+
before: null,
|
|
488
|
+
whole: false,
|
|
489
|
+
command: false,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
const cwd = typeof map["cwd"] === "string" ? map["cwd"] : undefined;
|
|
493
|
+
const asCommand = (found, shape) => ({
|
|
494
|
+
kind: "granted-command",
|
|
495
|
+
detail: `the granted ${shape} writes this path, in the segment ${JSON.stringify(oneLine(found.text))}`,
|
|
496
|
+
after: null,
|
|
497
|
+
before: null,
|
|
498
|
+
whole: false,
|
|
499
|
+
command: true,
|
|
500
|
+
target: found.named,
|
|
501
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
502
|
+
});
|
|
503
|
+
const command = map["command"];
|
|
504
|
+
if (typeof command === "string") {
|
|
505
|
+
const found = commandWritesPath(command, path, policyProtectedPaths, cwd);
|
|
506
|
+
if (found !== null)
|
|
507
|
+
return asCommand(found, "command");
|
|
508
|
+
}
|
|
509
|
+
const argv = map["argv"];
|
|
510
|
+
if (Array.isArray(argv)) {
|
|
511
|
+
const joined = argv.filter((word) => typeof word === "string").join(" ");
|
|
512
|
+
const found = commandWritesPath(joined, path, policyProtectedPaths, cwd);
|
|
513
|
+
if (found !== null)
|
|
514
|
+
return asCommand(found, "argv");
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
/** Every declaration of this action key that existed before the start. */
|
|
519
|
+
function precedingBindings(records, start) {
|
|
520
|
+
const actionKey = start.action_key;
|
|
521
|
+
if (actionKey === undefined)
|
|
522
|
+
return [];
|
|
523
|
+
const found = [];
|
|
524
|
+
for (const record of records) {
|
|
525
|
+
if (record.seq >= start.seq || record.event !== "task.registered")
|
|
526
|
+
continue;
|
|
527
|
+
const actions = payloadOf(record)["actions"];
|
|
528
|
+
if (!Array.isArray(actions))
|
|
529
|
+
continue;
|
|
530
|
+
for (const raw of actions) {
|
|
531
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
532
|
+
continue;
|
|
533
|
+
const action = raw;
|
|
534
|
+
if (action["idempotency_key"] !== actionKey)
|
|
535
|
+
continue;
|
|
536
|
+
const cls = action["class"];
|
|
537
|
+
const hash = action["payload_hash"];
|
|
538
|
+
// Count malformed declarations too. A malformed entry sharing the key is
|
|
539
|
+
// ambiguity, never something a later well-formed entry can hide.
|
|
540
|
+
found.push({
|
|
541
|
+
record,
|
|
542
|
+
task: record.task ?? null,
|
|
543
|
+
actionKey,
|
|
544
|
+
cls: typeof cls === "string" ? cls : null,
|
|
545
|
+
payloadHash: typeof hash === "string" ? hash : null,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return found;
|
|
550
|
+
}
|
|
551
|
+
/** Exact file bytes, excluding fallback, command and apply_patch material. */
|
|
552
|
+
function policyFileEvidence(material, path, policyProtectedPaths) {
|
|
553
|
+
if (typeof material !== "object" || material === null || Array.isArray(material))
|
|
554
|
+
return null;
|
|
555
|
+
const map = material;
|
|
556
|
+
const tool = map["tool"];
|
|
557
|
+
const file = map["file"];
|
|
558
|
+
if (typeof file !== "string" ||
|
|
559
|
+
file.startsWith("/") ||
|
|
560
|
+
file.includes("\\"))
|
|
561
|
+
return null;
|
|
562
|
+
const named = segmentsOf(file);
|
|
563
|
+
if (named.includes("..") || named.join("/") !== segmentsOf(path).join("/"))
|
|
564
|
+
return null;
|
|
565
|
+
const keys = Object.keys(map);
|
|
566
|
+
const hasOnly = (allowed) => keys.every((key) => allowed.includes(key));
|
|
567
|
+
if (tool === "Edit") {
|
|
568
|
+
if (!hasOnly(["tool", "rule", "file", "before", "after", "replace_all"]) ||
|
|
569
|
+
(map["rule"] !== undefined && typeof map["rule"] !== "string") ||
|
|
570
|
+
typeof map["before"] !== "string" ||
|
|
571
|
+
typeof map["after"] !== "string" ||
|
|
572
|
+
(map["replace_all"] !== undefined && typeof map["replace_all"] !== "boolean"))
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
else if (tool === "Write") {
|
|
576
|
+
if (!hasOnly(["tool", "rule", "file", "content"]) ||
|
|
577
|
+
(map["rule"] !== undefined && typeof map["rule"] !== "string") ||
|
|
578
|
+
typeof map["content"] !== "string")
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
const match = evidenceFor(material, path, policyProtectedPaths);
|
|
585
|
+
if (match === null || match.command || (match.after === null && match.before === null))
|
|
586
|
+
return null;
|
|
587
|
+
return {
|
|
588
|
+
...match,
|
|
589
|
+
kind: "policy-authorized-file",
|
|
590
|
+
detail: match.detail.replace(/^the granted material is/u, "the policy-authorized material is"),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
/** Differing applicable routes in an unlabeled policy union carry no single class. */
|
|
594
|
+
function hasAmbiguousRoute(path, policyProtectedPaths) {
|
|
595
|
+
const candidate = segmentsOf(path);
|
|
596
|
+
const classes = new Set();
|
|
597
|
+
for (const entry of policyProtectedPaths) {
|
|
598
|
+
const raw = typeof entry === "string" ? entry : entry.path;
|
|
599
|
+
const trimmed = raw.trim();
|
|
600
|
+
if (trimmed.length === 0 || trimmed.startsWith("/"))
|
|
601
|
+
continue;
|
|
602
|
+
const wanted = segmentsOf(trimmed);
|
|
603
|
+
if (wanted.includes("..") || wanted.length === 0 || wanted.length > candidate.length)
|
|
604
|
+
continue;
|
|
605
|
+
const directory = /[/\\]$/u.test(trimmed);
|
|
606
|
+
const matches = directory
|
|
607
|
+
? candidate.some((_, start) => start + wanted.length <= candidate.length &&
|
|
608
|
+
wanted.every((segment, offset) => candidate[start + offset] === segment))
|
|
609
|
+
: wanted.every((segment, offset) => candidate[candidate.length - wanted.length + offset] === segment);
|
|
610
|
+
if (matches)
|
|
611
|
+
classes.add(typeof entry === "string" ? "policy.edit" : entry.class);
|
|
612
|
+
}
|
|
613
|
+
return classes.size > 1;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Exact-file evidence from an unattended execution authorized by policy.
|
|
617
|
+
*
|
|
618
|
+
* The start must reproduce the one declaration that preceded it, and the
|
|
619
|
+
* committed payload must hash back to that declaration. A prior request means
|
|
620
|
+
* the action entered the human-decision path, so this tier cannot carry it.
|
|
621
|
+
*/
|
|
622
|
+
function policyAuthorizedEvidence(records, start, path, policyProtectedPaths, payloadFor, anchorMs, lookbackMs) {
|
|
623
|
+
if (start.event !== "execution.started")
|
|
624
|
+
return null;
|
|
625
|
+
const task = start.task;
|
|
626
|
+
const actionKey = start.action_key;
|
|
627
|
+
if (task === undefined || actionKey === undefined)
|
|
628
|
+
return null;
|
|
629
|
+
const started = payloadOf(start);
|
|
630
|
+
const harnessStart = started["execution"] === "harness";
|
|
631
|
+
const coreStart = !Object.hasOwn(started, "execution");
|
|
632
|
+
if ((!harnessStart && !coreStart) ||
|
|
633
|
+
Object.hasOwn(started, "grant_seq") ||
|
|
634
|
+
Object.hasOwn(started, "token_sha256"))
|
|
635
|
+
return null;
|
|
636
|
+
const cls = started["class"];
|
|
637
|
+
const hash = started["payload_hash"];
|
|
638
|
+
if (typeof cls !== "string" || typeof hash !== "string")
|
|
639
|
+
return null;
|
|
640
|
+
// The exact routed class is authority. Merely living in policy.edit.* is not.
|
|
641
|
+
if (hasAmbiguousRoute(path, policyProtectedPaths))
|
|
642
|
+
return null;
|
|
643
|
+
const routed = protectedPathClass(path, policyProtectedPaths);
|
|
644
|
+
if (routed === null || routed === "policy.core" || routed === "log.mutate" || cls !== routed) {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
const bindings = precedingBindings(records, start);
|
|
648
|
+
if (bindings.length !== 1)
|
|
649
|
+
return null;
|
|
650
|
+
const binding = bindings[0];
|
|
651
|
+
if (binding.task !== task ||
|
|
652
|
+
binding.actionKey !== actionKey ||
|
|
653
|
+
binding.cls !== cls ||
|
|
654
|
+
binding.payloadHash !== hash)
|
|
655
|
+
return null;
|
|
656
|
+
if (records.some((record) => record.seq < start.seq &&
|
|
657
|
+
record.action_key === actionKey &&
|
|
658
|
+
record.event === "approval.requested"))
|
|
659
|
+
return null;
|
|
660
|
+
// A start cannot authorize a change that already happened. This tier also
|
|
661
|
+
// requires a usable commit timestamp; missing temporal evidence fails closed.
|
|
662
|
+
if (anchorMs === null)
|
|
663
|
+
return null;
|
|
664
|
+
const at = Date.parse(start.ts);
|
|
665
|
+
if (Number.isNaN(at) || at > anchorMs || anchorMs - at > lookbackMs)
|
|
666
|
+
return null;
|
|
667
|
+
const material = payloadFor(hash);
|
|
668
|
+
if (material === null)
|
|
669
|
+
return null;
|
|
670
|
+
try {
|
|
671
|
+
if (payloadHash(material) !== hash)
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
return policyFileEvidence(material, path, policyProtectedPaths);
|
|
678
|
+
}
|
|
679
|
+
/** The closed, repository-relative Edit shape eligible for byte replay. */
|
|
680
|
+
function exactReplayEdit(material, path) {
|
|
681
|
+
if (typeof material !== "object" || material === null || Array.isArray(material))
|
|
682
|
+
return null;
|
|
683
|
+
const map = material;
|
|
684
|
+
const keys = Object.keys(map);
|
|
685
|
+
const allowed = ["tool", "rule", "file", "before", "after", "replace_all"];
|
|
686
|
+
if (keys.some((key) => !allowed.includes(key)))
|
|
687
|
+
return null;
|
|
688
|
+
if (map["tool"] !== "Edit" ||
|
|
689
|
+
typeof map["file"] !== "string" ||
|
|
690
|
+
map["file"].startsWith("/") ||
|
|
691
|
+
map["file"].includes("\\") ||
|
|
692
|
+
(map["rule"] !== undefined && typeof map["rule"] !== "string") ||
|
|
693
|
+
typeof map["before"] !== "string" ||
|
|
694
|
+
map["before"].length === 0 ||
|
|
695
|
+
typeof map["after"] !== "string" ||
|
|
696
|
+
map["before"] === map["after"] ||
|
|
697
|
+
(map["replace_all"] !== undefined && map["replace_all"] !== false))
|
|
698
|
+
return null;
|
|
699
|
+
const named = segmentsOf(map["file"]);
|
|
700
|
+
const changed = segmentsOf(path);
|
|
701
|
+
if (named.includes("..") ||
|
|
702
|
+
named.length !== changed.length ||
|
|
703
|
+
!named.every((segment, index) => segment === changed[index]))
|
|
704
|
+
return null;
|
|
705
|
+
return { before: map["before"], after: map["after"] };
|
|
706
|
+
}
|
|
707
|
+
/** The one token or harness spend that proves this manual grant actually ran. */
|
|
708
|
+
function startForReplayGrant(candidate, records, anchorMs, lookbackMs, path, policyProtectedPaths) {
|
|
709
|
+
if (anchorMs === null)
|
|
710
|
+
return null;
|
|
711
|
+
const grant = candidate.record;
|
|
712
|
+
const task = grant.task;
|
|
713
|
+
const actionKey = grant.action_key;
|
|
714
|
+
if (task === undefined || actionKey === undefined)
|
|
715
|
+
return null;
|
|
716
|
+
const granted = payloadOf(grant);
|
|
717
|
+
const cls = granted["class"];
|
|
718
|
+
const hash = granted["payload_hash"];
|
|
719
|
+
if (typeof cls !== "string" || typeof hash !== "string" || hash !== candidate.payloadHash)
|
|
720
|
+
return null;
|
|
721
|
+
if (hasAmbiguousRoute(path, policyProtectedPaths))
|
|
722
|
+
return null;
|
|
723
|
+
const routed = protectedPathClass(path, policyProtectedPaths);
|
|
724
|
+
if (routed === null || routed === "policy.core" || routed === "log.mutate" || cls !== routed) {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
try {
|
|
728
|
+
if (payloadHash(candidate.material) !== hash)
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
const grantToken = granted["token_sha256"];
|
|
735
|
+
const starts = records.filter((record) => {
|
|
736
|
+
if (record.event !== "execution.started" ||
|
|
737
|
+
record.seq <= grant.seq ||
|
|
738
|
+
record.task !== task ||
|
|
739
|
+
record.action_key !== actionKey)
|
|
740
|
+
return false;
|
|
741
|
+
const started = payloadOf(record);
|
|
742
|
+
if (started["class"] !== cls || started["payload_hash"] !== hash)
|
|
743
|
+
return false;
|
|
744
|
+
if (Object.hasOwn(started, "execution") && started["execution"] !== "harness")
|
|
745
|
+
return false;
|
|
746
|
+
const hasGrantLink = Object.hasOwn(started, "grant_seq");
|
|
747
|
+
const hasTokenLink = Object.hasOwn(started, "token_sha256");
|
|
748
|
+
if (!hasGrantLink && !hasTokenLink)
|
|
749
|
+
return false;
|
|
750
|
+
if (hasGrantLink && started["grant_seq"] !== grant.seq)
|
|
751
|
+
return false;
|
|
752
|
+
if (hasTokenLink &&
|
|
753
|
+
(typeof grantToken !== "string" || started["token_sha256"] !== grantToken))
|
|
754
|
+
return false;
|
|
755
|
+
const at = Date.parse(record.ts);
|
|
756
|
+
return !Number.isNaN(at) && at <= anchorMs && anchorMs - at <= lookbackMs;
|
|
757
|
+
});
|
|
758
|
+
if (starts.length !== 1)
|
|
759
|
+
return null;
|
|
760
|
+
const start = starts[0];
|
|
761
|
+
const bindings = precedingBindings(records, start);
|
|
762
|
+
if (bindings.length !== 1)
|
|
763
|
+
return null;
|
|
764
|
+
const binding = bindings[0];
|
|
765
|
+
if (binding.record.seq >= grant.seq ||
|
|
766
|
+
binding.task !== task ||
|
|
767
|
+
binding.actionKey !== actionKey ||
|
|
768
|
+
binding.cls !== cls ||
|
|
769
|
+
binding.payloadHash !== hash)
|
|
770
|
+
return null;
|
|
771
|
+
const requested = records.some((record) => {
|
|
772
|
+
if (record.event !== "approval.requested" ||
|
|
773
|
+
record.seq <= binding.record.seq ||
|
|
774
|
+
record.seq >= grant.seq ||
|
|
775
|
+
record.task !== task ||
|
|
776
|
+
record.action_key !== actionKey)
|
|
777
|
+
return false;
|
|
778
|
+
const payload = payloadOf(record);
|
|
779
|
+
return payload["class"] === cls && payload["payload_hash"] === hash;
|
|
780
|
+
});
|
|
781
|
+
return requested ? start : null;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Replay authorized exact edits in execution order and demand byte equality.
|
|
785
|
+
*
|
|
786
|
+
* The log may contain older edits whose anchors survive in BASE but which did
|
|
787
|
+
* not produce this change. Search deterministic ordered subsequences: skip a
|
|
788
|
+
* candidate first, and apply it only when its anchor occurs exactly once in the
|
|
789
|
+
* current state. An ambiguous candidate cannot be applied, but it does not make
|
|
790
|
+
* an independent later proof ambiguous. Only a non-empty sequence that turns
|
|
791
|
+
* BASE into HEAD byte for byte is evidence; there is no reordering, substring
|
|
792
|
+
* credit, or partial result.
|
|
793
|
+
*
|
|
794
|
+
* Search is bounded by candidate count, distinct states, and cumulative bytes
|
|
795
|
+
* examined. Crossing any bound refuses the fallback rather than pruning a
|
|
796
|
+
* potentially valid branch and claiming the remaining search was complete.
|
|
797
|
+
*/
|
|
798
|
+
function exactEditReplay(candidates, records, base, head, anchorMs, lookbackMs, path, policyProtectedPaths) {
|
|
799
|
+
if (base === null ||
|
|
800
|
+
head === null ||
|
|
801
|
+
base.includes("\uFFFD") ||
|
|
802
|
+
head.includes("\uFFFD"))
|
|
803
|
+
return { steps: null, bound: null };
|
|
804
|
+
const gathered = [];
|
|
805
|
+
for (const candidate of candidates) {
|
|
806
|
+
try {
|
|
807
|
+
if (payloadHash(candidate.material) !== candidate.payloadHash)
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
const edit = exactReplayEdit(candidate.material, path);
|
|
814
|
+
if (edit === null)
|
|
815
|
+
continue;
|
|
816
|
+
const start = candidate.source === "policy"
|
|
817
|
+
? candidate.record
|
|
818
|
+
: startForReplayGrant(candidate, records, anchorMs, lookbackMs, path, policyProtectedPaths);
|
|
819
|
+
if (start !== null)
|
|
820
|
+
gathered.push({ candidate, start, ...edit });
|
|
821
|
+
}
|
|
822
|
+
gathered.sort((left, right) => left.start.seq - right.start.seq);
|
|
823
|
+
// A verified execution record can authorize at most one application. Collapse
|
|
824
|
+
// duplicate evidence views of the same binding. If two views give one start
|
|
825
|
+
// conflicting edit bytes, neither representation of that start is usable.
|
|
826
|
+
const byStart = new Map();
|
|
827
|
+
const conflictingStarts = new Set();
|
|
828
|
+
for (const step of gathered) {
|
|
829
|
+
const prior = byStart.get(step.start.seq);
|
|
830
|
+
if (prior === undefined) {
|
|
831
|
+
byStart.set(step.start.seq, step);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
if (prior.before !== step.before || prior.after !== step.after) {
|
|
835
|
+
conflictingStarts.add(step.start.seq);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const steps = [...byStart.values()].filter((step) => !conflictingStarts.has(step.start.seq));
|
|
839
|
+
if (steps.length > EXACT_REPLAY_MAX_CANDIDATES) {
|
|
840
|
+
return { steps: null, bound: "candidate-limit" };
|
|
841
|
+
}
|
|
842
|
+
const headBytes = Buffer.byteLength(head, "utf8");
|
|
843
|
+
let examinedBytes = Buffer.byteLength(base, "utf8") + headBytes;
|
|
844
|
+
if (examinedBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
|
|
845
|
+
return { steps: null, bound: "byte-limit" };
|
|
846
|
+
}
|
|
847
|
+
let visitedStates = 0;
|
|
848
|
+
let exceeded = null;
|
|
849
|
+
// State equality is exact string equality, not a digest or lossy cache key.
|
|
850
|
+
// Empty/non-empty histories stay distinct because an empty replay is never
|
|
851
|
+
// evidence even when BASE already equals HEAD.
|
|
852
|
+
const visited = Array.from({ length: steps.length + 1 }, () => [new Set(), new Set()]);
|
|
853
|
+
const search = (index, replayed, applied) => {
|
|
854
|
+
if (exceeded !== null)
|
|
855
|
+
return null;
|
|
856
|
+
const replayedBytes = Buffer.byteLength(replayed, "utf8");
|
|
857
|
+
// Charge exact-state hashing/equality and the HEAD comparison before either
|
|
858
|
+
// can retain or accept this state.
|
|
859
|
+
const stateBytes = replayedBytes + headBytes;
|
|
860
|
+
if (examinedBytes + stateBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
|
|
861
|
+
exceeded = "byte-limit";
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
examinedBytes += stateBytes;
|
|
865
|
+
const seen = visited[index]?.[applied.length === 0 ? 0 : 1];
|
|
866
|
+
if (seen === undefined || seen.has(replayed))
|
|
867
|
+
return null;
|
|
868
|
+
seen.add(replayed);
|
|
869
|
+
visitedStates += 1;
|
|
870
|
+
if (visitedStates > EXACT_REPLAY_MAX_STATES) {
|
|
871
|
+
exceeded = "state-limit";
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
if (applied.length > 0 && replayed === head)
|
|
875
|
+
return [...applied];
|
|
876
|
+
if (index === steps.length)
|
|
877
|
+
return null;
|
|
878
|
+
// Deterministic skip-first DFS finds a late witness quickly and
|
|
879
|
+
// prevents irrelevant historical edits from polluting a valid proof.
|
|
880
|
+
const skipped = search(index + 1, replayed, applied);
|
|
881
|
+
if (skipped !== null || exceeded !== null)
|
|
882
|
+
return skipped;
|
|
883
|
+
const step = steps[index];
|
|
884
|
+
const scanBytes = replayedBytes * 2 +
|
|
885
|
+
Buffer.byteLength(step.before, "utf8");
|
|
886
|
+
if (examinedBytes + scanBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
|
|
887
|
+
exceeded = "byte-limit";
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
examinedBytes += scanBytes;
|
|
891
|
+
const first = replayed.indexOf(step.before);
|
|
892
|
+
if (first === -1 || replayed.indexOf(step.before, first + 1) !== -1)
|
|
893
|
+
return null;
|
|
894
|
+
// Bound the next allocation before constructing it. `replayed + after` is
|
|
895
|
+
// a conservative upper bound because the replacement removes `before`.
|
|
896
|
+
const prospectiveBytes = replayedBytes + Buffer.byteLength(step.after, "utf8");
|
|
897
|
+
if (examinedBytes + prospectiveBytes > EXACT_REPLAY_MAX_EXAMINED_BYTES) {
|
|
898
|
+
exceeded = "byte-limit";
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
examinedBytes += prospectiveBytes;
|
|
902
|
+
const next = `${replayed.slice(0, first)}${step.after}${replayed.slice(first + step.before.length)}`;
|
|
903
|
+
return search(index + 1, next, [...applied, step]);
|
|
904
|
+
};
|
|
905
|
+
const proof = search(0, base, []);
|
|
906
|
+
return { steps: proof, bound: exceeded };
|
|
907
|
+
}
|
|
908
|
+
/** A blob's lines, with the empty tail a trailing newline leaves dropped. */
|
|
909
|
+
function linesOf(text) {
|
|
910
|
+
const parts = text.split("\n");
|
|
911
|
+
if (parts.length > 0 && parts[parts.length - 1] === "")
|
|
912
|
+
parts.pop();
|
|
913
|
+
return parts;
|
|
914
|
+
}
|
|
915
|
+
/** A line that carries content. Blank lines neither need coverage nor give it. */
|
|
916
|
+
function substantive(line) {
|
|
917
|
+
return line.trim().length > 0;
|
|
918
|
+
}
|
|
919
|
+
/** Multiset difference, `left` minus `right`, keeping multiplicity and order. */
|
|
920
|
+
function linesMinus(left, right) {
|
|
921
|
+
const budget = new Map();
|
|
922
|
+
for (const line of right)
|
|
923
|
+
budget.set(line, (budget.get(line) ?? 0) + 1);
|
|
924
|
+
const out = [];
|
|
925
|
+
for (const line of left) {
|
|
926
|
+
const remaining = budget.get(line) ?? 0;
|
|
927
|
+
if (remaining > 0) {
|
|
928
|
+
budget.set(line, remaining - 1);
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
out.push(line);
|
|
932
|
+
}
|
|
933
|
+
return out;
|
|
934
|
+
}
|
|
935
|
+
/** Reduce a change to the substantive lines it adds and removes. */
|
|
936
|
+
function hunksOf(blobs) {
|
|
937
|
+
const baseText = blobs.base ?? "";
|
|
938
|
+
const headText = blobs.head ?? "";
|
|
939
|
+
const quiet = { added: [], removed: [], reordered: false, identical: true };
|
|
940
|
+
if (baseText === headText)
|
|
941
|
+
return quiet;
|
|
942
|
+
const baseLines = linesOf(baseText).filter(substantive);
|
|
943
|
+
const headLines = linesOf(headText).filter(substantive);
|
|
944
|
+
// Same substantive lines in the same order: the bytes moved, the content did
|
|
945
|
+
// not. A re-indent or a blank line is not a change a human needs to have seen.
|
|
946
|
+
if (baseLines.length === headLines.length &&
|
|
947
|
+
baseLines.every((line, index) => line === headLines[index])) {
|
|
948
|
+
return quiet;
|
|
949
|
+
}
|
|
950
|
+
const added = linesMinus(headLines, baseLines);
|
|
951
|
+
const removed = linesMinus(baseLines, headLines);
|
|
952
|
+
return {
|
|
953
|
+
added,
|
|
954
|
+
removed,
|
|
955
|
+
// Same lines, different order. Reported as an uncovered hunk rather than as
|
|
956
|
+
// no change, so a rewrite that only moves paragraphs cannot pass on a diff
|
|
957
|
+
// that looks empty to a multiset.
|
|
958
|
+
reordered: added.length === 0 && removed.length === 0,
|
|
959
|
+
identical: false,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
/** The substantive lines of every granted chunk, as a lookup set. */
|
|
963
|
+
function materialLines(chunks) {
|
|
964
|
+
const set = new Set();
|
|
965
|
+
for (const chunk of chunks ?? []) {
|
|
966
|
+
for (const line of linesOf(chunk)) {
|
|
967
|
+
if (substantive(line))
|
|
968
|
+
set.add(line);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return set;
|
|
972
|
+
}
|
|
973
|
+
/** One line of a possibly multi-line command segment, for a message. */
|
|
974
|
+
function oneLine(text) {
|
|
975
|
+
const flat = text.replace(/\s+/gu, " ").trim();
|
|
976
|
+
return flat.length > 160 ? `${flat.slice(0, 157)}…` : flat;
|
|
977
|
+
}
|
|
978
|
+
/** The window, rendered for a failure message. */
|
|
979
|
+
function windowText(window) {
|
|
980
|
+
if (window.firstSeq === null || window.lastSeq === null) {
|
|
981
|
+
return `the log at ${window.head} is empty`;
|
|
982
|
+
}
|
|
983
|
+
return `the log at ${window.head} covers seq ${window.firstSeq}..${window.lastSeq} (${window.firstTs ?? "?"} .. ${window.lastTs ?? "?"})`;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* How far past the change commit a run may still start and be its cause: five
|
|
987
|
+
* minutes, for clock disagreement between the log and git's author date. It is
|
|
988
|
+
* a skew allowance, not an ordering allowance.
|
|
989
|
+
*/
|
|
990
|
+
const SKEW_GRACE_MS = 5 * 60 * 1000;
|
|
991
|
+
/** Milliseconds as something a failure message can say out loud. */
|
|
992
|
+
function spanText(ms) {
|
|
993
|
+
if (ms < 90 * 60 * 1000)
|
|
994
|
+
return `${Math.round(ms / 60_000)}m`;
|
|
995
|
+
if (ms < 48 * 60 * 60 * 1000)
|
|
996
|
+
return `${Math.round(ms / 3_600_000)}h`;
|
|
997
|
+
return `${Math.round(ms / 86_400_000)}d`;
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Tie a granted command to the RUN that produced this change, or say why not.
|
|
1001
|
+
*
|
|
1002
|
+
* A command payload names a path and describes no bytes, so this is the only
|
|
1003
|
+
* link there is. Two things have to hold: the grant was SPENT (the log carries
|
|
1004
|
+
* an `execution.started` for its `action_key`, which is what the hook's
|
|
1005
|
+
* `consumeHarnessGrant` and `approval run` append), and that run sits within
|
|
1006
|
+
* the attribution window of the commit. A grant nobody spent authorized a
|
|
1007
|
+
* command that never ran, and a run a week away from the commit did not write
|
|
1008
|
+
* it. `execution.completed`, when the log has one, widens the run to the
|
|
1009
|
+
* interval it actually occupied: a batch that started before the commit and
|
|
1010
|
+
* finished after it brackets the commit, which is the strongest form of this.
|
|
1011
|
+
*/
|
|
1012
|
+
function attributeRun(grant, runs, anchorMs, attributionMs) {
|
|
1013
|
+
const key = grant.action_key;
|
|
1014
|
+
if (key === undefined) {
|
|
1015
|
+
return {
|
|
1016
|
+
ok: false,
|
|
1017
|
+
why: `the grant at seq ${grant.seq} carries no action_key, so no execution record can be tied to it`,
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
const tied = runs.get(key) ?? [];
|
|
1021
|
+
const started = tied.filter((record) => record.event === "execution.started");
|
|
1022
|
+
if (started.length === 0) {
|
|
1023
|
+
return {
|
|
1024
|
+
ok: false,
|
|
1025
|
+
why: `the grant at seq ${grant.seq} was never spent: the committed log carries no execution.started for ${key}, so nothing says the granted command ever ran`,
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
const completed = tied.filter((record) => record.event === "execution.completed");
|
|
1029
|
+
const endOf = (record) => completed.find((done) => done.seq > record.seq);
|
|
1030
|
+
if (anchorMs === null) {
|
|
1031
|
+
const first = started[0];
|
|
1032
|
+
return {
|
|
1033
|
+
ok: true,
|
|
1034
|
+
detail: `spent at seq ${first.seq} (execution.started ${first.ts}), and with no usable commit timestamp NO attribution bound was applied to that run`,
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
// A run that STARTED after the commit did not produce it. Unlike a grant,
|
|
1038
|
+
// whose relation to a write can be either order (APRV-200's grant-follows-
|
|
1039
|
+
// write), a command's effect strictly follows its own `execution.started`:
|
|
1040
|
+
// the record is appended before the process is spawned. The real log shows
|
|
1041
|
+
// what the symmetric window costs — a SPEC.md batch run four hours AFTER
|
|
1042
|
+
// PR #187's commit would otherwise have carried that commit's changes.
|
|
1043
|
+
// `SKEW_GRACE_MS` is for the two clocks (the log's and git's author date)
|
|
1044
|
+
// disagreeing, not for ordering.
|
|
1045
|
+
let best = null;
|
|
1046
|
+
let laterOnly = null;
|
|
1047
|
+
for (const record of started) {
|
|
1048
|
+
const from = Date.parse(record.ts);
|
|
1049
|
+
if (Number.isNaN(from))
|
|
1050
|
+
continue;
|
|
1051
|
+
if (from > anchorMs + SKEW_GRACE_MS) {
|
|
1052
|
+
if (laterOnly === null)
|
|
1053
|
+
laterOnly = record;
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
const end = endOf(record);
|
|
1057
|
+
const to = end === undefined ? from : Date.parse(end.ts);
|
|
1058
|
+
const upper = Number.isNaN(to) ? from : to;
|
|
1059
|
+
const distance = anchorMs >= Math.min(from, upper) && anchorMs <= Math.max(from, upper)
|
|
1060
|
+
? 0
|
|
1061
|
+
: Math.min(Math.abs(from - anchorMs), Math.abs(upper - anchorMs));
|
|
1062
|
+
if (best === null || distance < best.distance)
|
|
1063
|
+
best = { record, end, distance };
|
|
1064
|
+
}
|
|
1065
|
+
if (best === null && laterOnly !== null) {
|
|
1066
|
+
return {
|
|
1067
|
+
ok: false,
|
|
1068
|
+
why: `the granted command's only run started at ${laterOnly.ts}, AFTER the commit that changed this path; a command's effect follows its own execution.started, so that run did not write this change`,
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
if (best === null) {
|
|
1072
|
+
return {
|
|
1073
|
+
ok: false,
|
|
1074
|
+
why: `the execution records for ${key} carry no parseable timestamp, so the run cannot be placed against the commit`,
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
const span = best.end === undefined ? "" : `..${best.end.ts} (execution.completed)`;
|
|
1078
|
+
if (best.distance > attributionMs) {
|
|
1079
|
+
return {
|
|
1080
|
+
ok: false,
|
|
1081
|
+
why: `the granted command ran at ${best.record.ts}${span}, which is ${spanText(best.distance)} from the commit that changed this path and outside the ${spanText(attributionMs)} attribution window; that run produced some earlier change, not this one`,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
return {
|
|
1085
|
+
ok: true,
|
|
1086
|
+
detail: best.distance === 0 && best.end !== undefined
|
|
1087
|
+
? `the granted run brackets the commit (execution.started ${best.record.ts}${span})`
|
|
1088
|
+
: `the granted run sits ${spanText(best.distance)} from the commit (execution.started ${best.record.ts}${span}), within the ${spanText(attributionMs)} attribution window`,
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* The organ attestation index's key: the normalized path AND the digest.
|
|
1093
|
+
*
|
|
1094
|
+
* One key rather than a map of maps. The digest is a fixed 64 hex characters
|
|
1095
|
+
* and it comes last, so the split point is unambiguous however the path is
|
|
1096
|
+
* spelled: two different (path, digest) pairs cannot collide into one key.
|
|
1097
|
+
*/
|
|
1098
|
+
function organKey(organPath, sha256) {
|
|
1099
|
+
return `${normalizePathSpelling(organPath)}\0${sha256}`;
|
|
1100
|
+
}
|
|
1101
|
+
/** The ordering rule, stated identically on every failure that could be lag. */
|
|
1102
|
+
const ORDERING_RULE = "the committed log trails the primary checkout's live log, so if this edit WAS granted, " +
|
|
1103
|
+
"the log advance carrying the grant must be pushed to a records branch or merged to main " +
|
|
1104
|
+
"before or with this pull request";
|
|
1105
|
+
/**
|
|
1106
|
+
* Evaluate a candidate against the committed log. Pure: no IO, no clock.
|
|
1107
|
+
*
|
|
1108
|
+
* @see GuardInput for what the caller has to gather.
|
|
1109
|
+
*/
|
|
1110
|
+
export function evaluateProtectedPaths(input) {
|
|
1111
|
+
const exempt = [];
|
|
1112
|
+
const guarded = [];
|
|
1113
|
+
for (const path of input.changedPaths) {
|
|
1114
|
+
if (isExemptPath(path)) {
|
|
1115
|
+
if (isProtectedPath(path, input.policyProtectedPaths))
|
|
1116
|
+
exempt.push(path);
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
if (isProtectedPath(path, input.policyProtectedPaths))
|
|
1120
|
+
guarded.push(path);
|
|
1121
|
+
}
|
|
1122
|
+
if (guarded.length === 0) {
|
|
1123
|
+
return { ok: true, findings: [], exempt, window: input.window };
|
|
1124
|
+
}
|
|
1125
|
+
// Fail closed before any evidence is sought: an unreadable or unverified log
|
|
1126
|
+
// is not "no protected paths changed", it is "this guard cannot see".
|
|
1127
|
+
if (input.logStatus !== "ok" || input.records === null) {
|
|
1128
|
+
const code = input.logStatus === "missing" ? "log-missing" : "log-unverified";
|
|
1129
|
+
const why = code === "log-missing"
|
|
1130
|
+
? `the committed log is absent from the tree at ${input.window.head}`
|
|
1131
|
+
: `the committed log at ${input.window.head} does not pass chain verification (${input.logDetail ?? "no detail"})`;
|
|
1132
|
+
return {
|
|
1133
|
+
ok: false,
|
|
1134
|
+
findings: guarded.map((path) => ({
|
|
1135
|
+
path,
|
|
1136
|
+
ok: false,
|
|
1137
|
+
code,
|
|
1138
|
+
detail: `${path} is a protected path and ${why}, so no evidence can be read for it. Nothing in this guard reads unverified records.`,
|
|
1139
|
+
})),
|
|
1140
|
+
exempt,
|
|
1141
|
+
window: input.window,
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
const records = input.records;
|
|
1145
|
+
// The attestation index: every policy digest a human has signed off.
|
|
1146
|
+
const attestations = new Map();
|
|
1147
|
+
for (const record of records) {
|
|
1148
|
+
if (record.event !== "policy.updated")
|
|
1149
|
+
continue;
|
|
1150
|
+
const sha = payloadOf(record)["sha256"];
|
|
1151
|
+
if (typeof sha === "string")
|
|
1152
|
+
attestations.set(sha, record);
|
|
1153
|
+
}
|
|
1154
|
+
// The ORGAN attestation index (APRV-272), keyed by PATH AND digest, because
|
|
1155
|
+
// a digest attested for one organ is not evidence for another. Kept separate
|
|
1156
|
+
// from the policy index above, and fed by a different event type, so that
|
|
1157
|
+
// neither can ever answer the other's question.
|
|
1158
|
+
const organAttestations = new Map();
|
|
1159
|
+
for (const record of records) {
|
|
1160
|
+
const fields = organAttestationOf(record);
|
|
1161
|
+
if (fields === null)
|
|
1162
|
+
continue;
|
|
1163
|
+
organAttestations.set(organKey(fields.organPath, fields.sha256), record);
|
|
1164
|
+
}
|
|
1165
|
+
// The grants of a class that can authorize a protected write.
|
|
1166
|
+
const grants = records.filter((record) => record.event === "approval.granted" &&
|
|
1167
|
+
isGrantingClass(String(payloadOf(record)["class"] ?? "")));
|
|
1168
|
+
// The runs, by action key: what a granted COMMAND is attributed through
|
|
1169
|
+
// (APRV-202). `execution.started` is appended by the hook when it spends a
|
|
1170
|
+
// harness grant and by `approval run` when it spends a token; the completion
|
|
1171
|
+
// beside it, where the log has one, widens the run to an interval.
|
|
1172
|
+
const runs = new Map();
|
|
1173
|
+
for (const record of records) {
|
|
1174
|
+
if (record.event !== "execution.started" && record.event !== "execution.completed")
|
|
1175
|
+
continue;
|
|
1176
|
+
const key = record.action_key;
|
|
1177
|
+
if (key === undefined)
|
|
1178
|
+
continue;
|
|
1179
|
+
const bucket = runs.get(key);
|
|
1180
|
+
if (bucket === undefined)
|
|
1181
|
+
runs.set(key, [record]);
|
|
1182
|
+
else
|
|
1183
|
+
bucket.push(record);
|
|
1184
|
+
}
|
|
1185
|
+
const attributionMs = input.commandAttributionMs ?? DEFAULT_COMMAND_ATTRIBUTION_MS;
|
|
1186
|
+
const findings = [];
|
|
1187
|
+
for (const path of guarded) {
|
|
1188
|
+
// 1. The policy file, by attestation.
|
|
1189
|
+
if (endsWithSegments(input.policyPath, path) ||
|
|
1190
|
+
endsWithSegments(path, input.policyPath)) {
|
|
1191
|
+
if (input.policySha256AtHead !== null) {
|
|
1192
|
+
const attested = attestations.get(input.policySha256AtHead);
|
|
1193
|
+
if (attested !== undefined) {
|
|
1194
|
+
findings.push({
|
|
1195
|
+
path,
|
|
1196
|
+
ok: true,
|
|
1197
|
+
evidence: "attested",
|
|
1198
|
+
seq: attested.seq,
|
|
1199
|
+
ts: attested.ts,
|
|
1200
|
+
actor: attested.actor,
|
|
1201
|
+
detail: `${path} at ${input.window.head} hashes to ${input.policySha256AtHead}, which ${attested.actor} attested at seq ${attested.seq}`,
|
|
1202
|
+
});
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
// Not attested: fall through to the grant search rather than failing
|
|
1207
|
+
// here, because an ordinary policy.edit grant on the policy file is also
|
|
1208
|
+
// valid evidence and a human amendment is not the only way it changes.
|
|
1209
|
+
}
|
|
1210
|
+
// 1b. A gate organ, by attestation of THAT path's bytes (APRV-272).
|
|
1211
|
+
if (isGateOrganPath(path)) {
|
|
1212
|
+
const digest = input.organSha256AtHead?.(path) ?? null;
|
|
1213
|
+
if (digest !== null) {
|
|
1214
|
+
const attested = organAttestations.get(organKey(path, digest));
|
|
1215
|
+
if (attested !== undefined) {
|
|
1216
|
+
findings.push({
|
|
1217
|
+
path,
|
|
1218
|
+
ok: true,
|
|
1219
|
+
evidence: "attested",
|
|
1220
|
+
seq: attested.seq,
|
|
1221
|
+
ts: attested.ts,
|
|
1222
|
+
actor: attested.actor,
|
|
1223
|
+
detail: `${path} at ${input.window.head} hashes to ${digest}, which ${attested.actor} attested FOR THAT PATH at seq ${attested.seq}. A gate organ is policy.core and policy.core is human-only, so no grant for this edit can exist; content attestation is the evidence`,
|
|
1224
|
+
});
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
// Not attested: fall through, exactly as the policy file does. A policy
|
|
1229
|
+
// that does not make policy.core human-only can still produce a grant
|
|
1230
|
+
// naming this path, and that grant is evidence like any other.
|
|
1231
|
+
}
|
|
1232
|
+
// 2 and 3. A grant whose bound material names this path, close enough in
|
|
1233
|
+
// time to the commit that introduced the change to be about it.
|
|
1234
|
+
const changeTs = input.changeTsFor(path);
|
|
1235
|
+
const lookbackMs = input.lookbackMs ?? DEFAULT_LOOKBACK_MS;
|
|
1236
|
+
// ONE derivation of the anchor, so the bound that is enforced and the bound
|
|
1237
|
+
// that is reported cannot disagree. An unparseable timestamp is no anchor,
|
|
1238
|
+
// exactly as a missing one is not: both land in `anchorMs === null`.
|
|
1239
|
+
const parsed = changeTs === null ? Number.NaN : Date.parse(changeTs);
|
|
1240
|
+
const anchorMs = Number.isNaN(parsed) ? null : parsed;
|
|
1241
|
+
const inWindow = (ts) => {
|
|
1242
|
+
if (anchorMs === null)
|
|
1243
|
+
return true;
|
|
1244
|
+
const at = Date.parse(ts);
|
|
1245
|
+
return !Number.isNaN(at) && Math.abs(at - anchorMs) <= lookbackMs;
|
|
1246
|
+
};
|
|
1247
|
+
const boundText = anchorMs === null
|
|
1248
|
+
? `no usable commit timestamp for this path (${changeTs === null ? "git named none" : `git named ${JSON.stringify(changeTs)}, which does not parse`}), so NO recency bound was applied and this evidence rests on the path match alone`
|
|
1249
|
+
: `within ${Math.round(lookbackMs / 86_400_000)}d of the commit that changed it (${changeTs})`;
|
|
1250
|
+
// EVERY qualifying grant is collected and the best one is reported, rather
|
|
1251
|
+
// than the first one found. The first-match version passed commit 41d2c9f
|
|
1252
|
+
// on a `cp SPEC.md <dir>/` granted four days earlier while the grant that
|
|
1253
|
+
// actually authorized it sat 95 seconds before the commit: a true verdict
|
|
1254
|
+
// resting on a misleading reason, which is the kind of pass that survives
|
|
1255
|
+
// review and then misleads whoever reads the log after an incident. Order:
|
|
1256
|
+
// the stronger evidence kind first, then the grant nearest the change.
|
|
1257
|
+
const unresolved = [];
|
|
1258
|
+
const stale = [];
|
|
1259
|
+
const candidates = [];
|
|
1260
|
+
for (const grant of grants) {
|
|
1261
|
+
const hash = payloadOf(grant)["payload_hash"];
|
|
1262
|
+
if (typeof hash !== "string")
|
|
1263
|
+
continue;
|
|
1264
|
+
const material = input.payloadFor(hash);
|
|
1265
|
+
if (material === null) {
|
|
1266
|
+
unresolved.push(hash);
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
const found = evidenceFor(material, path, input.policyProtectedPaths);
|
|
1270
|
+
if (found === null)
|
|
1271
|
+
continue;
|
|
1272
|
+
if (!inWindow(grant.ts)) {
|
|
1273
|
+
stale.push(grant);
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
candidates.push({ record: grant, match: found, source: "grant", material, payloadHash: hash });
|
|
1277
|
+
}
|
|
1278
|
+
for (const start of records) {
|
|
1279
|
+
const found = policyAuthorizedEvidence(records, start, path, input.policyProtectedPaths, input.payloadFor, anchorMs, lookbackMs);
|
|
1280
|
+
const hash = payloadOf(start)["payload_hash"];
|
|
1281
|
+
if (found !== null && typeof hash === "string") {
|
|
1282
|
+
const material = input.payloadFor(hash);
|
|
1283
|
+
if (material !== null) {
|
|
1284
|
+
candidates.push({ record: start, match: found, source: "policy", material, payloadHash: hash });
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
const evidenceNoun = candidates.every((candidate) => candidate.source === "grant")
|
|
1289
|
+
? `grant${candidates.length === 1 ? "" : "s"}`
|
|
1290
|
+
: `evidence record${candidates.length === 1 ? "" : "s"}`;
|
|
1291
|
+
/** Distance from the change commit, or 0 when there is no anchor to measure from. */
|
|
1292
|
+
const distance = (grant) => {
|
|
1293
|
+
if (anchorMs === null)
|
|
1294
|
+
return 0;
|
|
1295
|
+
const at = Date.parse(grant.ts);
|
|
1296
|
+
return Number.isNaN(at) ? Number.POSITIVE_INFINITY : Math.abs(at - anchorMs);
|
|
1297
|
+
};
|
|
1298
|
+
const rank = (kind) => kind === "granted-file" ? 0 : kind === "policy-authorized-file" ? 1 : 2;
|
|
1299
|
+
candidates.sort((left, right) => rank(left.match.kind) - rank(right.match.kind) ||
|
|
1300
|
+
distance(left.record) - distance(right.record));
|
|
1301
|
+
// The change this pull request makes to the path. Read before any coverage
|
|
1302
|
+
// is attempted, and a failure to read it is a failure: a change the guard
|
|
1303
|
+
// cannot see is not a change it has checked (APRV-202).
|
|
1304
|
+
const blobs = input.blobsFor(path);
|
|
1305
|
+
if (blobs === null) {
|
|
1306
|
+
findings.push({
|
|
1307
|
+
path,
|
|
1308
|
+
ok: false,
|
|
1309
|
+
code: "change-unreadable",
|
|
1310
|
+
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}.`,
|
|
1311
|
+
});
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
const hunks = hunksOf(blobs);
|
|
1315
|
+
const baseText = blobs.base ?? "";
|
|
1316
|
+
const headText = blobs.head ?? "";
|
|
1317
|
+
// Which of those grants covers which lines. A pull request may carry
|
|
1318
|
+
// several approved edits to one file, so coverage accumulates and the
|
|
1319
|
+
// finding names every contributor.
|
|
1320
|
+
const contributors = [];
|
|
1321
|
+
const rejected = [];
|
|
1322
|
+
const addedCover = new Set();
|
|
1323
|
+
const removedCover = new Set();
|
|
1324
|
+
let whole = false;
|
|
1325
|
+
for (const candidate of candidates) {
|
|
1326
|
+
const { record, match, source } = candidate;
|
|
1327
|
+
// An execution start says an exact file action was authorized, not that a
|
|
1328
|
+
// mode-only or whitespace-only change happened. Legacy human grants retain
|
|
1329
|
+
// their narrow no-substantive-hunk behavior; policy starts do not gain it.
|
|
1330
|
+
if (hunks.identical && source === "policy")
|
|
1331
|
+
continue;
|
|
1332
|
+
const at = source === "grant"
|
|
1333
|
+
? `the grant at seq ${record.seq}`
|
|
1334
|
+
: `the policy-authorized execution at seq ${record.seq}`;
|
|
1335
|
+
if (match.command) {
|
|
1336
|
+
if (!commandTargetsPath(match.target ?? "", match.cwd, path)) {
|
|
1337
|
+
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`);
|
|
1338
|
+
continue;
|
|
1339
|
+
}
|
|
1340
|
+
const run = attributeRun(record, runs, anchorMs, attributionMs);
|
|
1341
|
+
if (!run.ok) {
|
|
1342
|
+
rejected.push(run.why);
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
// A command payload describes no bytes, so attribution is all or
|
|
1346
|
+
// nothing: the run that wrote this file wrote whatever is in it.
|
|
1347
|
+
whole = true;
|
|
1348
|
+
contributors.push({ record, kind: match.kind, why: `${match.detail}, and ${run.detail}`, whole: true, source });
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
if (match.after === null && match.before === null) {
|
|
1352
|
+
rejected.push(`${at} names this path and binds no bytes (${match.detail}), so it covers nothing`);
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
const after = match.after?.[0] ?? "";
|
|
1356
|
+
const before = match.before?.[0] ?? "";
|
|
1357
|
+
let contributed = null;
|
|
1358
|
+
let wholeHere = false;
|
|
1359
|
+
if (match.whole) {
|
|
1360
|
+
if (after === headText) {
|
|
1361
|
+
whole = true;
|
|
1362
|
+
wholeHere = true;
|
|
1363
|
+
contributed = `${match.detail}, and those bytes ARE the blob at head`;
|
|
1364
|
+
}
|
|
1365
|
+
else if (after.length > 0 && headText.includes(after)) {
|
|
1366
|
+
for (const line of materialLines([after]))
|
|
1367
|
+
addedCover.add(line);
|
|
1368
|
+
contributed = `${match.detail}, and those bytes occur in the blob at head`;
|
|
1369
|
+
}
|
|
1370
|
+
else {
|
|
1371
|
+
rejected.push(`${at} binds a whole-file write whose bytes are neither equal to nor contained in the blob at head, so what landed is not what was approved`);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
else {
|
|
1375
|
+
const parts = [];
|
|
1376
|
+
if (after.length > 0) {
|
|
1377
|
+
if (headText.includes(after)) {
|
|
1378
|
+
for (const line of materialLines([after]))
|
|
1379
|
+
addedCover.add(line);
|
|
1380
|
+
parts.push("its after-state occurs verbatim in the blob at head");
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
rejected.push(`${at} binds an after-state that does not occur in the blob at head, so the edit it approved is not the edit that landed`);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
if (before.length > 0) {
|
|
1387
|
+
if (baseText.includes(before)) {
|
|
1388
|
+
for (const line of materialLines([before]))
|
|
1389
|
+
removedCover.add(line);
|
|
1390
|
+
parts.push("its before-state occurs in the blob at base");
|
|
1391
|
+
}
|
|
1392
|
+
else {
|
|
1393
|
+
rejected.push(`${at} binds a before-state that does not occur in the blob at base`);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
if (parts.length > 0)
|
|
1397
|
+
contributed = `${match.detail}, and ${parts.join(" and ")}`;
|
|
1398
|
+
}
|
|
1399
|
+
if (contributed !== null) {
|
|
1400
|
+
contributors.push({ record, kind: match.kind, why: contributed, whole: wholeHere, source });
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
// Exact Edit payloads may bind fragments within a line, including several
|
|
1404
|
+
// independent fragments of the same long line. Whole-line set membership
|
|
1405
|
+
// cannot express that safely. Replay is the bounded fallback: genuine
|
|
1406
|
+
// starts, execution order, unique anchors, and final byte equality.
|
|
1407
|
+
const needsReplay = !whole &&
|
|
1408
|
+
!hunks.identical &&
|
|
1409
|
+
(hunks.reordered ||
|
|
1410
|
+
hunks.added.some((line) => !addedCover.has(line)) ||
|
|
1411
|
+
hunks.removed.some((line) => !removedCover.has(line)));
|
|
1412
|
+
const replay = needsReplay
|
|
1413
|
+
? exactEditReplay(candidates, records, blobs.base, blobs.head, anchorMs, lookbackMs, path, input.policyProtectedPaths)
|
|
1414
|
+
: { steps: null, bound: null };
|
|
1415
|
+
if (replay.bound !== null) {
|
|
1416
|
+
rejected.unshift(`exact BASE-to-HEAD replay refused after reaching its ${replay.bound.replace("-", " ")}`);
|
|
1417
|
+
}
|
|
1418
|
+
if (replay.steps !== null) {
|
|
1419
|
+
whole = true;
|
|
1420
|
+
contributors.splice(0, contributors.length, ...replay.steps.map((step) => ({
|
|
1421
|
+
record: step.candidate.record,
|
|
1422
|
+
kind: step.candidate.match.kind,
|
|
1423
|
+
why: `${step.candidate.match.detail}, applied at execution.started seq ${step.start.seq} in an exact BASE-to-HEAD replay`,
|
|
1424
|
+
whole: true,
|
|
1425
|
+
source: step.candidate.source,
|
|
1426
|
+
})));
|
|
1427
|
+
}
|
|
1428
|
+
// What the bytes alone cover, before any whole-file attribution. If this is
|
|
1429
|
+
// non-empty the change RESTS on the attribution, and the finding has to
|
|
1430
|
+
// lead with the grant carrying it rather than with a file grant that
|
|
1431
|
+
// covered a line or two: a true verdict resting on a misleading reason is
|
|
1432
|
+
// the failure APRV-151 fixed once already.
|
|
1433
|
+
const byBytesAdded = hunks.added.filter((line) => !addedCover.has(line));
|
|
1434
|
+
const byBytesRemoved = hunks.removed.filter((line) => !removedCover.has(line));
|
|
1435
|
+
const restsOnAttribution = whole && (byBytesAdded.length > 0 || byBytesRemoved.length > 0 || hunks.reordered);
|
|
1436
|
+
const uncoveredAdded = whole ? [] : byBytesAdded;
|
|
1437
|
+
const uncoveredRemoved = whole ? [] : byBytesRemoved;
|
|
1438
|
+
const uncovered = [
|
|
1439
|
+
...uncoveredAdded.map((line) => `+${line}`),
|
|
1440
|
+
...uncoveredRemoved.map((line) => `-${line}`),
|
|
1441
|
+
];
|
|
1442
|
+
if (hunks.reordered && !whole) {
|
|
1443
|
+
uncovered.push("~ the same lines in a different order (a reordering no granted material describes)");
|
|
1444
|
+
}
|
|
1445
|
+
// The headline. Ordinarily it is the strongest and nearest grant that
|
|
1446
|
+
// actually covered something. The one exception is a change with no
|
|
1447
|
+
// substantive line difference at all (whitespace, a mode bit): there is no
|
|
1448
|
+
// hunk to cover, so the pre-APRV-202 rule stands for it and a naming grant
|
|
1449
|
+
// inside the window is the evidence. That is the only surviving path-level
|
|
1450
|
+
// pass, and it is narrow by construction: a change that alters no line.
|
|
1451
|
+
const lead = restsOnAttribution
|
|
1452
|
+
? (contributors.find((one) => one.whole) ?? contributors[0])
|
|
1453
|
+
: contributors[0];
|
|
1454
|
+
const quiet = hunks.identical
|
|
1455
|
+
? candidates.find((candidate) => candidate.source === "grant")
|
|
1456
|
+
: undefined;
|
|
1457
|
+
const passing = lead !== undefined
|
|
1458
|
+
? { record: lead.record, kind: lead.kind, why: lead.why }
|
|
1459
|
+
: quiet !== undefined
|
|
1460
|
+
? {
|
|
1461
|
+
record: quiet.record,
|
|
1462
|
+
kind: quiet.match.kind,
|
|
1463
|
+
why: `${quiet.match.detail}, and this change alters no substantive line (whitespace, mode or metadata only), so there is no hunk to cover`,
|
|
1464
|
+
}
|
|
1465
|
+
: undefined;
|
|
1466
|
+
if (passing !== undefined && uncovered.length === 0) {
|
|
1467
|
+
const record = passing.record;
|
|
1468
|
+
const kind = passing.kind;
|
|
1469
|
+
const why = passing.why;
|
|
1470
|
+
findings.push({
|
|
1471
|
+
path,
|
|
1472
|
+
ok: true,
|
|
1473
|
+
evidence: kind,
|
|
1474
|
+
seq: record.seq,
|
|
1475
|
+
ts: record.ts,
|
|
1476
|
+
actor: record.actor,
|
|
1477
|
+
coveredBy: contributors.map((one) => one.record.seq),
|
|
1478
|
+
detail: replay.steps !== null
|
|
1479
|
+
? `${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}.`
|
|
1480
|
+
: `${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
|
|
1481
|
+
? "there are no substantive hunks to cover"
|
|
1482
|
+
: whole
|
|
1483
|
+
? "the whole change is attributed to that authorization record"
|
|
1484
|
+
: `${hunks.added.length} added and ${hunks.removed.length} removed line(s) all trace to authorized material`}${contributors.length > 1
|
|
1485
|
+
? ` (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)`
|
|
1486
|
+
: ""}`,
|
|
1487
|
+
});
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
// Naming grants exist and the change is not made of them: the repeat-edit
|
|
1491
|
+
// shape. Its own code, because the reader's next move differs from
|
|
1492
|
+
// `no-evidence` — take THIS change to the gate, rather than hunt for a
|
|
1493
|
+
// record that was never written.
|
|
1494
|
+
if (candidates.length > 0) {
|
|
1495
|
+
const sample = uncovered.slice(0, 5).map((line) => oneLine(line));
|
|
1496
|
+
findings.push({
|
|
1497
|
+
path,
|
|
1498
|
+
ok: false,
|
|
1499
|
+
code: "uncovered-hunk",
|
|
1500
|
+
uncovered: sample,
|
|
1501
|
+
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
|
|
1502
|
+
? `${contributors.length} evidence record${contributors.length === 1 ? "" : "s"} covered part of it (seq ${contributors.map((one) => one.record.seq).join(", ")}); `
|
|
1503
|
+
: ""}${rejected.length > 0
|
|
1504
|
+
? `${rejected.slice(0, 6).join("; ")}${rejected.length > 6 ? `; … and ${rejected.length - 6} other naming grants set aside for the same kinds of reason` : ""}. `
|
|
1505
|
+
: ""}uncovered: ${sample.map((line) => JSON.stringify(line)).join(", ")}${uncovered.length > sample.length ? `, … ${uncovered.length - sample.length} more` : ""}. ${windowText(input.window)}. ${ORDERING_RULE}.`,
|
|
1506
|
+
});
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
const diagnosis = [
|
|
1510
|
+
`${grants.length} grant${grants.length === 1 ? "" : "s"} of class ${GRANTING_CLASSES.join("/")} in this window, none naming this path`,
|
|
1511
|
+
];
|
|
1512
|
+
if (stale.length > 0) {
|
|
1513
|
+
const oldest = stale[0];
|
|
1514
|
+
diagnosis.push(`${stale.length} grant${stale.length === 1 ? "" : "s"} DO name this path but sit outside the recency bound (${boundText}); the oldest is seq ${oldest.seq} at ${oldest.ts}, and a grant that old authorized some earlier edit, not this one`);
|
|
1515
|
+
}
|
|
1516
|
+
if (unresolved.length > 0) {
|
|
1517
|
+
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`);
|
|
1518
|
+
}
|
|
1519
|
+
if ((endsWithSegments(input.policyPath, path) || endsWithSegments(path, input.policyPath)) &&
|
|
1520
|
+
input.policySha256AtHead !== null) {
|
|
1521
|
+
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`);
|
|
1522
|
+
}
|
|
1523
|
+
if (isGateOrganPath(path)) {
|
|
1524
|
+
// The one failure in this guard whose repair is NOT "take the change to
|
|
1525
|
+
// the gate": there is no gate for it. `policy.core` is human-only, the
|
|
1526
|
+
// gate mints nothing for a human-only class, so a grant for this edit
|
|
1527
|
+
// cannot be obtained by anybody. Naming the verb is the whole point of
|
|
1528
|
+
// the message (APRV-272).
|
|
1529
|
+
const digest = input.organSha256AtHead?.(path) ?? null;
|
|
1530
|
+
diagnosis.push(digest === null
|
|
1531
|
+
? `${path} is one of the gate's organs and no bytes for it could be hashed at ${input.window.head} (this change deletes it, or the caller supplied no digest), so no attestation can match it; a deletion is not evidenced by a record about bytes that no longer exist`
|
|
1532
|
+
: `no gate.organ.attested record attests ${path} at ${digest}, and a digest attested for some OTHER path is not evidence for this one. A gate organ is policy.core, policy.core is human-only, and the gate mints nothing for a human-only class — so no grant for this edit can exist and content attestation is the only evidence there is. The human route, in the PRIMARY checkout: \`approval policy attest --organ ${path} --as human:<id>\`, then a log advance carrying that record`);
|
|
1533
|
+
}
|
|
1534
|
+
findings.push({
|
|
1535
|
+
path,
|
|
1536
|
+
ok: false,
|
|
1537
|
+
code: "no-evidence",
|
|
1538
|
+
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}.`,
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
return {
|
|
1542
|
+
ok: findings.every((finding) => finding.ok),
|
|
1543
|
+
findings,
|
|
1544
|
+
exempt,
|
|
1545
|
+
window: input.window,
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
/** The report as the lines CI prints. Pure. */
|
|
1549
|
+
export function renderGuardReport(report) {
|
|
1550
|
+
const lines = [];
|
|
1551
|
+
lines.push(`protected-path guard: ${report.window.base}..${report.window.head}`);
|
|
1552
|
+
if (report.exempt.length > 0) {
|
|
1553
|
+
lines.push(` exempt (the daemon's own append surface, evidence rather than a protected write): ${report.exempt.join(", ")}`);
|
|
1554
|
+
}
|
|
1555
|
+
if (report.findings.length === 0) {
|
|
1556
|
+
lines.push(" no protected paths changed");
|
|
1557
|
+
return `${lines.join("\n")}\n`;
|
|
1558
|
+
}
|
|
1559
|
+
for (const finding of report.findings) {
|
|
1560
|
+
const head = finding.ok ? ` PASS ${finding.path} [${finding.evidence}]` : ` FAIL ${finding.path} [${finding.code}]`;
|
|
1561
|
+
lines.push(head);
|
|
1562
|
+
lines.push(` ${finding.detail}`);
|
|
1563
|
+
}
|
|
1564
|
+
return `${lines.join("\n")}\n`;
|
|
1565
|
+
}
|
|
1566
|
+
//# sourceMappingURL=protected-path-guard.js.map
|