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,2849 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `approval hook` — harness adapters that put the gate in front of the commands
|
|
3
|
+
* an agent's harness runs directly (APRV-82 Claude Code, APRV-133 Cursor).
|
|
4
|
+
*
|
|
5
|
+
* The problem it closes. Until this verb, the runtime gated what went through
|
|
6
|
+
* `approval run`. Everything the harness executed on its own — `git push`, `gh
|
|
7
|
+
* pr create`, `npm install`, `curl` — bypassed APPROVAL.md entirely, so the
|
|
8
|
+
* enforcement of those classes was the prose in CLAUDE.md and an agent's
|
|
9
|
+
* willingness to read it. That is exactly the AGENTS.md failure SPEC.md §2
|
|
10
|
+
* critiques, reproduced inside the repository that critiques it.
|
|
11
|
+
*
|
|
12
|
+
* As everywhere else in this CLI, **no logic lives here.** Classification is
|
|
13
|
+
* `core/command-class.ts` (pure, fixture-tested); registration, policy
|
|
14
|
+
* resolution and intake are `core/gate.ts`; the decision is derived from the
|
|
15
|
+
* verified log by `core/state.ts`. This file reads one JSON object from stdin,
|
|
16
|
+
* calls those, and prints one JSON object back.
|
|
17
|
+
*
|
|
18
|
+
* Four choices are load-bearing enough to state plainly.
|
|
19
|
+
*
|
|
20
|
+
* **It exits 0 with a verdict, or 2 with nothing.** Claude Code reads a hook's
|
|
21
|
+
* stdout as a decision only on exit 0; a hook that exits 2 is a *block* with the
|
|
22
|
+
* stderr text as the reason, and any other non-zero code is a non-blocking
|
|
23
|
+
* error. So every classified or decided outcome — allow and deny alike — is an
|
|
24
|
+
* exit 0 with `hookSpecificOutput` on stdout, and the only exit 2 is a
|
|
25
|
+
* misconfigured hook (an unknown flag, a bad identity), where blocking is the
|
|
26
|
+
* correct failure mode. No new exit code is added to the frozen table.
|
|
27
|
+
*
|
|
28
|
+
* **Never `ask`.** The permission decision vocabulary includes `ask`, which
|
|
29
|
+
* hands the question to the harness's own prompt. Using it would answer an
|
|
30
|
+
* approval question outside the log: no request, no record, no audit trail, and
|
|
31
|
+
* a human deciding in a UI the policy never named. The hook allows or denies,
|
|
32
|
+
* and every deny carries a machine-readable code.
|
|
33
|
+
*
|
|
34
|
+
* **Fail closed on every axis.** An unreadable policy, an unreachable log, a
|
|
35
|
+
* command the classifier cannot read, a wait that times out: all deny. A hook
|
|
36
|
+
* that fell back to allow when it could not reach the gate would be worst
|
|
37
|
+
* precisely when it mattered. Since APRV-139 that includes an unattested
|
|
38
|
+
* policy: a verdict nobody is asked about is checked against the verified log
|
|
39
|
+
* first, exactly as `core/execute.ts` checks one (see `unattendedGuard`).
|
|
40
|
+
*
|
|
41
|
+
* **The harness executes, not the runtime.** The hook decides *before* the tool
|
|
42
|
+
* runs and never spawns anything, so it never writes an `execution.completed`
|
|
43
|
+
* or `execution.failed`: the runtime does not run the command and never learns
|
|
44
|
+
* how it went. It does write one `execution.started`, and only where a verdict
|
|
45
|
+
* of `allow` rests on a human's grant — that record is the *consumption* of the
|
|
46
|
+
* grant (APRV-117), which a harness request needs because it mints no token
|
|
47
|
+
* that could be spent instead. `core/gate.ts`'s `consumeHarnessGrant` is where
|
|
48
|
+
* that lives and why. What the log records is otherwise the approval lifecycle:
|
|
49
|
+
* `task.registered`, `approval.requested`, and the human's decision.
|
|
50
|
+
*
|
|
51
|
+
* **A decision outlives the invocation that asked for it (APRV-117).** Requests
|
|
52
|
+
* are matched by the payload hash of `{command, cwd}`, so the answer to "may I
|
|
53
|
+
* run these bytes, here" belongs to the bytes rather than to one tool-use id.
|
|
54
|
+
* A retry while the question is pending adopts it instead of asking twice; a
|
|
55
|
+
* retry after a grant lands proceeds on it, once, inside the TTL. That is why
|
|
56
|
+
* the wait no longer ends in an immediate withdrawal: a late tap authorizes
|
|
57
|
+
* something. It ends in one once the RETRY GRACE has run out (APRV-287): past
|
|
58
|
+
* that window nothing is coming back to adopt the question, and a request left
|
|
59
|
+
* standing is one more dead message a restarted listener re-delivers.
|
|
60
|
+
*
|
|
61
|
+
* **An allow follows its record, and says which window it sits in (APRV-200).**
|
|
62
|
+
* The harness executes and never sees this process's return value, so what
|
|
63
|
+
* authorizes the tool call is the record and not the verdict. Every allow that
|
|
64
|
+
* rests on a grant therefore spends it, RE-READS the verified log to establish
|
|
65
|
+
* that the `execution.started` is in the chain, and only then prints — a
|
|
66
|
+
* `hook-grant-unverified` deny where it cannot. The record itself carries
|
|
67
|
+
* `grant_origin`: `direct` where the tool call that spent the grant is the tool
|
|
68
|
+
* call that asked for it, `carried` where a later one spent it under the
|
|
69
|
+
* carryover above. Only `direct` states an ordering this runtime observed;
|
|
70
|
+
* `carried` is the window in which a grant can be a ratification of a write the
|
|
71
|
+
* harness already applied, and naming it is what makes that visible to an
|
|
72
|
+
* auditor holding the log alone. See `docs/claude-code-hook.md`.
|
|
73
|
+
*/
|
|
74
|
+
import { spawnSync } from "node:child_process";
|
|
75
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
76
|
+
import { randomBytes } from "node:crypto";
|
|
77
|
+
import { tmpdir } from "node:os";
|
|
78
|
+
import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments, sep, } from "node:path";
|
|
79
|
+
import { attestationRefusal, checkAttestation } from "../core/attest.js";
|
|
80
|
+
import { childEnvironment } from "../core/child-env.js";
|
|
81
|
+
import { classifyCommand, commandSegmentWords, CODE_EXECUTING_RULES, GATE_SELF_CLASS, protectedPathClass, } from "../core/command-class.js";
|
|
82
|
+
import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, register, request, startHarnessExecution, withdraw, } from "../core/gate.js";
|
|
83
|
+
import { openGateWindow, recordGateBypass, } from "../core/gate-window.js";
|
|
84
|
+
import { harnessProvenance, } from "../core/harness-version.js";
|
|
85
|
+
import { abandonedAfterMs, HOOK_DEFAULT_WAIT, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
|
|
86
|
+
import { harnessLoopFloor, isLoopEscalated, isSideEffectingClass, loopClearance, UNKNOWN_SESSION, } from "../core/loop.js";
|
|
87
|
+
import { drawSocketPathFor, drawSocketUsable } from "../core/live-draw.js";
|
|
88
|
+
import { payloadHash } from "../core/payload.js";
|
|
89
|
+
import { classifyApplyPatch, parseApplyPatch } from "../core/apply-patch.js";
|
|
90
|
+
import { loadPolicy, parseDuration } from "../core/policy-load.js";
|
|
91
|
+
import { humanOnlyRefusal, resolve as resolvePolicy } from "../core/policy-match.js";
|
|
92
|
+
import { payloadOf, readVerifiedRecords, requestState, useVerifiedSnapshots, } from "../core/state.js";
|
|
93
|
+
import { boolFlag, parseFlags, stringFlag } from "./args.js";
|
|
94
|
+
import { EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
|
|
95
|
+
import { primaryRoot as resolvePrimaryRoot } from "./git-scope.js";
|
|
96
|
+
import { HOOK_HELP } from "./help.js";
|
|
97
|
+
import { DEFAULT_LOG_PATH } from "./paths.js";
|
|
98
|
+
import { refusal as renderRefusal, style, table } from "./style.js";
|
|
99
|
+
import { usageErrorText } from "./usage.js";
|
|
100
|
+
import { checkCodexHookInput, codexBinding, CODEX_POST_TOOL_EVENT, readCodexReportedOutcome, } from "./hook-codex.js";
|
|
101
|
+
/** Identity accepted for the proposing side: a person or an agent. */
|
|
102
|
+
const PRINCIPAL_ACTOR = /^(human|agent):.+/u;
|
|
103
|
+
/**
|
|
104
|
+
* Default wait, chosen to sit inside Claude Code's own 60s hook default.
|
|
105
|
+
*
|
|
106
|
+
* Spelled in `core/harness-wait.ts` since APRV-287, where the Telegram
|
|
107
|
+
* listener reads the same duration to decide which pending requests nobody is
|
|
108
|
+
* waiting on any more.
|
|
109
|
+
*/
|
|
110
|
+
const DEFAULT_TIMEOUT = HOOK_DEFAULT_WAIT;
|
|
111
|
+
/** Poll interval for the decision wait. */
|
|
112
|
+
const DEFAULT_INTERVAL_MS = 1_000;
|
|
113
|
+
/**
|
|
114
|
+
* How much of the command line goes in the (claimed) summary field.
|
|
115
|
+
*
|
|
116
|
+
* A HEADLINE, and only that (APRV-124). What the approver is bound to is the
|
|
117
|
+
* payload, which carries the whole command (or the whole change) and is never
|
|
118
|
+
* shortened; this is the one-line label above it. Exported because the tests
|
|
119
|
+
* pin the distinction.
|
|
120
|
+
*/
|
|
121
|
+
export const SUMMARY_LIMIT = 160;
|
|
122
|
+
/**
|
|
123
|
+
* The closed set of hook denial codes, frozen in the sense
|
|
124
|
+
* `GATE_REFUSAL_CODES` is: the reason string a human reads and an agent
|
|
125
|
+
* branches on starts with one of these.
|
|
126
|
+
*
|
|
127
|
+
* `hook-gate-refused` is a family: the emitted code is
|
|
128
|
+
* `hook-gate-refused:<gate refusal code>`, so the gate's own frozen vocabulary
|
|
129
|
+
* reaches the caller unflattened.
|
|
130
|
+
*/
|
|
131
|
+
export const HOOK_DENY_CODES = [
|
|
132
|
+
/** No rule covers some segment of the command line. */
|
|
133
|
+
"hook-unclassified",
|
|
134
|
+
/**
|
|
135
|
+
* Some class of the command resolves to `human-only` (APRV-185, amended
|
|
136
|
+
* SPEC.md §5.2): the policy reserves it to human hands, so the command is
|
|
137
|
+
* denied outright and no gate lifecycle is opened for it.
|
|
138
|
+
*
|
|
139
|
+
* This union's spelling of the gate's `class-human-only`, which the detail
|
|
140
|
+
* names in full. It wears the `hook-` prefix every other member wears rather
|
|
141
|
+
* than borrowing the gate's bare code, because a caller branching on this
|
|
142
|
+
* vocabulary branches on one shape; `hook-gate-refused:<c>` is the form
|
|
143
|
+
* reserved for a code the gate itself produced, and the gate is not asked
|
|
144
|
+
* here.
|
|
145
|
+
*
|
|
146
|
+
* Distinct from `hook-unclassified`, and the repairs are opposites. That one
|
|
147
|
+
* says the policy has nothing to say about this command, so the fix is to
|
|
148
|
+
* declare a class for it. This one says the policy has spoken as clearly as
|
|
149
|
+
* it can, and the fix is for a person to run the command themselves. Distinct
|
|
150
|
+
* from `hook-rejected` for the reason the gate's code is distinct from a
|
|
151
|
+
* rejection: nobody decided anything, so there is nothing to ask again.
|
|
152
|
+
*/
|
|
153
|
+
"hook-class-human-only",
|
|
154
|
+
/** A construct whose effect cannot be read off the text (`bash -c`, `eval`). */
|
|
155
|
+
"hook-opaque",
|
|
156
|
+
/** The command line could not be tokenized at all. */
|
|
157
|
+
"hook-unparseable",
|
|
158
|
+
/** A human rejected the request. */
|
|
159
|
+
"hook-rejected",
|
|
160
|
+
/** A previously granted request was withdrawn. */
|
|
161
|
+
"hook-revoked",
|
|
162
|
+
/** The request's TTL lapsed before a decision. */
|
|
163
|
+
"hook-expired",
|
|
164
|
+
/**
|
|
165
|
+
* The request was withdrawn before a decision landed (APRV-106). Since
|
|
166
|
+
* APRV-117 the timeout no longer produces this: what does is a session that
|
|
167
|
+
* ended mid-wait (signal or failure) and an operator's `approval withdraw`.
|
|
168
|
+
* Terminal, and not a refusal by anyone.
|
|
169
|
+
*/
|
|
170
|
+
"hook-withdrawn",
|
|
171
|
+
/**
|
|
172
|
+
* The wait elapsed with the request still undecided. The request stays open
|
|
173
|
+
* for the RETRY GRACE (APRV-117, bounded by APRV-287): a decision inside that
|
|
174
|
+
* window authorizes a retry of the identical command in the identical
|
|
175
|
+
* directory, once. Past the grace the hook withdraws it (reason `timeout`),
|
|
176
|
+
* because a question nothing will adopt is a message on a phone that decides
|
|
177
|
+
* nothing.
|
|
178
|
+
*/
|
|
179
|
+
"hook-timeout",
|
|
180
|
+
/** The gate refused intake; the gate's own code follows a colon. */
|
|
181
|
+
"hook-gate-refused",
|
|
182
|
+
/**
|
|
183
|
+
* The grant was spent and the VERIFIED log does not show it (APRV-200).
|
|
184
|
+
*
|
|
185
|
+
* Distinct from `hook-gate-refused:append-failed`, which says the write was
|
|
186
|
+
* refused and nothing landed. This one says the write reported success and the
|
|
187
|
+
* chain cannot be seen to carry it, which is a different fact with a different
|
|
188
|
+
* repair: nothing here is retried, the log is checked (`approval log verify`).
|
|
189
|
+
*
|
|
190
|
+
* On this surface the record IS the authorization — the harness executes and
|
|
191
|
+
* never sees the gate's return value — so a verdict is not printed until the
|
|
192
|
+
* verified chain carries the execution the harness is about to perform. The
|
|
193
|
+
* grant is spent by the time this fires, which is the fail-closed direction:
|
|
194
|
+
* one more prompt on the retry, and nothing authorized meanwhile.
|
|
195
|
+
*/
|
|
196
|
+
"hook-grant-unverified",
|
|
197
|
+
/**
|
|
198
|
+
* `APPROVAL_HOOK_REQUIRE_SANDBOX=1` is set and this command runs code the
|
|
199
|
+
* runtime did not author, unwrapped (APRV-193).
|
|
200
|
+
*
|
|
201
|
+
* The one deny in this union that names a spelling that would work rather
|
|
202
|
+
* than a decision or a fault: re-run it as `approval sandbox -- <cmd>` and it
|
|
203
|
+
* proceeds, classified exactly as it is now, with no way out to the network.
|
|
204
|
+
*
|
|
205
|
+
* It exists because the hook DECIDES and the harness EXECUTES. A verdict
|
|
206
|
+
* cannot rewrite a command into a wrapper, so the only way for this runtime
|
|
207
|
+
* to insist on the room is to refuse the spelling that does not ask for it.
|
|
208
|
+
* Off by default, and turning it on can only ever refuse more — which is why
|
|
209
|
+
* an environment variable is an acceptable home for it, and why nothing in
|
|
210
|
+
* the other direction is readable from one.
|
|
211
|
+
*/
|
|
212
|
+
"hook-sandbox-required",
|
|
213
|
+
/** The policy could not be loaded, so no class can be resolved. */
|
|
214
|
+
"hook-policy-unavailable",
|
|
215
|
+
/**
|
|
216
|
+
* No log exists where the hook was pointed. The hook is a WRITER to an
|
|
217
|
+
* existing log, never an initializer: creating one where it happens to stand
|
|
218
|
+
* (an agent worktree, say) forks a chain off the real log's tail, and git
|
|
219
|
+
* merges do not reconcile hash chains (APRV-101).
|
|
220
|
+
*/
|
|
221
|
+
"hook-log-unreachable",
|
|
222
|
+
/** Malformed hook input, or a log/filesystem fact that stopped the check. */
|
|
223
|
+
"hook-io",
|
|
224
|
+
];
|
|
225
|
+
const COMMON_FLAGS = {
|
|
226
|
+
"--help": "boolean",
|
|
227
|
+
"-h": "boolean",
|
|
228
|
+
};
|
|
229
|
+
const POLICY_FLAGS = {
|
|
230
|
+
"--policy": "string",
|
|
231
|
+
"--dir": "string",
|
|
232
|
+
};
|
|
233
|
+
function absolute(value, cwd) {
|
|
234
|
+
return isAbsolute(value) ? value : resolvePathSegments(cwd, value);
|
|
235
|
+
}
|
|
236
|
+
function usageError(streams, message) {
|
|
237
|
+
streams.err(usageErrorText(message, HOOK_HELP));
|
|
238
|
+
return EXIT_USAGE;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The primary checkout containing `cwd`, or `null` when git cannot say.
|
|
242
|
+
*
|
|
243
|
+
* `git rev-parse --git-common-dir` names the SHARED git directory: in a linked
|
|
244
|
+
* worktree it is the primary checkout's `.git`, in a plain checkout it is this
|
|
245
|
+
* checkout's own (printed as bare `.git` at the top level, absolute from a
|
|
246
|
+
* subdirectory). Either way the primary root is its parent, so a plain checkout
|
|
247
|
+
* resolves to itself.
|
|
248
|
+
*
|
|
249
|
+
* Run exactly as `amend.ts` runs git: `spawnSync`, no shell, and every failure
|
|
250
|
+
* is a value. When git is absent, or `cwd` is not a repository at all, this
|
|
251
|
+
* returns `null` and the caller falls back to `cwd` — today's behaviour, which
|
|
252
|
+
* is what a non-git deployment of the hook has always relied on.
|
|
253
|
+
*
|
|
254
|
+
* APRV-125 gave the resolution two more callers (`log sync` and `log advance`,
|
|
255
|
+
* which refuse outside the primary rather than falling back), so the
|
|
256
|
+
* implementation moved to `cli/git-scope.ts`. This alias keeps the hook reading
|
|
257
|
+
* the same answer they read.
|
|
258
|
+
*/
|
|
259
|
+
const primaryRoot = resolvePrimaryRoot;
|
|
260
|
+
/**
|
|
261
|
+
* Policy and log, resolved from the same root (APRV-101).
|
|
262
|
+
*
|
|
263
|
+
* Before this, `--dir` scoped only the policy and the log was resolved from the
|
|
264
|
+
* process cwd, so a hook invoked with `--dir <primary>` from an agent worktree
|
|
265
|
+
* read the primary's policy and wrote the worktree's copy of the log: a
|
|
266
|
+
* dead-end chain that forks from the real one. Explicit flags still win
|
|
267
|
+
* (`--policy` for the policy, `--log` for the log); otherwise both follow
|
|
268
|
+
* `--dir`, and with no flags at all both follow the primary checkout.
|
|
269
|
+
*/
|
|
270
|
+
function hookScope(flags, cwd) {
|
|
271
|
+
const policyFlag = stringFlag(flags, "--policy");
|
|
272
|
+
const logFlag = stringFlag(flags, "--log");
|
|
273
|
+
const dirFlag = stringFlag(flags, "--dir");
|
|
274
|
+
const root = dirFlag !== null ? absolute(dirFlag, cwd) : (primaryRoot(cwd) ?? cwd);
|
|
275
|
+
const options = policyFlag === null
|
|
276
|
+
? { policy: { dir: root } }
|
|
277
|
+
: { policy: { file: absolute(policyFlag, cwd) } };
|
|
278
|
+
const logPath = logFlag === null ? join(root, DEFAULT_LOG_PATH) : absolute(logFlag, cwd);
|
|
279
|
+
return { logPath, root, options };
|
|
280
|
+
}
|
|
281
|
+
const CLAUDE_ADAPTER = {
|
|
282
|
+
kind: "claude-code",
|
|
283
|
+
originApp: "claude-code-hook",
|
|
284
|
+
defaultActor: "agent:claude-code",
|
|
285
|
+
shellTool: "Bash",
|
|
286
|
+
fileTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
|
|
287
|
+
};
|
|
288
|
+
const CURSOR_ADAPTER = {
|
|
289
|
+
kind: "cursor",
|
|
290
|
+
originApp: "cursor-hook",
|
|
291
|
+
defaultActor: "agent:cursor",
|
|
292
|
+
shellTool: "Shell",
|
|
293
|
+
fileTools: ["Write", "Delete"],
|
|
294
|
+
};
|
|
295
|
+
const CODEX_ADAPTER = {
|
|
296
|
+
kind: "codex",
|
|
297
|
+
originApp: "codex-hook",
|
|
298
|
+
defaultActor: "agent:codex",
|
|
299
|
+
shellTool: "Bash",
|
|
300
|
+
fileTools: ["apply_patch"],
|
|
301
|
+
bindToolName: true,
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* The decision object the harness reads from stdout.
|
|
305
|
+
*
|
|
306
|
+
* Claude Code wants the nested PreToolUse envelope. Cursor native hooks want
|
|
307
|
+
* `{permission, user_message, agent_message}`. One construction site per
|
|
308
|
+
* harness, still never `ask`.
|
|
309
|
+
*/
|
|
310
|
+
function decision(permission, reason, harness, codexCommand) {
|
|
311
|
+
if (harness === "cursor") {
|
|
312
|
+
return `${JSON.stringify({
|
|
313
|
+
permission,
|
|
314
|
+
user_message: reason,
|
|
315
|
+
agent_message: reason,
|
|
316
|
+
})}\n`;
|
|
317
|
+
}
|
|
318
|
+
const hookSpecificOutput = {
|
|
319
|
+
hookEventName: "PreToolUse",
|
|
320
|
+
permissionDecision: permission,
|
|
321
|
+
permissionDecisionReason: reason,
|
|
322
|
+
};
|
|
323
|
+
if (harness === "codex" && permission === "allow") {
|
|
324
|
+
if (codexCommand !== undefined)
|
|
325
|
+
hookSpecificOutput["updatedInput"] = { command: codexCommand };
|
|
326
|
+
}
|
|
327
|
+
return `${JSON.stringify({ hookSpecificOutput })}\n`;
|
|
328
|
+
}
|
|
329
|
+
function allow(streams, reason, harness, codexCommand) {
|
|
330
|
+
if (harness === "codex" && codexCommand === undefined) {
|
|
331
|
+
return deny(streams, "hook-io", "the Codex allow lost its exact bound tool_input.command", harness);
|
|
332
|
+
}
|
|
333
|
+
streams.out(decision("allow", reason, harness, codexCommand));
|
|
334
|
+
return EXIT_OK;
|
|
335
|
+
}
|
|
336
|
+
function deny(streams, code, detail, harness) {
|
|
337
|
+
streams.out(decision("deny", `${code}: ${detail}`, harness));
|
|
338
|
+
return EXIT_OK;
|
|
339
|
+
}
|
|
340
|
+
function readString(source, key) {
|
|
341
|
+
const value = source[key];
|
|
342
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Parse the PreToolUse JSON.
|
|
346
|
+
*
|
|
347
|
+
* Deliberately tolerant about fields the decision does not depend on and strict
|
|
348
|
+
* about the two it does (`tool_name`, and `tool_input.command` for Bash). The
|
|
349
|
+
* `description` field is NEVER read: it is authored by the agent being gated,
|
|
350
|
+
* and a gate that read the subject's own account of its intent would be letting
|
|
351
|
+
* a self-reported field reduce scrutiny (SPEC.md §11.1).
|
|
352
|
+
*/
|
|
353
|
+
function parseHookInput(raw) {
|
|
354
|
+
if (raw.trim().length === 0)
|
|
355
|
+
return { ok: false, detail: "hook stdin was empty" };
|
|
356
|
+
let parsed;
|
|
357
|
+
try {
|
|
358
|
+
parsed = JSON.parse(raw);
|
|
359
|
+
}
|
|
360
|
+
catch (cause) {
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
detail: `hook stdin is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
367
|
+
return { ok: false, detail: "hook stdin is not a JSON object" };
|
|
368
|
+
}
|
|
369
|
+
const fields = parsed;
|
|
370
|
+
const toolName = readString(fields, "tool_name");
|
|
371
|
+
if (toolName === null)
|
|
372
|
+
return { ok: false, detail: "hook input has no tool_name" };
|
|
373
|
+
const toolInputValue = fields["tool_input"];
|
|
374
|
+
const toolInput = typeof toolInputValue === "object" && toolInputValue !== null && !Array.isArray(toolInputValue)
|
|
375
|
+
? toolInputValue
|
|
376
|
+
: {};
|
|
377
|
+
const responseValue = fields["tool_response"];
|
|
378
|
+
const sessionId = readString(fields, "session_id");
|
|
379
|
+
return {
|
|
380
|
+
ok: true,
|
|
381
|
+
input: {
|
|
382
|
+
// The ONE shared bucket for an unreadable session (`core/loop.ts`'s
|
|
383
|
+
// `UNKNOWN_SESSION`): absence accrues faster than a readable id and never
|
|
384
|
+
// slower, which is the fail-closed direction.
|
|
385
|
+
sessionId: sessionId ?? UNKNOWN_SESSION,
|
|
386
|
+
sessionIdPresent: sessionId !== null,
|
|
387
|
+
cwd: readString(fields, "cwd") ?? "",
|
|
388
|
+
toolName,
|
|
389
|
+
toolInput,
|
|
390
|
+
toolUseId: readString(fields, "tool_use_id"),
|
|
391
|
+
hookEventName: readString(fields, "hook_event_name"),
|
|
392
|
+
harnessVersion: readString(fields, "version"),
|
|
393
|
+
interrupted: fields["is_interrupt"] === true,
|
|
394
|
+
toolResponse: typeof responseValue === "object" && responseValue !== null && !Array.isArray(responseValue)
|
|
395
|
+
? responseValue
|
|
396
|
+
: null,
|
|
397
|
+
toolResponseRaw: responseValue,
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
// ===========================================================================
|
|
402
|
+
// History-rewrite refinement (APRV-108)
|
|
403
|
+
// ===========================================================================
|
|
404
|
+
/*
|
|
405
|
+
* Rewriting history nobody else holds is a commit.
|
|
406
|
+
*
|
|
407
|
+
* `vcs.history.rewrite` exists to guard SHARED history: a force push, a rebase
|
|
408
|
+
* of a branch other people have pulled, an amend of a commit that is already on
|
|
409
|
+
* the remote. An agent amending its own unpublished worktree branch destroys
|
|
410
|
+
* nothing anyone can observe, and pricing that at a human's attention spends the
|
|
411
|
+
* audit budget SPEC.md §11 asks to protect on a non-event.
|
|
412
|
+
*
|
|
413
|
+
* The classifier cannot answer this, and deliberately does not try: it is pure,
|
|
414
|
+
* and "is this branch published" is a fact about a checkout, not about a string.
|
|
415
|
+
* So the refinement lives HERE, in the impure layer that already runs git
|
|
416
|
+
* (`primaryRoot`, APRV-101), and is applied to the classifier's output rather
|
|
417
|
+
* than folded into it. `classifyCommand` keeps returning `vcs.history.rewrite`
|
|
418
|
+
* for these verbs, its fixture table keeps meaning what it says, and everything
|
|
419
|
+
* environment-dependent is in one named step a reader can audit.
|
|
420
|
+
*
|
|
421
|
+
* What downgrades, and only this:
|
|
422
|
+
*
|
|
423
|
+
* - the branch has NO upstream at all — nothing was ever published from it, so
|
|
424
|
+
* no rewrite of it can reach anyone else; or
|
|
425
|
+
* - the command is `git commit --amend` and HEAD is not reachable from the
|
|
426
|
+
* upstream — the one commit an amend rewrites has not been pushed.
|
|
427
|
+
*
|
|
428
|
+
* What never downgrades: anything push-side (`git push --force` and friends),
|
|
429
|
+
* a detached HEAD, the repository's default branch, a rebase or reset whose
|
|
430
|
+
* target the text does not name (a `git reset --hard HEAD~5` on a branch with an
|
|
431
|
+
* upstream may well be rewriting published commits, and the text cannot say), and
|
|
432
|
+
* every case where git declines to answer. Fail closed on each: a wrong
|
|
433
|
+
* downgrade removes a human from a decision that needed one, and a wrong
|
|
434
|
+
* `rewrite` costs one approval prompt.
|
|
435
|
+
*/
|
|
436
|
+
/**
|
|
437
|
+
* Classifier rules whose rewrite is LOCAL, and so can be refined.
|
|
438
|
+
*
|
|
439
|
+
* `git-push-force` is deliberately absent: a push is a rewrite of the remote by
|
|
440
|
+
* construction, whatever this checkout's branch state is.
|
|
441
|
+
*/
|
|
442
|
+
const LOCAL_REWRITE_RULES = [
|
|
443
|
+
/** `git commit --amend`. */
|
|
444
|
+
"git-commit-amend",
|
|
445
|
+
/** `git reset --hard`. */
|
|
446
|
+
"git-reset-hard",
|
|
447
|
+
/** `git rebase` / `filter-branch` / `filter-repo` (the table row's own id). */
|
|
448
|
+
"git-rewrite",
|
|
449
|
+
];
|
|
450
|
+
/** The one rule whose rewritten commit is exactly HEAD. */
|
|
451
|
+
const AMEND_RULE = "git-commit-amend";
|
|
452
|
+
/** The rule name a refined segment reports, in `hook classify` and in tests. */
|
|
453
|
+
const REWRITE_UNPUBLISHED_RULE = "rewrite-unpublished";
|
|
454
|
+
const REWRITE_CLASS = "vcs.history.rewrite";
|
|
455
|
+
const UNPUBLISHED_CLASS = "vcs.commit.branch";
|
|
456
|
+
/**
|
|
457
|
+
* The environment every git child of this verb receives (APRV-205).
|
|
458
|
+
*
|
|
459
|
+
* The hook spawns no granted command — it answers allow or deny and the harness
|
|
460
|
+
* runs the command itself — so nothing here is the task's load-bearing case.
|
|
461
|
+
* These git children are still children of a process holding the session's
|
|
462
|
+
* credentials, and `git rev-parse` has no use for a Telegram token. Built
|
|
463
|
+
* through the one helper so there is one list.
|
|
464
|
+
*/
|
|
465
|
+
function gitEnvironment() {
|
|
466
|
+
return childEnvironment().env;
|
|
467
|
+
}
|
|
468
|
+
/** Trimmed stdout of a successful git command, or `null` for any failure. */
|
|
469
|
+
function gitOutput(cwd, args) {
|
|
470
|
+
const result = spawnSync("git", [...args], { cwd, encoding: "utf8", env: gitEnvironment() });
|
|
471
|
+
if (result.error !== undefined || result.status !== 0)
|
|
472
|
+
return null;
|
|
473
|
+
return result.stdout.trim();
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* `git merge-base --is-ancestor` as three values, not two.
|
|
477
|
+
*
|
|
478
|
+
* Exit 0 is yes and exit 1 is no; every other exit (a missing ref, a broken
|
|
479
|
+
* repository, no git at all) is `null`, which the caller reads as "stay a
|
|
480
|
+
* rewrite" rather than as "no".
|
|
481
|
+
*/
|
|
482
|
+
function isAncestor(cwd, ancestor, descendant) {
|
|
483
|
+
const result = spawnSync("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
|
|
484
|
+
cwd,
|
|
485
|
+
encoding: "utf8",
|
|
486
|
+
env: gitEnvironment(),
|
|
487
|
+
});
|
|
488
|
+
if (result.error !== undefined)
|
|
489
|
+
return null;
|
|
490
|
+
if (result.status === 0)
|
|
491
|
+
return true;
|
|
492
|
+
if (result.status === 1)
|
|
493
|
+
return false;
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Is this the branch a rewrite must never be quiet about?
|
|
498
|
+
*
|
|
499
|
+
* `main` and `master` always count, whatever the remote says, so a local-only
|
|
500
|
+
* repository (and a branch someone named `main` in a scratch checkout) is
|
|
501
|
+
* covered. `refs/remotes/origin/HEAD` adds the remote's own answer when it is
|
|
502
|
+
* set, which is how a repository whose trunk is `develop` or `trunk` is read.
|
|
503
|
+
*/
|
|
504
|
+
function isDefaultBranch(cwd, branch) {
|
|
505
|
+
if (branch === "main" || branch === "master")
|
|
506
|
+
return true;
|
|
507
|
+
const head = gitOutput(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
|
508
|
+
if (head === null || head.length === 0)
|
|
509
|
+
return false;
|
|
510
|
+
return head.replace(/^refs\/remotes\/origin\//u, "") === branch;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Ask git how far the checkout at `cwd` has been published.
|
|
514
|
+
*
|
|
515
|
+
* Every step that cannot be answered returns `shared`, which refines nothing.
|
|
516
|
+
* `for-each-ref` rather than `@{u}` on purpose: `rev-parse @{u}` exits non-zero
|
|
517
|
+
* both when there is no upstream and when the repository cannot be read, and
|
|
518
|
+
* those two must not collapse — one downgrades, the other must not.
|
|
519
|
+
*/
|
|
520
|
+
function rewriteReach(cwd) {
|
|
521
|
+
const branch = gitOutput(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
522
|
+
// No git, not a repository, or a detached HEAD (which prints `HEAD`): a
|
|
523
|
+
// detached rewrite has no branch whose publication could be checked.
|
|
524
|
+
if (branch === null || branch.length === 0 || branch === "HEAD")
|
|
525
|
+
return { kind: "shared" };
|
|
526
|
+
if (isDefaultBranch(cwd, branch))
|
|
527
|
+
return { kind: "shared" };
|
|
528
|
+
// Exits 0 and prints an empty line when the branch tracks nothing, so an
|
|
529
|
+
// empty result is a real answer and a failure is not.
|
|
530
|
+
const upstream = gitOutput(cwd, [
|
|
531
|
+
"for-each-ref",
|
|
532
|
+
"--format=%(upstream:short)",
|
|
533
|
+
`refs/heads/${branch}`,
|
|
534
|
+
]);
|
|
535
|
+
if (upstream === null)
|
|
536
|
+
return { kind: "shared" };
|
|
537
|
+
if (upstream.length === 0)
|
|
538
|
+
return { kind: "no-upstream", branch };
|
|
539
|
+
// An upstream is configured. HEAD reachable from it (or unanswerable, e.g. a
|
|
540
|
+
// tracking ref that was never fetched) stays a rewrite.
|
|
541
|
+
return isAncestor(cwd, "HEAD", upstream) === false
|
|
542
|
+
? { kind: "head-unpushed", branch, upstream }
|
|
543
|
+
: { kind: "shared" };
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Downgrade local rewrites of unpublished history to `vcs.commit.branch`.
|
|
547
|
+
*
|
|
548
|
+
* IMPURE by design and by contract: it runs git in `cwd`. Both callers pass the
|
|
549
|
+
* same directory the hook itself resolves from, so what `hook classify` prints
|
|
550
|
+
* is what `hook claude-code` decides.
|
|
551
|
+
*/
|
|
552
|
+
export function refineRewrite(result, cwd) {
|
|
553
|
+
if (!result.ok)
|
|
554
|
+
return { result, notes: [] };
|
|
555
|
+
const refinable = result.segments.some((segment) => segment.class === REWRITE_CLASS && LOCAL_REWRITE_RULES.includes(segment.rule));
|
|
556
|
+
if (!refinable)
|
|
557
|
+
return { result, notes: [] };
|
|
558
|
+
const reach = rewriteReach(cwd);
|
|
559
|
+
if (reach.kind === "shared")
|
|
560
|
+
return { result, notes: [] };
|
|
561
|
+
const notes = [];
|
|
562
|
+
const segments = result.segments.map((segment) => {
|
|
563
|
+
if (segment.class !== REWRITE_CLASS || !LOCAL_REWRITE_RULES.includes(segment.rule)) {
|
|
564
|
+
return segment;
|
|
565
|
+
}
|
|
566
|
+
// With an upstream, only an amend is narrow enough to be sure: it rewrites
|
|
567
|
+
// HEAD and nothing else. A rebase or reset names a base the text cannot
|
|
568
|
+
// resolve, so it may reach commits that ARE on the upstream.
|
|
569
|
+
if (reach.kind === "head-unpushed" && segment.rule !== AMEND_RULE)
|
|
570
|
+
return segment;
|
|
571
|
+
notes.push(reach.kind === "no-upstream"
|
|
572
|
+
? `${REWRITE_UNPUBLISHED_RULE}: branch ${reach.branch} has no upstream, so \`${segment.text}\` rewrites only unpublished history`
|
|
573
|
+
: `${REWRITE_UNPUBLISHED_RULE}: HEAD is not yet on ${reach.upstream}, so \`${segment.text}\` amends only unpublished history`);
|
|
574
|
+
return { ...segment, class: UNPUBLISHED_CLASS, rule: REWRITE_UNPUBLISHED_RULE };
|
|
575
|
+
});
|
|
576
|
+
if (notes.length === 0)
|
|
577
|
+
return { result, notes };
|
|
578
|
+
const classes = [];
|
|
579
|
+
for (const segment of segments) {
|
|
580
|
+
if (!classes.includes(segment.class))
|
|
581
|
+
classes.push(segment.class);
|
|
582
|
+
}
|
|
583
|
+
return { result: { ok: true, segments, classes }, notes };
|
|
584
|
+
}
|
|
585
|
+
// ===========================================================================
|
|
586
|
+
// Scratch-delete refinement (APRV-267)
|
|
587
|
+
// ===========================================================================
|
|
588
|
+
/*
|
|
589
|
+
* Where the agent's own scratch space is, and whether a delete really stays
|
|
590
|
+
* inside it.
|
|
591
|
+
*
|
|
592
|
+
* The classifier cannot answer either question. It is pure over command text,
|
|
593
|
+
* and "is this path under the scratchpad this process was allotted" is a fact
|
|
594
|
+
* about a machine. So the work splits the way APRV-108's rewrite refinement
|
|
595
|
+
* split: `command-class.ts` compares path segments against roots it is HANDED
|
|
596
|
+
* (`ClassifierContext.scratchRoots`), and everything that needs a disk or an
|
|
597
|
+
* environment lives here, in the impure layer that already runs git.
|
|
598
|
+
*
|
|
599
|
+
* ## What the roots are read from
|
|
600
|
+
*
|
|
601
|
+
* No harness exports the session scratchpad as an environment variable today.
|
|
602
|
+
* Claude Code names it in the system prompt and nowhere else, and this process
|
|
603
|
+
* inherits no `CLAUDE_SCRATCHPAD*` and no `TMPDIR` from it. So the roots are
|
|
604
|
+
* built from what a process CAN observe:
|
|
605
|
+
*
|
|
606
|
+
* - `CLAUDE_SCRATCHPAD_DIR` and `CLAUDE_CODE_SCRATCHPAD_DIR`, read if a
|
|
607
|
+
* harness ever starts exporting them, so that the day it does the rule is
|
|
608
|
+
* already narrow enough to name one session's own directory;
|
|
609
|
+
* - `os.tmpdir()`, which is where every observed scratchpad actually lives
|
|
610
|
+
* (`/private/tmp/claude-501/<project>/<session>/scratchpad` on this Mac);
|
|
611
|
+
* - the fixed platform temp roots `/tmp` and `/var/tmp`, plus `/private/tmp`
|
|
612
|
+
* on macOS, where `/tmp` is a symlink to it.
|
|
613
|
+
*
|
|
614
|
+
* ## Why nothing an agent controls widens the class
|
|
615
|
+
*
|
|
616
|
+
* SPEC.md §11.1: self-reported fields never reduce scrutiny. `os.tmpdir()`
|
|
617
|
+
* reads `TMPDIR`, so a poisoned value could in principle nominate `/` and turn
|
|
618
|
+
* every absolute delete into a scratch delete. Three guards close that, and
|
|
619
|
+
* none of them trusts the value: a root must resolve to a real directory, must
|
|
620
|
+
* clear the depth floor, and must not contain the directory the hook was
|
|
621
|
+
* invoked in. A checkout is never inside its own scratch root.
|
|
622
|
+
*
|
|
623
|
+
* The depth floor is two path segments, so `/` and one-segment directories like
|
|
624
|
+
* `/etc` are out, with the three compiled-in temp roots (`/tmp`, `/private/tmp`,
|
|
625
|
+
* `/var/tmp`) exempt from it because on Linux `os.tmpdir()` IS `/tmp`, a single
|
|
626
|
+
* segment. See {@link scratchRootDepthAccepted} for why that exemption cannot
|
|
627
|
+
* be reached by a poisoned value.
|
|
628
|
+
*
|
|
629
|
+
* ## Why the second pass exists at all
|
|
630
|
+
*
|
|
631
|
+
* A path can be textually under a root and physically somewhere else (a symlink
|
|
632
|
+
* in the middle of it), and a git checkout can live inside the temp root
|
|
633
|
+
* (`/tmp/probe-clone`), where a delete destroys work rather than tidying up.
|
|
634
|
+
* Neither is visible in the argv. So this pass re-reads each target, resolves
|
|
635
|
+
* the nearest ancestor that exists, and TIGHTENS back to
|
|
636
|
+
* `files.delete.out_of_scope` on any doubt: a target it cannot resolve, a
|
|
637
|
+
* resolution that leaves the root, a `.git` at or above the target.
|
|
638
|
+
*/
|
|
639
|
+
/** The class the classifier hands over, and the one this pass falls back to. */
|
|
640
|
+
const OUT_OF_SCOPE_CLASS = "files.delete.out_of_scope";
|
|
641
|
+
/** The classifier rule whose segments this pass re-reads. */
|
|
642
|
+
const SCRATCH_RULE = "rm-scratch";
|
|
643
|
+
/** The rule a tightened segment reports. */
|
|
644
|
+
const SCRATCH_REJECTED_RULE = "rm-scratch-rejected";
|
|
645
|
+
/**
|
|
646
|
+
* Environment variables a HARNESS may use to name the session scratchpad.
|
|
647
|
+
*
|
|
648
|
+
* None is set by any harness this runtime has seen; they are read so the rule
|
|
649
|
+
* narrows the day one starts exporting it, rather than staying pinned to the
|
|
650
|
+
* whole temp root forever. A value that fails any of the guards (absolute, a
|
|
651
|
+
* real directory, deep enough, clear of the cwd) is ignored like any other
|
|
652
|
+
* candidate.
|
|
653
|
+
*/
|
|
654
|
+
const SCRATCHPAD_ENV_NAMES = [
|
|
655
|
+
"CLAUDE_SCRATCHPAD_DIR",
|
|
656
|
+
"CLAUDE_CODE_SCRATCHPAD_DIR",
|
|
657
|
+
];
|
|
658
|
+
/**
|
|
659
|
+
* Fixed temp roots, beyond whatever `os.tmpdir()` reports.
|
|
660
|
+
*
|
|
661
|
+
* These are the well-known system temp directories, and the depth rule below
|
|
662
|
+
* exempts them: they are compiled-in constants, not anything a caller reports.
|
|
663
|
+
*/
|
|
664
|
+
const FIXED_TEMP_ROOTS = ["/tmp", "/private/tmp", "/var/tmp"];
|
|
665
|
+
/** Segments a root must have when it is not one of {@link FIXED_TEMP_ROOTS}. */
|
|
666
|
+
const MIN_ROOT_SEGMENTS = 2;
|
|
667
|
+
/** Non-empty path segments in `path`. */
|
|
668
|
+
function segmentDepth(path) {
|
|
669
|
+
return path.split(sep).filter((segment) => segment.length > 0).length;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Is a candidate deep enough, once resolved, to stand as a scratch root?
|
|
673
|
+
*
|
|
674
|
+
* The depth floor is the anti-poisoning guard (SPEC.md §11.1: self-reported
|
|
675
|
+
* fields never reduce scrutiny). A `TMPDIR` naming `/` resolves and exists, and
|
|
676
|
+
* a root of `/` would turn every absolute delete into a scratch delete, so a
|
|
677
|
+
* resolved root is refused below {@link MIN_ROOT_SEGMENTS}.
|
|
678
|
+
*
|
|
679
|
+
* The well-known system temp roots are the one exception, and they are one on
|
|
680
|
+
* every platform: on Linux `os.tmpdir()` is `/tmp`, a single segment, and
|
|
681
|
+
* refusing it would mean `files.delete.scratch` could never fire there, while
|
|
682
|
+
* on macOS the same directory resolves through the `/tmp` symlink to
|
|
683
|
+
* `/private/tmp` and clears the floor by accident of layout. The exemption is
|
|
684
|
+
* keyed on the RESOLVED value being one of the three compiled-in names, so
|
|
685
|
+
* nothing a caller reports widens it: a poisoned `TMPDIR` still has to resolve
|
|
686
|
+
* to `/tmp`, `/private/tmp` or `/var/tmp` to get in, and those are roots
|
|
687
|
+
* already. `/` is not among them, and every other one-segment directory
|
|
688
|
+
* (`/etc`, `/home`, `/usr`) stays refused.
|
|
689
|
+
*/
|
|
690
|
+
export function scratchRootDepthAccepted(resolved) {
|
|
691
|
+
if (FIXED_TEMP_ROOTS.includes(resolved))
|
|
692
|
+
return true;
|
|
693
|
+
return segmentDepth(resolved) >= MIN_ROOT_SEGMENTS;
|
|
694
|
+
}
|
|
695
|
+
/** `realpathSync`, or `null` for anything that does not resolve. */
|
|
696
|
+
function resolvedPath(candidate) {
|
|
697
|
+
try {
|
|
698
|
+
return realpathSync(candidate);
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/** Is `candidate` a strict descendant of `root`, by path segment? */
|
|
705
|
+
function isBelow(candidate, root) {
|
|
706
|
+
const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
|
|
707
|
+
return candidate.startsWith(prefix) && candidate.length > prefix.length;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* The scratch roots this process may vouch for, resolved and guarded.
|
|
711
|
+
*
|
|
712
|
+
* `cwd` is the directory the hook itself resolved from; a candidate containing
|
|
713
|
+
* it is discarded, because a root that swallowed the checkout would make every
|
|
714
|
+
* delete in the repository a scratch delete.
|
|
715
|
+
*/
|
|
716
|
+
export function resolveScratchRoots(cwd, env = process.env) {
|
|
717
|
+
const resolvedCwd = resolvedPath(cwd) ?? cwd;
|
|
718
|
+
const candidates = [];
|
|
719
|
+
for (const name of SCRATCHPAD_ENV_NAMES) {
|
|
720
|
+
const value = env[name];
|
|
721
|
+
if (typeof value === "string" && value.length > 0)
|
|
722
|
+
candidates.push(value);
|
|
723
|
+
}
|
|
724
|
+
candidates.push(tmpdir(), ...FIXED_TEMP_ROOTS);
|
|
725
|
+
const roots = [];
|
|
726
|
+
for (const candidate of candidates) {
|
|
727
|
+
if (!isAbsolute(candidate))
|
|
728
|
+
continue;
|
|
729
|
+
const resolved = resolvedPath(candidate);
|
|
730
|
+
if (resolved === null)
|
|
731
|
+
continue;
|
|
732
|
+
if (!scratchRootDepthAccepted(resolved))
|
|
733
|
+
continue;
|
|
734
|
+
if (resolved === resolvedCwd || isBelow(resolvedCwd, resolved))
|
|
735
|
+
continue;
|
|
736
|
+
if (!roots.includes(resolved))
|
|
737
|
+
roots.push(resolved);
|
|
738
|
+
}
|
|
739
|
+
return roots;
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Is there a `.git` at `target` or above it, stopping at `root`?
|
|
743
|
+
*
|
|
744
|
+
* `root` itself is checked too: a checkout whose top IS a scratch root would
|
|
745
|
+
* otherwise hide from the walk. Any filesystem error answers `true`, because an
|
|
746
|
+
* unreadable directory is not one this pass may vouch for.
|
|
747
|
+
*/
|
|
748
|
+
function insideCheckout(target, root) {
|
|
749
|
+
let at = target;
|
|
750
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
751
|
+
try {
|
|
752
|
+
if (existsSync(join(at, ".git")))
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
if (at === root)
|
|
759
|
+
return false;
|
|
760
|
+
const up = dirname(at);
|
|
761
|
+
if (up === at)
|
|
762
|
+
return true;
|
|
763
|
+
at = up;
|
|
764
|
+
}
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Does this one target survive the physical checks?
|
|
769
|
+
*
|
|
770
|
+
* The target itself may or may not exist, so the nearest EXISTING ancestor is
|
|
771
|
+
* resolved and the unresolved tail re-appended. A symlink anywhere in that
|
|
772
|
+
* ancestor chain therefore cannot smuggle the path out of the root, which is
|
|
773
|
+
* the escape the pure half cannot see.
|
|
774
|
+
*/
|
|
775
|
+
function targetStaysInScratch(target, roots) {
|
|
776
|
+
let existing = target;
|
|
777
|
+
const tail = [];
|
|
778
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
779
|
+
if (existsSync(existing))
|
|
780
|
+
break;
|
|
781
|
+
const up = dirname(existing);
|
|
782
|
+
if (up === existing)
|
|
783
|
+
return false;
|
|
784
|
+
tail.unshift(basename(existing));
|
|
785
|
+
existing = up;
|
|
786
|
+
}
|
|
787
|
+
const resolved = resolvedPath(existing);
|
|
788
|
+
if (resolved === null)
|
|
789
|
+
return false;
|
|
790
|
+
const full = tail.length === 0 ? resolved : join(resolved, ...tail);
|
|
791
|
+
const root = roots.find((candidate) => isBelow(full, candidate));
|
|
792
|
+
if (root === undefined)
|
|
793
|
+
return false;
|
|
794
|
+
return !insideCheckout(full, root);
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Tighten `files.delete.scratch` back to `files.delete.out_of_scope` wherever
|
|
798
|
+
* the disk disagrees with the text.
|
|
799
|
+
*
|
|
800
|
+
* IMPURE by design and by contract, exactly as {@link refineRewrite} is: it
|
|
801
|
+
* stats paths. It only ever moves a segment toward the stricter class, so a
|
|
802
|
+
* caller that skipped it would never be MORE permissive than one that runs it,
|
|
803
|
+
* which is what lets `hook classify` and `hook claude-code` share it without
|
|
804
|
+
* either becoming the authority.
|
|
805
|
+
*/
|
|
806
|
+
export function refineScratchDelete(result, roots) {
|
|
807
|
+
if (!result.ok)
|
|
808
|
+
return { result, notes: [] };
|
|
809
|
+
if (!result.segments.some((segment) => segment.rule === SCRATCH_RULE)) {
|
|
810
|
+
return { result, notes: [] };
|
|
811
|
+
}
|
|
812
|
+
const notes = [];
|
|
813
|
+
const segments = result.segments.map((segment) => {
|
|
814
|
+
if (segment.rule !== SCRATCH_RULE)
|
|
815
|
+
return segment;
|
|
816
|
+
const reject = (detail) => {
|
|
817
|
+
notes.push(`${SCRATCH_REJECTED_RULE}: ${detail}`);
|
|
818
|
+
return { ...segment, class: OUT_OF_SCOPE_CLASS, rule: SCRATCH_REJECTED_RULE };
|
|
819
|
+
};
|
|
820
|
+
const words = commandSegmentWords(segment.text);
|
|
821
|
+
const parsed = words === null ? undefined : words[0];
|
|
822
|
+
// The classifier read this segment a moment ago, so a parse that disagrees
|
|
823
|
+
// here is two reads of the same bytes disagreeing. Fail closed.
|
|
824
|
+
if (parsed === undefined) {
|
|
825
|
+
return reject(`\`${segment.text}\` could not be re-read, so it stays ${OUT_OF_SCOPE_CLASS}`);
|
|
826
|
+
}
|
|
827
|
+
const targets = parsed.args.filter((arg) => !arg.startsWith("-") || arg === "-");
|
|
828
|
+
if (targets.length === 0) {
|
|
829
|
+
return reject(`\`${segment.text}\` names no target, so it stays ${OUT_OF_SCOPE_CLASS}`);
|
|
830
|
+
}
|
|
831
|
+
const escaped = targets.find((target) => !targetStaysInScratch(target, roots));
|
|
832
|
+
if (escaped === undefined)
|
|
833
|
+
return segment;
|
|
834
|
+
return reject(`${escaped} does not resolve to a path inside a scratch root clear of any git checkout, so \`${segment.text}\` stays ${OUT_OF_SCOPE_CLASS}`);
|
|
835
|
+
});
|
|
836
|
+
if (notes.length === 0)
|
|
837
|
+
return { result, notes };
|
|
838
|
+
const classes = [];
|
|
839
|
+
for (const segment of segments) {
|
|
840
|
+
if (!classes.includes(segment.class))
|
|
841
|
+
classes.push(segment.class);
|
|
842
|
+
}
|
|
843
|
+
return { result: { ok: true, segments, classes }, notes };
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* The classifier, its scratch context, and both impure refinements, in the one
|
|
847
|
+
* order every caller must use.
|
|
848
|
+
*
|
|
849
|
+
* `hook classify` printing a different class from the one `hook claude-code`
|
|
850
|
+
* decides would make the explainer a different program (APRV-108's note), and
|
|
851
|
+
* that stays true now there are two refinements in the chain.
|
|
852
|
+
*/
|
|
853
|
+
export function classifyForHook(command, protectedPaths, cwd) {
|
|
854
|
+
const roots = resolveScratchRoots(cwd);
|
|
855
|
+
const classified = classifyCommand(command, protectedPaths, { scratchRoots: roots });
|
|
856
|
+
const rewritten = refineRewrite(classified, cwd);
|
|
857
|
+
const scratched = refineScratchDelete(rewritten.result, roots);
|
|
858
|
+
return {
|
|
859
|
+
result: scratched.result,
|
|
860
|
+
notes: [...rewritten.notes, ...scratched.notes],
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
// ===========================================================================
|
|
864
|
+
// hook classify
|
|
865
|
+
// ===========================================================================
|
|
866
|
+
/**
|
|
867
|
+
* What the classifier made of a command (APRV-91 #9).
|
|
868
|
+
*
|
|
869
|
+
* Human output is an aligned three-column table under a `key` header row; the
|
|
870
|
+
* command text and the rule name are copyable and stay undressed. `--json`
|
|
871
|
+
* emits the classification object unchanged, and asks for the style FIRST so
|
|
872
|
+
* that the `json` veto on colour is the answer this process memoizes.
|
|
873
|
+
*/
|
|
874
|
+
export function renderClassification(result, json, st = style({ json })) {
|
|
875
|
+
if (json)
|
|
876
|
+
return `${JSON.stringify(result)}\n`;
|
|
877
|
+
if (!result.ok) {
|
|
878
|
+
// APRV-102: the shared refusal shape rather than a second copy of it. The
|
|
879
|
+
// segment is a copyable value on its own line, which is what `refusal`'s
|
|
880
|
+
// optional second line is for.
|
|
881
|
+
return `${renderRefusal(st, result.code, result.detail)}\n ${st.key("segment:")} ${result.segment}\n`;
|
|
882
|
+
}
|
|
883
|
+
const rows = result.segments.map((segment) => [segment.class, segment.rule, segment.text]);
|
|
884
|
+
return `${table(st, rows, { header: ["class", "rule", "command"] })}\n\n${st.key("classes:")} ${result.classes.join(", ")}\n`;
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* `approval hook classify <command…>` — what the classifier makes of a command.
|
|
888
|
+
*
|
|
889
|
+
* Everything after `--` is the command verbatim, which is how a command with
|
|
890
|
+
* its own flags is passed without this parser claiming them.
|
|
891
|
+
*
|
|
892
|
+
* It reads the policy for the same reason `hook claude-code` does (APRV-107):
|
|
893
|
+
* `policy.protected_paths` widens the protected surface, and an explainer
|
|
894
|
+
* that answered from the built-ins alone would tell an agent a gated file is
|
|
895
|
+
* ungated. `--dir` / `--policy` scope it exactly as they scope the hook. This
|
|
896
|
+
* verb decides nothing and writes nothing, so an unreadable policy is not a
|
|
897
|
+
* refusal here: it classifies against the built-ins and says on stderr that the
|
|
898
|
+
* answer is the narrow one.
|
|
899
|
+
*/
|
|
900
|
+
function commandClassify(argv, streams, cwd) {
|
|
901
|
+
const separator = argv.indexOf("--");
|
|
902
|
+
const head = separator === -1 ? argv : argv.slice(0, separator);
|
|
903
|
+
const tail = separator === -1 ? [] : argv.slice(separator + 1);
|
|
904
|
+
const parsed = parseFlags(head, { ...COMMON_FLAGS, ...POLICY_FLAGS, "--json": "boolean" });
|
|
905
|
+
if (!parsed.ok) {
|
|
906
|
+
return usageError(streams, `${parsed.message}; flags belonging to the command being classified must follow \`--\``);
|
|
907
|
+
}
|
|
908
|
+
if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
|
|
909
|
+
streams.out(`${HOOK_HELP}\n`);
|
|
910
|
+
return EXIT_OK;
|
|
911
|
+
}
|
|
912
|
+
const command = [...parsed.positionals, ...tail].join(" ").trim();
|
|
913
|
+
if (command.length === 0) {
|
|
914
|
+
return usageError(streams, "missing <command> argument for `approval hook classify`");
|
|
915
|
+
}
|
|
916
|
+
const { options } = hookScope(parsed.flags, cwd);
|
|
917
|
+
const load = loadPolicy(options.policy?.file === undefined
|
|
918
|
+
? { dir: options.policy?.dir ?? cwd }
|
|
919
|
+
: { file: options.policy.file });
|
|
920
|
+
if (!load.ok) {
|
|
921
|
+
streams.err(`note: no policy read (${load.code}: ${load.message}); classifying against the built-in protected paths only\n`);
|
|
922
|
+
}
|
|
923
|
+
const protectedPaths = load.ok ? (load.policy.protected_paths ?? []) : [];
|
|
924
|
+
// The same impure refinements `hook claude-code` applies (APRV-108,
|
|
925
|
+
// APRV-267), run against the same directory: an explainer that printed the
|
|
926
|
+
// pure class where the hook decides a refined one would be explaining a
|
|
927
|
+
// different program.
|
|
928
|
+
streams.out(renderClassification(classifyForHook(command, protectedPaths, cwd).result, boolFlag(parsed.flags, "--json")));
|
|
929
|
+
return EXIT_OK;
|
|
930
|
+
}
|
|
931
|
+
function sleepSync(ms) {
|
|
932
|
+
if (ms <= 0)
|
|
933
|
+
return;
|
|
934
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
935
|
+
}
|
|
936
|
+
function truncate(text, limit) {
|
|
937
|
+
const collapsed = text.replace(/\s+/gu, " ").trim();
|
|
938
|
+
return collapsed.length <= limit ? collapsed : `${collapsed.slice(0, limit - 1)}…`;
|
|
939
|
+
}
|
|
940
|
+
// ===========================================================================
|
|
941
|
+
// File tools: the change, and which checkout it lands in (APRV-124)
|
|
942
|
+
// ===========================================================================
|
|
943
|
+
/**
|
|
944
|
+
* The rule a protected-path file touch reports, on the SAME class.
|
|
945
|
+
*
|
|
946
|
+
* Three tiers, and the class is whichever protected class the path selects
|
|
947
|
+
* (APRV-198: `policy.edit`, `policy.core` or `log.mutate`); the tier never
|
|
948
|
+
* changes it. A protected touch inside an agent worktree is a branch
|
|
949
|
+
* PROPOSAL: the file it writes is a copy on a branch, and the merge that makes
|
|
950
|
+
* it real is separately gated (`vcs.push.main`, `gh pr merge`). The same touch
|
|
951
|
+
* in the live checkout is the file itself. A protected name that resolves
|
|
952
|
+
* OUTSIDE the gated checkout altogether (a scratchpad `APPROVAL.md`, a demo
|
|
953
|
+
* fixture) is neither: the match is on the name, and the name is all it shares
|
|
954
|
+
* with the live policy (APRV-161). The approver was being told the same thing
|
|
955
|
+
* about all three, which is the "truthful label" half of this task.
|
|
956
|
+
*
|
|
957
|
+
* The distinction is deliberately NOT a class and NOT an autonomy: policy
|
|
958
|
+
* semantics are untouched here, every tier resolves exactly as the path's own
|
|
959
|
+
* protected class resolves, and APRV-127 is where sampling may hang off it.
|
|
960
|
+
* What changes is what the human reads.
|
|
961
|
+
*/
|
|
962
|
+
const PROTECTED_PATH_RULE = "protected-path";
|
|
963
|
+
const PROTECTED_PATH_PROPOSAL_RULE = "protected-path-proposal";
|
|
964
|
+
const PROTECTED_NAME_ELSEWHERE_RULE = "protected-name-elsewhere";
|
|
965
|
+
/** Where agent worktrees live, relative to the primary root. */
|
|
966
|
+
const WORKTREE_DIR = [".claude", "worktrees"];
|
|
967
|
+
/** `realpathSync`, as a value. */
|
|
968
|
+
function realOrNull(path) {
|
|
969
|
+
try {
|
|
970
|
+
return realpathSync(path);
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* `path` with every existing ancestor resolved through its symlinks.
|
|
978
|
+
*
|
|
979
|
+
* A `Write` names a file that need not exist yet, and a comparison of an
|
|
980
|
+
* unresolved path against a resolved root answers "no" for the wrong reason on
|
|
981
|
+
* any machine where the checkout sits under a symlink (`/tmp` on macOS, every
|
|
982
|
+
* home directory behind an automounter). So the deepest existing ancestor is
|
|
983
|
+
* resolved and the remainder is joined back on.
|
|
984
|
+
*/
|
|
985
|
+
function resolveExisting(path) {
|
|
986
|
+
let current = path;
|
|
987
|
+
const tail = [];
|
|
988
|
+
for (;;) {
|
|
989
|
+
const real = realOrNull(current);
|
|
990
|
+
if (real !== null)
|
|
991
|
+
return tail.length === 0 ? real : join(real, ...tail);
|
|
992
|
+
const parent = dirname(current);
|
|
993
|
+
if (parent === current)
|
|
994
|
+
return null;
|
|
995
|
+
tail.unshift(basename(current));
|
|
996
|
+
current = parent;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* The tier `target` sits in, resolved once, from the hook's own directory.
|
|
1001
|
+
*
|
|
1002
|
+
* FAIL CLOSED, on every axis: anything not *provably* inside
|
|
1003
|
+
* `<primary>/.claude/worktrees/<name>/…` and not *provably* outside the primary
|
|
1004
|
+
* root is live-tier. A wrong "proposal" tells a human their APPROVAL.md edit is
|
|
1005
|
+
* a branch copy when it is the live file; a wrong "elsewhere" tells them a
|
|
1006
|
+
* scratch file is being edited when the live policy is; a wrong "live" costs
|
|
1007
|
+
* nothing but a sterner sentence.
|
|
1008
|
+
*
|
|
1009
|
+
* Order matters. The proposal test runs first and against the worktrees
|
|
1010
|
+
* directory's own resolved path, so a worktrees directory reached through a
|
|
1011
|
+
* symlink stays a proposal rather than falling out of the root comparison.
|
|
1012
|
+
*
|
|
1013
|
+
* The primary root comes from `primaryRoot`, i.e. from git run in the hook's
|
|
1014
|
+
* OWN process directory (APRV-108's discipline). The harness-supplied `cwd`
|
|
1015
|
+
* field is never consulted: it is authored by the party under oversight, and a
|
|
1016
|
+
* tier that could be chosen by the subject of the gate is not a tier.
|
|
1017
|
+
*/
|
|
1018
|
+
function tierOf(target, cwd) {
|
|
1019
|
+
const live = (root) => ({
|
|
1020
|
+
rule: PROTECTED_PATH_RULE,
|
|
1021
|
+
worktree: null,
|
|
1022
|
+
root,
|
|
1023
|
+
});
|
|
1024
|
+
const root = primaryRoot(cwd);
|
|
1025
|
+
if (root === null)
|
|
1026
|
+
return live(null);
|
|
1027
|
+
const file = resolveExisting(target);
|
|
1028
|
+
if (file === null)
|
|
1029
|
+
return live(root);
|
|
1030
|
+
const base = resolveExisting(join(root, ...WORKTREE_DIR));
|
|
1031
|
+
if (base !== null && file.startsWith(`${base}${sep}`)) {
|
|
1032
|
+
const rest = file.slice(base.length + 1).split(sep);
|
|
1033
|
+
// `rest[0]` is the worktree; a target that IS the worktrees directory or a
|
|
1034
|
+
// worktree root names no file inside one and stays live-tier.
|
|
1035
|
+
const name = rest[0];
|
|
1036
|
+
if (name !== undefined && name.length > 0 && rest.length >= 2) {
|
|
1037
|
+
return { rule: PROTECTED_PATH_PROPOSAL_RULE, worktree: name, root };
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
const realRoot = resolveExisting(root);
|
|
1041
|
+
if (realRoot === null)
|
|
1042
|
+
return live(root);
|
|
1043
|
+
if (file === realRoot || file.startsWith(`${realRoot}${sep}`))
|
|
1044
|
+
return live(realRoot);
|
|
1045
|
+
return { rule: PROTECTED_NAME_ELSEWHERE_RULE, worktree: null, root: realRoot };
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* The class an ordinary file edit is (APRV-303).
|
|
1049
|
+
*
|
|
1050
|
+
* The same string `core/command-class.ts` gives a shell redirect into the
|
|
1051
|
+
* workspace, and spelled here because the file tools reach the same class by a
|
|
1052
|
+
* different road. Until APRV-303 the file path produced no class at all for a
|
|
1053
|
+
* non-protected target, which is why the loop floor could not see an Edit.
|
|
1054
|
+
*/
|
|
1055
|
+
const WORKSPACE_WRITE_CLASS = "files.write.workspace";
|
|
1056
|
+
/**
|
|
1057
|
+
* What a non-Bash tool call asks for, or `null` when it names no file at all.
|
|
1058
|
+
*
|
|
1059
|
+
* Every file edit is a gate question. Protected targets take their derived
|
|
1060
|
+
* protected class; every other target is `files.write.workspace`. The policy
|
|
1061
|
+
* decides the autonomy of either class, so changing the ordinary-file rule to
|
|
1062
|
+
* manual, supervised or human-only changes the hook verdict too (APRV-304).
|
|
1063
|
+
*
|
|
1064
|
+
* ## The ordinary edit gets a class and follows it (APRV-303, APRV-304)
|
|
1065
|
+
*
|
|
1066
|
+
* It used to get none: an unprotected target returned `null` here, and
|
|
1067
|
+
* `describeToolCall` answered `allow` from a branch that sits ABOVE the loop
|
|
1068
|
+
* floor, above `recordUnattended`, and above everything that appends. So a
|
|
1069
|
+
* session three failed writes deep had its Bash calls routed to a human and its
|
|
1070
|
+
* Edit calls waved through, which is the disagreement APRV-303 was filed on:
|
|
1071
|
+
* eight edits to the same file, under a standing floor, none of them routed and
|
|
1072
|
+
* none of them counted.
|
|
1073
|
+
*
|
|
1074
|
+
* APRV-303 made the floor predicate one predicate over one class for every tool
|
|
1075
|
+
* kind. APRV-304 carries the same class through the rest of the shared path:
|
|
1076
|
+
* policy resolution, human-only refusal, budgets, registration and execution
|
|
1077
|
+
* accounting. An open window records the bypass before allowing the edit.
|
|
1078
|
+
*
|
|
1079
|
+
* ## The payload is the change (APRV-124)
|
|
1080
|
+
*
|
|
1081
|
+
* It used to be `{command: "Edit <path>", cwd}` — the *touch*. A human reading
|
|
1082
|
+
* that was asked to approve "an edit to CI config", with no way to tell a typo
|
|
1083
|
+
* fix from a disabled test job; the observed complaint (2026-08-20) is exactly
|
|
1084
|
+
* "I don't know what the actual CI edit is". The PreToolUse event carries the
|
|
1085
|
+
* whole change, so the payload does too:
|
|
1086
|
+
*
|
|
1087
|
+
* - `Edit` → `{tool, rule, file, before, after}` (plus `replace_all` when the
|
|
1088
|
+
* call sets it, because "replace every occurrence" is part of what is being
|
|
1089
|
+
* approved and two calls differing only in it are two different questions);
|
|
1090
|
+
* - `Write` → `{tool, rule, file, content}`;
|
|
1091
|
+
* - every other file tool → the same head plus its `tool_input` verbatim under
|
|
1092
|
+
* `input`, which renders as JSON rather than as a diff but hides nothing.
|
|
1093
|
+
*
|
|
1094
|
+
* Those bytes are what `payload_hash` binds, so the grant binds to the edit.
|
|
1095
|
+
* They are also what APRV-117's carryover keys on: an identical retry of the
|
|
1096
|
+
* identical edit hashes identically and adopts or carries the same question, a
|
|
1097
|
+
* changed edit is a new question, and a proposal-tier grant cannot be spent on
|
|
1098
|
+
* the live file because the absolute `file` differs.
|
|
1099
|
+
*
|
|
1100
|
+
* `description` is dropped on the way in: it is the agent's account of its own
|
|
1101
|
+
* intent, and it has no business in the bytes a human is bound to.
|
|
1102
|
+
*/
|
|
1103
|
+
function fileToolGate(toolName, toolInput, protectedPaths, cwd) {
|
|
1104
|
+
const declared = readString(toolInput, "file_path") ??
|
|
1105
|
+
readString(toolInput, "notebook_path") ??
|
|
1106
|
+
readString(toolInput, "path");
|
|
1107
|
+
if (declared === null)
|
|
1108
|
+
return null;
|
|
1109
|
+
// The SAME split the shell classifier applies (APRV-198): a file tool aimed
|
|
1110
|
+
// at APPROVAL.md or the approval home is `policy.core`, one aimed at
|
|
1111
|
+
// `.approval/log/` is `log.mutate`, and only the prose-and-configuration
|
|
1112
|
+
// surface stays `policy.edit`. Editing through the Edit tool must not be a
|
|
1113
|
+
// cheaper way to touch the gate than editing through a shell redirect.
|
|
1114
|
+
const surface = protectedPathClass(declared, protectedPaths);
|
|
1115
|
+
const file = absolute(declared, cwd);
|
|
1116
|
+
const tier = tierOf(file, cwd);
|
|
1117
|
+
const rule = tier.rule;
|
|
1118
|
+
const head = { tool: toolName, rule, file };
|
|
1119
|
+
const before = toolInput["old_string"];
|
|
1120
|
+
const after = toolInput["new_string"];
|
|
1121
|
+
const content = toolInput["content"] ?? toolInput["contents"];
|
|
1122
|
+
const replaceAll = toolInput["replace_all"];
|
|
1123
|
+
let payload;
|
|
1124
|
+
if (typeof before === "string" && typeof after === "string") {
|
|
1125
|
+
payload =
|
|
1126
|
+
typeof replaceAll === "boolean"
|
|
1127
|
+
? { ...head, replace_all: replaceAll, before, after }
|
|
1128
|
+
: { ...head, before, after };
|
|
1129
|
+
}
|
|
1130
|
+
else if (typeof content === "string") {
|
|
1131
|
+
payload = { ...head, content };
|
|
1132
|
+
}
|
|
1133
|
+
else {
|
|
1134
|
+
const input = { ...toolInput };
|
|
1135
|
+
delete input["description"];
|
|
1136
|
+
payload = { ...head, input };
|
|
1137
|
+
}
|
|
1138
|
+
return {
|
|
1139
|
+
cls: surface ?? WORKSPACE_WRITE_CLASS,
|
|
1140
|
+
protectedPath: surface !== null,
|
|
1141
|
+
rule,
|
|
1142
|
+
file,
|
|
1143
|
+
worktree: tier.worktree,
|
|
1144
|
+
root: tier.root,
|
|
1145
|
+
payload,
|
|
1146
|
+
// The tier leads the headline rather than trailing it: a summary is
|
|
1147
|
+
// truncated from the right, and the qualifier is the last thing that may
|
|
1148
|
+
// be ellipsized away (a long path is not — the payload carries it whole).
|
|
1149
|
+
summary: summaryFor(tier, toolName, file),
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
/** The headline for a tier: the qualifier first, the touch after it. */
|
|
1153
|
+
function summaryFor(tier, toolName, file) {
|
|
1154
|
+
if (tier.worktree !== null) {
|
|
1155
|
+
return `branch proposal (worktree ${tier.worktree}): ${toolName} ${file}`;
|
|
1156
|
+
}
|
|
1157
|
+
if (tier.rule === PROTECTED_NAME_ELSEWHERE_RULE) {
|
|
1158
|
+
return `file named like a policy file, outside this gated checkout: ${toolName} ${file}`;
|
|
1159
|
+
}
|
|
1160
|
+
return `${toolName} ${file}`;
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* The tier, in the verdict's note, so an `allow` says what it authorized.
|
|
1164
|
+
*
|
|
1165
|
+
* The elsewhere arm names the root it was decided against, because "outside the
|
|
1166
|
+
* gated checkout" is only readable next to which checkout that is.
|
|
1167
|
+
*/
|
|
1168
|
+
function fileTierNote(gated) {
|
|
1169
|
+
if (gated.worktree !== null) {
|
|
1170
|
+
return `${gated.rule}: ${gated.file} is inside agent worktree ${gated.worktree}, so this is a branch proposal and the merge to the live checkout is gated separately`;
|
|
1171
|
+
}
|
|
1172
|
+
if (gated.rule === PROTECTED_NAME_ELSEWHERE_RULE) {
|
|
1173
|
+
return `${gated.rule}: ${gated.file} is NAMED like a policy file but sits outside the gated checkout ${gated.root ?? "(unresolved)"}, so it is not this repository's live policy; it is gated because a protected name is protected wherever it sits`;
|
|
1174
|
+
}
|
|
1175
|
+
return `${gated.rule}: ${gated.file} is the LIVE checkout's copy`;
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* The provenance pair to stamp on a record this invocation is about to write,
|
|
1179
|
+
* or `null` when this process cannot establish one (APRV-227).
|
|
1180
|
+
*
|
|
1181
|
+
* Called at the write site and not before. `installedHarnessVersion` memoizes,
|
|
1182
|
+
* so a multi-class command that registers once and a session that reaches this
|
|
1183
|
+
* twice both pay at most one probe per process.
|
|
1184
|
+
*/
|
|
1185
|
+
function registrationProvenance(run) {
|
|
1186
|
+
return harnessProvenance(run.harness, run.eventVersion);
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Withdraw every still-pending key this invocation OPENED (APRV-106, narrowed
|
|
1190
|
+
* by APRV-117).
|
|
1191
|
+
*
|
|
1192
|
+
* BEST EFFORT, always. The caller has already decided what verdict it is
|
|
1193
|
+
* printing; this only decides whether a human is still going to be asked about
|
|
1194
|
+
* it. A withdrawal that refuses is reported on stderr and changes nothing —
|
|
1195
|
+
* including the case that matters most, `already-decided`, which means a human
|
|
1196
|
+
* answered while this was running and their answer must not be touched.
|
|
1197
|
+
*
|
|
1198
|
+
* Two things narrowed under APRV-117, and both are load-bearing.
|
|
1199
|
+
*
|
|
1200
|
+
* **The timeout no longer calls this immediately.** A request keyed by payload
|
|
1201
|
+
* hash can be adopted by the retry, so an answer that lands after this process
|
|
1202
|
+
* gave up still authorizes something; retracting it at once would be throwing
|
|
1203
|
+
* away the very decision the human is about to make. What still calls this is
|
|
1204
|
+
* every path where nothing will retry: a signal, a thrown failure, an intake
|
|
1205
|
+
* refusal that dooms the whole command, and — since APRV-287 — a wait whose
|
|
1206
|
+
* retry grace has run out (see {@link withdrawAbandoned}).
|
|
1207
|
+
*
|
|
1208
|
+
* **Only keys this invocation opened.** An ADOPTED key was requested by another
|
|
1209
|
+
* process, and `withdraw` is requester-only by design (APRV-106 rule 1): taking
|
|
1210
|
+
* back a question somebody else asked is exactly the queue-clearing the gate
|
|
1211
|
+
* refuses. So adopted keys are never passed here.
|
|
1212
|
+
*
|
|
1213
|
+
* Returns the keys actually withdrawn, for the deny reason.
|
|
1214
|
+
*/
|
|
1215
|
+
function withdrawPending(run, streams, keys, why, reason = "cancelled") {
|
|
1216
|
+
const withdrawn = [];
|
|
1217
|
+
for (const key of keys) {
|
|
1218
|
+
const result = withdraw(run.logPath, key, run.actor, {
|
|
1219
|
+
...run.options,
|
|
1220
|
+
reason,
|
|
1221
|
+
note: why,
|
|
1222
|
+
});
|
|
1223
|
+
if (result.ok) {
|
|
1224
|
+
withdrawn.push(key);
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
if (result.code === "already-decided" || result.code === "request-withdrawn")
|
|
1228
|
+
continue;
|
|
1229
|
+
streams.err(`approval: the hook could not withdraw ${key} (${result.code}): ${result.message}\n`);
|
|
1230
|
+
}
|
|
1231
|
+
return withdrawn;
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Pending harness requests this actor opened that nothing will ever adopt
|
|
1235
|
+
* (APRV-287).
|
|
1236
|
+
*
|
|
1237
|
+
* ## The state this names
|
|
1238
|
+
*
|
|
1239
|
+
* A wait that expires leaves its question open, because a decision inside the
|
|
1240
|
+
* policy's TTL still authorizes an identical retry (APRV-117). That is right
|
|
1241
|
+
* for as long as a retry is plausible and wrong afterwards: on 2026-09-06 three
|
|
1242
|
+
* waits expired behind a dead daemon, nothing retried them, and the requests sat
|
|
1243
|
+
* live until the TTL — so the daemon's restart re-delivered a dozen dead
|
|
1244
|
+
* questions to a phone, one message each. The grace window
|
|
1245
|
+
* (`core/harness-wait.ts`) is where the two readings meet: inside it the
|
|
1246
|
+
* question is live for the retry, past it the asker is gone.
|
|
1247
|
+
*
|
|
1248
|
+
* ## What it will not name
|
|
1249
|
+
*
|
|
1250
|
+
* - **A request another actor opened.** `withdraw` is requester-only by design
|
|
1251
|
+
* (APRV-106 rule 1), so the filter is the same fact stated before the call:
|
|
1252
|
+
* taking back somebody else's question is the queue-clearing the gate
|
|
1253
|
+
* refuses.
|
|
1254
|
+
* - **The bytes this invocation is asking about.** `keepHash` is this
|
|
1255
|
+
* invocation's payload hash, and a request carrying it is the question this
|
|
1256
|
+
* process is adopting or waiting on. Sweeping it would be a hook withdrawing
|
|
1257
|
+
* its own live question.
|
|
1258
|
+
* - **Anything but a live `approval.requested`.** The state is derived through
|
|
1259
|
+
* `requestState` from the verified records the caller already read, so a
|
|
1260
|
+
* decided, expired or already withdrawn request is never touched.
|
|
1261
|
+
* - **A request younger than the wait plus the grace**, measured from the
|
|
1262
|
+
* `approval.requested` record's own runtime-assigned timestamp.
|
|
1263
|
+
*
|
|
1264
|
+
* Nothing here appends: the caller decides what to do with the list, and the
|
|
1265
|
+
* append happens through {@link withdrawPending} like every other withdrawal on
|
|
1266
|
+
* this surface.
|
|
1267
|
+
*/
|
|
1268
|
+
function abandonedRequests(run, records, now, keepHash) {
|
|
1269
|
+
const nowMs = Date.parse(now);
|
|
1270
|
+
if (Number.isNaN(nowMs))
|
|
1271
|
+
return [];
|
|
1272
|
+
const limit = abandonedAfterMs(run.timeoutMs, run.graceMs);
|
|
1273
|
+
const found = new Map();
|
|
1274
|
+
for (const record of records) {
|
|
1275
|
+
if (record.event !== "approval.requested")
|
|
1276
|
+
continue;
|
|
1277
|
+
if (record.actor !== run.actor)
|
|
1278
|
+
continue;
|
|
1279
|
+
const key = record.action_key;
|
|
1280
|
+
if (typeof key !== "string" || key.length === 0)
|
|
1281
|
+
continue;
|
|
1282
|
+
const payload = payloadOf(record);
|
|
1283
|
+
if (payload["execution"] !== "harness")
|
|
1284
|
+
continue;
|
|
1285
|
+
if (keepHash !== null && payload["payload_hash"] === keepHash)
|
|
1286
|
+
continue;
|
|
1287
|
+
const at = Date.parse(record.ts);
|
|
1288
|
+
if (Number.isNaN(at) || nowMs - at < limit)
|
|
1289
|
+
continue;
|
|
1290
|
+
if (requestState(records, key, now, run.ttlMs).state !== "requested")
|
|
1291
|
+
continue;
|
|
1292
|
+
const cls = payload["class"];
|
|
1293
|
+
found.set(key, {
|
|
1294
|
+
actionKey: key,
|
|
1295
|
+
cls: typeof cls === "string" ? cls : "(no class)",
|
|
1296
|
+
ageMs: nowMs - at,
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
return [...found.values()];
|
|
1300
|
+
}
|
|
1301
|
+
/** Minutes, for a sentence a human reads. */
|
|
1302
|
+
function minutesText(ms) {
|
|
1303
|
+
const minutes = Math.round(ms / 60_000);
|
|
1304
|
+
if (minutes >= 1)
|
|
1305
|
+
return `${String(minutes)}m`;
|
|
1306
|
+
return `${String(Math.max(1, Math.round(ms / 1000)))}s`;
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Take back every question this actor opened that the grace window has run out
|
|
1310
|
+
* on (APRV-287).
|
|
1311
|
+
*
|
|
1312
|
+
* Best effort, exactly as {@link withdrawPending} is: a withdrawal that refuses
|
|
1313
|
+
* changes nothing, and `already-decided` — a human answering while this ran —
|
|
1314
|
+
* is passed over in silence there. Returns the keys actually withdrawn.
|
|
1315
|
+
*/
|
|
1316
|
+
function withdrawAbandoned(run, streams, records, now, keepHash, only = null) {
|
|
1317
|
+
const abandoned = abandonedRequests(run, records, now, keepHash).filter((entry) => only === null || only.includes(entry.actionKey));
|
|
1318
|
+
if (abandoned.length === 0)
|
|
1319
|
+
return [];
|
|
1320
|
+
return withdrawPending(run, streams, abandoned.map((entry) => entry.actionKey), `no retry adopted this question within ${minutesText(abandonedAfterMs(run.timeoutMs, run.graceMs))} of the hook's wait opening it (APRV-287); the asking tool call is gone, so the request is taken back rather than left for a listener to re-deliver`, "timeout");
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Spend every grant this verdict rests on, once each (APRV-117).
|
|
1324
|
+
*
|
|
1325
|
+
* A harness grant mints no token, so the record that it was used has to be
|
|
1326
|
+
* written deliberately: `consumeHarnessGrant` appends one `execution.started`
|
|
1327
|
+
* per key, through compare-and-append, and refuses `already-executed` if
|
|
1328
|
+
* anything spent it first. Called ONLY immediately before an `allow`, so a
|
|
1329
|
+
* verdict of deny spends nothing.
|
|
1330
|
+
*
|
|
1331
|
+
* Returns `null` on success, or the refusal that stopped it. A multi-class
|
|
1332
|
+
* command can consume its first key and fail on its second; the result is a
|
|
1333
|
+
* DENY with the first grant spent, which costs one extra prompt on the retry
|
|
1334
|
+
* and authorizes nothing. The reverse ordering — allow first, record later —
|
|
1335
|
+
* would trade that for a grant the harness used and the log never saw, so the
|
|
1336
|
+
* cheap failure is the correct one.
|
|
1337
|
+
*
|
|
1338
|
+
* `hash` is the binding the caller already computed over the bytes this verdict
|
|
1339
|
+
* is about (APRV-146). The gate requires it and compares it against what the
|
|
1340
|
+
* human answered: the same value keyed the carryover that found these grants, so
|
|
1341
|
+
* presenting it states, at the spend, the fact the match was made on.
|
|
1342
|
+
*/
|
|
1343
|
+
function consumeGrants(run, keys, hash,
|
|
1344
|
+
/**
|
|
1345
|
+
* This invocation's own task id (APRV-200). The gate compares it against the
|
|
1346
|
+
* task the request record carries and records `grant_origin: "direct"` only
|
|
1347
|
+
* when they are the same tool call; anything else records `carried`.
|
|
1348
|
+
*/
|
|
1349
|
+
task) {
|
|
1350
|
+
for (const key of keys) {
|
|
1351
|
+
const spent = consumeHarnessGrant(run.logPath, key, run.actor, {
|
|
1352
|
+
...run.options,
|
|
1353
|
+
presentedPayloadHash: hash,
|
|
1354
|
+
spendingTask: task,
|
|
1355
|
+
});
|
|
1356
|
+
if (!spent.ok)
|
|
1357
|
+
return { code: spent.code, message: `${key}: ${spent.message}` };
|
|
1358
|
+
}
|
|
1359
|
+
return null;
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Establish, from the VERIFIED log, that every grant this verdict rests on is
|
|
1363
|
+
* spent and recorded — before the allow is printed (APRV-200).
|
|
1364
|
+
*
|
|
1365
|
+
* ## Why a second read
|
|
1366
|
+
*
|
|
1367
|
+
* {@link consumeGrants} appends through compare-and-append and reports what the
|
|
1368
|
+
* gate returned, which is the write side of §11.1 invariant 8. This is the read
|
|
1369
|
+
* side, and on this surface it is not redundant. Everywhere else in the runtime
|
|
1370
|
+
* the process that appends `execution.started` is the process that then performs
|
|
1371
|
+
* the side effect, so an append that returned success is an append the same
|
|
1372
|
+
* process is about to act on. Here the executor is the HARNESS: the hook prints
|
|
1373
|
+
* `allow` and a different program does the thing. What that program is authorized
|
|
1374
|
+
* by is not the gate's return value, which it never sees; it is the record. So
|
|
1375
|
+
* the record is what the hook checks, through the same verified path every
|
|
1376
|
+
* enforcement read in this module uses (§11.1 invariant 1), and a verdict is
|
|
1377
|
+
* printed only once the chain carries it.
|
|
1378
|
+
*
|
|
1379
|
+
* A failure here denies with the grant already spent. That is the fail-closed
|
|
1380
|
+
* direction and the same trade `consumeGrants` documents: the retry costs one
|
|
1381
|
+
* more prompt and authorizes nothing, where the reverse ordering would hand the
|
|
1382
|
+
* harness a permission the log cannot show.
|
|
1383
|
+
*/
|
|
1384
|
+
function verifySpent(run, keys) {
|
|
1385
|
+
const read = readVerifiedRecords(run.logPath);
|
|
1386
|
+
if (!read.ok) {
|
|
1387
|
+
return {
|
|
1388
|
+
code: "hook-grant-unverified",
|
|
1389
|
+
detail: `the grant(s) for ${keys.join(", ")} were spent, but the log could not be re-read verified afterwards (${read.message}), so this hook cannot show that the record authorizing the tool call is in the chain. Nothing is allowed on an authorization the log cannot be seen to carry.`,
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
const missing = keys.filter((key) => !read.records.some((record) => record.event === "execution.started" && record.action_key === key));
|
|
1393
|
+
if (missing.length === 0)
|
|
1394
|
+
return null;
|
|
1395
|
+
return {
|
|
1396
|
+
code: "hook-grant-unverified",
|
|
1397
|
+
detail: `the spend of ${missing.join(", ")} reported success and the verified log does not carry its execution.started; the tool call is denied rather than allowed on a record that is not there.`,
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* What the runtime must establish from the VERIFIED log before the hook lets a
|
|
1402
|
+
* command run with no human anywhere in the loop (APRV-139).
|
|
1403
|
+
*
|
|
1404
|
+
* The hole this closes. Until this check the hook's autonomous verdict was
|
|
1405
|
+
* computed from the on-disk policy alone: `loadPolicy`, `resolve`, allow. No
|
|
1406
|
+
* attestation, no loop-escalation, nothing appended, nothing read. Because the
|
|
1407
|
+
* harness — not this runtime — executes the tool call on an allow,
|
|
1408
|
+
* `core/execute.ts`'s own guards were never reached either, so whoever could
|
|
1409
|
+
* write APPROVAL.md (a merge, a second agent, a branch checkout) could
|
|
1410
|
+
* reclassify any class to `autonomous` and the hook would let the command
|
|
1411
|
+
* through. SPEC.md §11.1 says the opposite: an edited policy is inoperative
|
|
1412
|
+
* until a human re-attests it, and the hook is an enforcement surface like any
|
|
1413
|
+
* other.
|
|
1414
|
+
*
|
|
1415
|
+
* The two facts checked here are exactly the two `core/execute.ts`'s
|
|
1416
|
+
* supervised/autonomous branch checks before it starts one, and they are
|
|
1417
|
+
* checked in the same order, against a log read the same way:
|
|
1418
|
+
*
|
|
1419
|
+
* 1. the live policy bytes match the latest attestation (`core/attest.ts`);
|
|
1420
|
+
* 2. the task is not loop-escalated (SPEC.md §10.2, `core/loop.ts`).
|
|
1421
|
+
*
|
|
1422
|
+
* **Where a failure lands.** Both refusals are the gate's own frozen codes,
|
|
1423
|
+
* emitted through the `hook-gate-refused:` family, and they are the verdict the
|
|
1424
|
+
* gated path would have printed for these classes anyway: `core/gate.ts`'s
|
|
1425
|
+
* `request` checks attestation before it resolves anything, and refuses a
|
|
1426
|
+
* non-manual class for an escalated task. Checking here rather than there means
|
|
1427
|
+
* the deny costs no `task.registered` — under an unattested policy every
|
|
1428
|
+
* autonomous command an agent runs would otherwise append one, which is a log
|
|
1429
|
+
* full of registrations written under rules nobody is enforcing.
|
|
1430
|
+
*
|
|
1431
|
+
* **Only where nobody is asked.** The caller runs this when EVERY class resolves
|
|
1432
|
+
* non-manual. A command with a manual class keeps its existing path: escalation
|
|
1433
|
+
* escalates *to* manual rather than closing the task (`core/loop.ts`), and
|
|
1434
|
+
* refusing the human's question too would leave an escalated task with no way
|
|
1435
|
+
* back.
|
|
1436
|
+
*/
|
|
1437
|
+
function unattendedGuard(logPath, policyPath, task,
|
|
1438
|
+
/**
|
|
1439
|
+
* Records this invocation has ALREADY read and verified (APRV-214). The
|
|
1440
|
+
* window lookup near the top of `runHarnessHook` performs a verified read
|
|
1441
|
+
* before the policy is loaded, and handing its result down means the closed
|
|
1442
|
+
* path still costs one verified read rather than gaining a third (APRV-209).
|
|
1443
|
+
* `null` where that read did not happen or did not verify, and then this does
|
|
1444
|
+
* its own, exactly as it always did.
|
|
1445
|
+
*/
|
|
1446
|
+
known = null) {
|
|
1447
|
+
// The VERIFIED log, as every enforcement path reads it (SPEC.md §11.1): an
|
|
1448
|
+
// attestation or a failure streak read off unverified bytes is whatever the
|
|
1449
|
+
// last writer of the file wanted it to be.
|
|
1450
|
+
const read = known === null ? readVerifiedRecords(logPath) : { ok: true, records: known };
|
|
1451
|
+
if (!read.ok)
|
|
1452
|
+
return { code: "hook-io", detail: read.message };
|
|
1453
|
+
const refusal = attestationRefusal(checkAttestation(read.records, policyPath));
|
|
1454
|
+
if (refusal !== null) {
|
|
1455
|
+
return {
|
|
1456
|
+
code: `hook-gate-refused:${refusal.code}`,
|
|
1457
|
+
detail: `${refusal.message}. Until then the hook decides nothing unattended: this command would have run with no human in the loop under rules no human has vouched for.`,
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
if (isLoopEscalated(read.records, task)) {
|
|
1461
|
+
return {
|
|
1462
|
+
code: "hook-gate-refused:loop-escalated",
|
|
1463
|
+
detail: `loop-escalated: task ${task} has three consecutive failed side-effecting executions and is escalated to manual (amended SPEC.md §10.2), so its unattended classes may not run. ${loopClearance("task", task)}.`,
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Is this tool call floored to manual by a harness streak (APRV-145)?
|
|
1470
|
+
*
|
|
1471
|
+
* The VERIFIED log, like every other enforcement read in this module: a failure
|
|
1472
|
+
* streak computed off unverified bytes is whatever the last writer of the file
|
|
1473
|
+
* wanted it to be (§11.1 invariant 1). A log this hook cannot read fails closed
|
|
1474
|
+
* as a deny at the call site rather than as "no floor", because "we could not
|
|
1475
|
+
* establish the streak" is not "there is no streak".
|
|
1476
|
+
*
|
|
1477
|
+
* Both scopes are derived inside `core/loop.ts` from the task id this runtime
|
|
1478
|
+
* minted and the identity this process runs as. Nothing on the event names the
|
|
1479
|
+
* bucket it is counted in, which is what stops an agent shedding a streak by
|
|
1480
|
+
* rotating a string.
|
|
1481
|
+
*/
|
|
1482
|
+
function harnessFloor(logPath, task, actor,
|
|
1483
|
+
/** Already-verified records from this invocation's window lookup (APRV-214). */
|
|
1484
|
+
known = null) {
|
|
1485
|
+
if (known !== null)
|
|
1486
|
+
return { ok: true, floor: harnessLoopFloor(known, task, actor) };
|
|
1487
|
+
const read = readVerifiedRecords(logPath);
|
|
1488
|
+
if (!read.ok)
|
|
1489
|
+
return { ok: false, detail: read.message };
|
|
1490
|
+
return { ok: true, floor: harnessLoopFloor(read.records, task, actor) };
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Charge and record every class of an unattended allow (APRV-141).
|
|
1494
|
+
*
|
|
1495
|
+
* One `execution.started` per class, through `core/gate.ts`, before the allow
|
|
1496
|
+
* is printed. Until this, a supervised or autonomous harness verdict appended
|
|
1497
|
+
* nothing at all, so `core/budgets.ts` charged it nothing (`daily_actions`
|
|
1498
|
+
* included) and `core/audit.ts` could never sample it — under Claude Code, on
|
|
1499
|
+
* the path that carries most of the traffic. The comment that path used to
|
|
1500
|
+
* carry was right that a record per agent action fills the log; APRV-141's
|
|
1501
|
+
* recorded decision is that an uncharged, unsampleable majority is the worse
|
|
1502
|
+
* of the two, and the record is kept as small as the contract allows.
|
|
1503
|
+
*
|
|
1504
|
+
* **The order is record-then-allow, and the failure is a deny.** A verdict
|
|
1505
|
+
* printed before the charge landed is a command that ran outside every budget,
|
|
1506
|
+
* which is the hole this closes. A refusal here (a budget ceiling, a head that
|
|
1507
|
+
* moved) therefore denies, and reaches the caller as the gate's own code.
|
|
1508
|
+
*
|
|
1509
|
+
* The autonomous classes are recorded with no `task.registered` behind them,
|
|
1510
|
+
* deliberately: `core/audit.ts` samples supervised executions only, so a
|
|
1511
|
+
* declaration would buy no oversight and would double the volume of exactly the
|
|
1512
|
+
* traffic this is trying not to drown the log in. The supervised classes are
|
|
1513
|
+
* registered already, by the caller, which is what makes them sampleable.
|
|
1514
|
+
*/
|
|
1515
|
+
function recordUnattended(run, task, classes, hash) {
|
|
1516
|
+
for (const cls of classes) {
|
|
1517
|
+
const started = startHarnessExecution(run.logPath, { task, actionKey: `${task}:${cls}`, cls, payload_hash: hash }, run.actor, run.options);
|
|
1518
|
+
if (!started.ok)
|
|
1519
|
+
return { code: started.code, message: `${cls}: ${started.message}` };
|
|
1520
|
+
}
|
|
1521
|
+
return null;
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* Say, on STDERR, that a question is now on a human's queue and where it went
|
|
1525
|
+
* (APRV-281).
|
|
1526
|
+
*
|
|
1527
|
+
* The behaviour this replaces: a gated tool call appended its request and then
|
|
1528
|
+
* blocked for the whole wait in complete silence, ending in a `hook-timeout` the
|
|
1529
|
+
* agent read as a refusal and the operator never saw coming. Nine minutes of a
|
|
1530
|
+
* session's clock, with no way to tell "nobody has answered yet" from "nothing
|
|
1531
|
+
* is even delivering this".
|
|
1532
|
+
*
|
|
1533
|
+
* **STDERR, and never stdout.** Stdout carries the verdict object the harness
|
|
1534
|
+
* parses (see this file's header); a second object, or any prose at all, on that
|
|
1535
|
+
* stream is a hook the harness cannot read. Claude Code shows stderr to the
|
|
1536
|
+
* operator, which is exactly the audience for this.
|
|
1537
|
+
*
|
|
1538
|
+
* **It decides nothing.** No verdict, no timeout, no record, no refusal code
|
|
1539
|
+
* turns on any of it. Both lines are printed after the request is appended and
|
|
1540
|
+
* before the poll loop starts, so the state they describe is the state that
|
|
1541
|
+
* exists; a probe that reported nothing (an unreadable directory, a platform
|
|
1542
|
+
* with no euid) simply stays quiet rather than changing what this process does.
|
|
1543
|
+
*
|
|
1544
|
+
* **The listener line names a socket, and claims only what a socket can tell
|
|
1545
|
+
* you.** `drawSocketUsable` is the same predicate an asker consults, and this
|
|
1546
|
+
* connects to nothing: a usable-looking socket therefore prints NOTHING here,
|
|
1547
|
+
* because a `stat` cannot establish that the far side answers. What an absent
|
|
1548
|
+
* or untrustworthy socket does establish is that `approval up` is not running
|
|
1549
|
+
* against this log in this checkout, and `approval up` is the one process that
|
|
1550
|
+
* both serves the channels and consumes the taps. That is worth saying: on
|
|
1551
|
+
* 2026-09-05 taps piled up unconsumed while hooks waited out their windows.
|
|
1552
|
+
*/
|
|
1553
|
+
function announceWait(streams, run, waiting) {
|
|
1554
|
+
const where = run.channels.length === 0
|
|
1555
|
+
? "no channel (this policy configures none, so nothing is delivering the question)"
|
|
1556
|
+
: `channel ${run.channels.join(", ")}`;
|
|
1557
|
+
for (const action of waiting) {
|
|
1558
|
+
const adopted = action.origin === "adopted"
|
|
1559
|
+
? " The question was already open for these exact bytes, so this tool call adopts it rather than asking a second time."
|
|
1560
|
+
: "";
|
|
1561
|
+
streams.err(`approval: ${action.actionKey} (${action.cls}) is waiting for a human on ${where}; a decision on the phone releases it, and this hook blocks for up to ${String(run.timeoutMs)}ms before denying with hook-timeout and leaving the request open for a ${minutesText(run.graceMs)} retry grace.${adopted}\n`);
|
|
1562
|
+
}
|
|
1563
|
+
const socket = drawSocketPathFor(run.logPath);
|
|
1564
|
+
const listener = drawSocketUsable(socket);
|
|
1565
|
+
if (listener.ok)
|
|
1566
|
+
return;
|
|
1567
|
+
streams.err(`approval: no listener is running for this log (${listener.reason}: ${socket}), so the request above may sit undelivered and a decision may go unconsumed. Start the gate's ambient runtime in the checkout that owns this log: \`eval "$(approval env)" && approval up\`, which runs the daemon loop and every configured channel in one process.\n`);
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* The gated half: find what is already open for these bytes, request whatever
|
|
1571
|
+
* is not, wait for the decisions, spend the grants. Returns the exit code of
|
|
1572
|
+
* whatever verdict it printed.
|
|
1573
|
+
*
|
|
1574
|
+
* ## Requests are keyed by bytes, not by invocation (APRV-117)
|
|
1575
|
+
*
|
|
1576
|
+
* The action key is still `hook:<session>:<tool-use id>:<class>` and is still
|
|
1577
|
+
* unique per invocation — what changed is that intake LOOKS for an earlier
|
|
1578
|
+
* request about the same `{command, cwd}` before opening a new one, matching on
|
|
1579
|
+
* the `payload_hash` recorded on `approval.requested`. Three outcomes per class,
|
|
1580
|
+
* decided by `core/gate.ts`'s `findHarnessCarry`:
|
|
1581
|
+
*
|
|
1582
|
+
* - nothing to carry: register and request, exactly as before;
|
|
1583
|
+
* - a pending request: **adopt** it — wait out the remainder of this
|
|
1584
|
+
* invocation's window on somebody else's key, opening nothing. The approver's
|
|
1585
|
+
* phone never shows two prompts for one command, because there is only ever
|
|
1586
|
+
* one question;
|
|
1587
|
+
* - an unspent grant inside the TTL: **carry** it — no wait, no prompt, and
|
|
1588
|
+
* the grant is spent (once) before the allow is printed.
|
|
1589
|
+
*
|
|
1590
|
+
* ## Why the wait no longer ends in a withdrawal (APRV-106, revised)
|
|
1591
|
+
*
|
|
1592
|
+
* APRV-106 retracted the request when the wait elapsed, because a retried tool
|
|
1593
|
+
* call was a new request with a new key and a late tap therefore authorized
|
|
1594
|
+
* nothing: the human spent attention on a question whose asker had left. The
|
|
1595
|
+
* carryover above removes the premise. A late tap now authorizes the retry, so
|
|
1596
|
+
* the request stays open for the policy's TTL and the timeout says so.
|
|
1597
|
+
*
|
|
1598
|
+
* What still withdraws is every path where nothing can adopt the question: a
|
|
1599
|
+
* SIGTERM or SIGINT (the session is going away), a thrown failure, and an intake
|
|
1600
|
+
* refusal partway through a multi-class command (the command cannot proceed on
|
|
1601
|
+
* any retry, so the classes already opened are noise in a human's queue). The
|
|
1602
|
+
* signal handlers are installed for the duration of the wait ONLY, and removed
|
|
1603
|
+
* in `finally`: a hook process is short-lived and borrowing the harness's
|
|
1604
|
+
* signal disposition for longer than the loop would be a side effect nobody
|
|
1605
|
+
* asked for.
|
|
1606
|
+
*/
|
|
1607
|
+
function gateAndWait(streams, run, classes,
|
|
1608
|
+
/**
|
|
1609
|
+
* The bytes the grant binds to: `{command, cwd}` for a Bash call, the change
|
|
1610
|
+
* itself for a file tool (APRV-124). Whatever this is, it is what reaches the
|
|
1611
|
+
* approver's FULL PAYLOAD block, complete — the summary below is a headline
|
|
1612
|
+
* and is the only thing here that may be shortened.
|
|
1613
|
+
*/
|
|
1614
|
+
payload, headline,
|
|
1615
|
+
/**
|
|
1616
|
+
* The task id this invocation acts under, minted once by the caller
|
|
1617
|
+
* (APRV-139) so the loop-escalation check and the registration it may lead to
|
|
1618
|
+
* name the same task. Deriving it twice would mint two ids whenever
|
|
1619
|
+
* `tool_use_id` is absent and the random fallback runs.
|
|
1620
|
+
*/
|
|
1621
|
+
task,
|
|
1622
|
+
/** The history-rewrite refinement's own words, or `""` (APRV-108). */
|
|
1623
|
+
note = "",
|
|
1624
|
+
/**
|
|
1625
|
+
* The harness streak that floors the SIDE-EFFECTING classes of this
|
|
1626
|
+
* invocation to `manual` (APRV-145, narrowed by APRV-297), or `null` where
|
|
1627
|
+
* policy alone sent it here.
|
|
1628
|
+
*
|
|
1629
|
+
* Passed into `request` as a boolean rather than acted on here, so the floored
|
|
1630
|
+
* action takes the identical path a manual class takes — same records, same
|
|
1631
|
+
* order, same wait — and nothing below knows how it got there. What the STATE
|
|
1632
|
+
* adds (APRV-280) is the deny text: an agent whose commands are all suddenly
|
|
1633
|
+
* on the phone is owed the reason and the way out in the same breath, and
|
|
1634
|
+
* before APRV-280 the nine-minute wait ended in a bare `hook-timeout` that
|
|
1635
|
+
* said neither.
|
|
1636
|
+
*
|
|
1637
|
+
* Since APRV-297 the caller passes `null` for a command whose classes are all
|
|
1638
|
+
* reads, and {@link floorApplies} below carves the read classes out of a mixed
|
|
1639
|
+
* one, so a floor never puts a question about looking on a human's phone.
|
|
1640
|
+
*/
|
|
1641
|
+
floor = null) {
|
|
1642
|
+
/**
|
|
1643
|
+
* Does the floor route THIS class to a human? (APRV-297.)
|
|
1644
|
+
*
|
|
1645
|
+
* Per class rather than per command, because a MIXED tool call is one question
|
|
1646
|
+
* about its side effects and no question at all about its looking. Under a
|
|
1647
|
+
* floor, `ls -la && mkdir build` raises the write and leaves the read to the
|
|
1648
|
+
* policy, so the approver sees one prompt for what the command DOES. Before
|
|
1649
|
+
* this the read class was raised too, and a floored session put two prompts on
|
|
1650
|
+
* a phone for one command, one of which nobody needed to answer.
|
|
1651
|
+
*
|
|
1652
|
+
* The command is still routed as a whole: the verdict waits on the classes
|
|
1653
|
+
* that were raised, and an allow covers the command.
|
|
1654
|
+
*/
|
|
1655
|
+
const floorApplies = (cls) => floor !== null && isSideEffectingClass(cls);
|
|
1656
|
+
const hash = payloadHash(payload);
|
|
1657
|
+
const summary = truncate(headline, SUMMARY_LIMIT);
|
|
1658
|
+
const sayAllow = (reason) => allow(streams, reason, run.harness, run.codexCommand);
|
|
1659
|
+
/**
|
|
1660
|
+
* Every deny this function can print, with the floor's own sentence appended
|
|
1661
|
+
* when a floor is what routed the command here (APRV-280). One wrapper rather
|
|
1662
|
+
* than a sentence bolted onto the timeout alone: a floored invocation that
|
|
1663
|
+
* ends in a rejection, a lapse or an I/O fault leaves the agent in exactly the
|
|
1664
|
+
* same place, and the operator reading the harness's error stream needs the
|
|
1665
|
+
* scope key either way.
|
|
1666
|
+
*/
|
|
1667
|
+
const sayDeny = (code, detail) => deny(streams, code, floor === null
|
|
1668
|
+
? detail
|
|
1669
|
+
: `${detail} This tool call was routed to a human by loop safety rather than by policy — loop-escalated: ${floor.scope} ${floor.key} has ${String(floor.consecutiveFailures)} consecutive failed side-effecting harness tool calls (amended SPEC.md §10.2). ${loopClearance(floor.scope, floor.key)}`, run.harness);
|
|
1670
|
+
// Intake reads the VERIFIED log, once, before anything is written: an
|
|
1671
|
+
// enforcement path reads nothing else (SPEC.md §11.1), and a carry decided
|
|
1672
|
+
// from unverified bytes would be a grant invented by whoever could write the
|
|
1673
|
+
// file.
|
|
1674
|
+
const intake = readVerifiedRecords(run.logPath);
|
|
1675
|
+
if (!intake.ok)
|
|
1676
|
+
return sayDeny("hook-io", intake.message);
|
|
1677
|
+
const intakeTs = new Date().toISOString();
|
|
1678
|
+
// APRV-287. Before this invocation adds a question of its own, the questions
|
|
1679
|
+
// earlier invocations of this actor left behind are taken back — every one
|
|
1680
|
+
// whose grace window has run out, and never the bytes this one is about to
|
|
1681
|
+
// ask about. The hook is the only writer that can do this: `withdraw` is
|
|
1682
|
+
// requester-only, and the requester of a harness request is this actor.
|
|
1683
|
+
const swept = withdrawAbandoned(run, streams, intake.records, intakeTs, hash);
|
|
1684
|
+
if (swept.length > 0) {
|
|
1685
|
+
streams.err(`approval: withdrew ${String(swept.length)} abandoned harness request(s) nothing retried (${swept.join(", ")}); a tap on one of them now authorizes nothing and the channel says so\n`);
|
|
1686
|
+
}
|
|
1687
|
+
const actions = classes.map((cls) => {
|
|
1688
|
+
const carry = findHarnessCarry(intake.records, hash, cls, intakeTs, run.ttlMs);
|
|
1689
|
+
if (carry === null)
|
|
1690
|
+
return { cls, actionKey: `${task}:${cls}`, origin: "new" };
|
|
1691
|
+
return {
|
|
1692
|
+
cls,
|
|
1693
|
+
actionKey: carry.actionKey,
|
|
1694
|
+
origin: carry.kind === "granted" ? "carried" : "adopted",
|
|
1695
|
+
};
|
|
1696
|
+
});
|
|
1697
|
+
const fresh = actions.filter((action) => action.origin === "new");
|
|
1698
|
+
const adopted = actions.filter((action) => action.origin === "adopted");
|
|
1699
|
+
const carried = actions.filter((action) => action.origin === "carried");
|
|
1700
|
+
// Only the classes that need a new question are registered. A retry whose
|
|
1701
|
+
// every class carries or adopts registers no task at all — the envelope it
|
|
1702
|
+
// would declare already exists, under the key it is about to wait on.
|
|
1703
|
+
if (fresh.length > 0) {
|
|
1704
|
+
const envelope = {
|
|
1705
|
+
origin: { app: run.originApp, created_by: run.actor },
|
|
1706
|
+
state: "proposed",
|
|
1707
|
+
actions: fresh.map((action) => ({
|
|
1708
|
+
class: action.cls,
|
|
1709
|
+
summary,
|
|
1710
|
+
idempotency_key: action.actionKey,
|
|
1711
|
+
payload_hash: hash,
|
|
1712
|
+
})),
|
|
1713
|
+
};
|
|
1714
|
+
// APRV-227: which harness binary wrote this registration. A CALL option,
|
|
1715
|
+
// never a field of the envelope above — an envelope is authored by the
|
|
1716
|
+
// party under oversight, and this is a statement about the binary doing
|
|
1717
|
+
// the overseeing.
|
|
1718
|
+
const provenance = registrationProvenance(run);
|
|
1719
|
+
const registered = register(run.logPath, { task, envelope }, run.actor, {
|
|
1720
|
+
...run.options,
|
|
1721
|
+
...(provenance === null ? {} : { harness: provenance }),
|
|
1722
|
+
});
|
|
1723
|
+
if (!registered.ok) {
|
|
1724
|
+
return sayDeny(`hook-gate-refused:${registered.code}`, registered.message);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
// `execution: "harness"` says a grant here mints no execution token. The hook
|
|
1728
|
+
// answers allow/deny and Claude Code runs the command; nothing ever calls
|
|
1729
|
+
// `approval run`, so a minted token would be a live credential with no
|
|
1730
|
+
// spender. It removes capability from the requester and grants none.
|
|
1731
|
+
//
|
|
1732
|
+
// APRV-106's companion field, `wait_until`, is deliberately NOT declared any
|
|
1733
|
+
// more. It rendered as "requester waits until 09:23 UTC" on the approver's
|
|
1734
|
+
// phone, and under carryover that sentence is false: an answer after this
|
|
1735
|
+
// invocation stops waiting authorizes the retry. With no `wait_until` the
|
|
1736
|
+
// channel's own line falls back to the deadline that does govern — "expires
|
|
1737
|
+
// HH:MM UTC", the policy's TTL — which is now exactly the truth.
|
|
1738
|
+
const ownKeys = [];
|
|
1739
|
+
for (const action of fresh) {
|
|
1740
|
+
const result = request(run.logPath, {
|
|
1741
|
+
task,
|
|
1742
|
+
actionKey: action.actionKey,
|
|
1743
|
+
cls: action.cls,
|
|
1744
|
+
summary,
|
|
1745
|
+
payload_hash: hash,
|
|
1746
|
+
payload: { value: payload },
|
|
1747
|
+
execution: "harness",
|
|
1748
|
+
...(floorApplies(action.cls) ? { loopFloor: true } : {}),
|
|
1749
|
+
}, run.actor, run.options);
|
|
1750
|
+
if (!result.ok) {
|
|
1751
|
+
// Whatever this invocation opened is retracted before the deny: a refusal
|
|
1752
|
+
// on the third class dooms the command on every retry too, so the first
|
|
1753
|
+
// two must not stand in a queue that nothing will ever adopt.
|
|
1754
|
+
withdrawPending(run, streams, ownKeys, `intake refused ${action.actionKey}; this command cannot proceed, so the classes already opened for it are questions nobody needs to answer`);
|
|
1755
|
+
return sayDeny(`hook-gate-refused:${result.code}`, result.message);
|
|
1756
|
+
}
|
|
1757
|
+
if (result.record !== null)
|
|
1758
|
+
ownKeys.push(action.actionKey);
|
|
1759
|
+
}
|
|
1760
|
+
/** Every key that must be granted before this hook says yes. */
|
|
1761
|
+
const waitKeys = [...adopted.map((action) => action.actionKey), ...ownKeys];
|
|
1762
|
+
/** Every key whose grant this verdict would spend. */
|
|
1763
|
+
const spendKeys = [...carried.map((action) => action.actionKey), ...waitKeys];
|
|
1764
|
+
/** How the allow line describes where its authorization came from. */
|
|
1765
|
+
const provenance = carried.length === 0
|
|
1766
|
+
? ""
|
|
1767
|
+
: ` (carried: ${carried.map((action) => action.actionKey).join(", ")})`;
|
|
1768
|
+
if (waitKeys.length === 0) {
|
|
1769
|
+
if (spendKeys.length === 0) {
|
|
1770
|
+
// Every class resolved supervised: intake recorded no request (amended
|
|
1771
|
+
// SPEC.md §6.3), so there is nothing to wait for and nothing to spend.
|
|
1772
|
+
// What there is, since APRV-141, is something to charge: the start event
|
|
1773
|
+
// is this execution's authorization, and the registration `fresh` just
|
|
1774
|
+
// wrote is what makes it a sampleable one.
|
|
1775
|
+
const charged = recordUnattended(run, task, classes, hash);
|
|
1776
|
+
if (charged !== null) {
|
|
1777
|
+
return sayDeny(`hook-gate-refused:${charged.code}`, charged.message);
|
|
1778
|
+
}
|
|
1779
|
+
return sayAllow(`granted: ${classes.join(", ")} needs no approval under this policy${note}`);
|
|
1780
|
+
}
|
|
1781
|
+
// Every gated class carried an unspent grant: a human already answered this
|
|
1782
|
+
// exact question about these exact bytes, and nobody is asked again.
|
|
1783
|
+
const failed = consumeGrants(run, spendKeys, hash, task);
|
|
1784
|
+
if (failed !== null) {
|
|
1785
|
+
return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
|
|
1786
|
+
}
|
|
1787
|
+
const unverified = verifySpent(run, spendKeys);
|
|
1788
|
+
if (unverified !== null)
|
|
1789
|
+
return sayDeny(unverified.code, unverified.detail);
|
|
1790
|
+
return sayAllow(`granted: ${classes.join(", ")}${provenance}${note}`);
|
|
1791
|
+
}
|
|
1792
|
+
// Past every early return, so this is reached only where this process is
|
|
1793
|
+
// genuinely about to block on a human (APRV-281). The set it names is the set
|
|
1794
|
+
// it waits on: the keys this invocation opened, plus the ones it adopted from
|
|
1795
|
+
// an earlier tool call, which wait in the same silence and were the case the
|
|
1796
|
+
// announce would most easily have missed. A carried grant is not here because
|
|
1797
|
+
// nothing is waiting on it.
|
|
1798
|
+
announceWait(streams, run, actions.filter((action) => waitKeys.includes(action.actionKey)));
|
|
1799
|
+
const deadline = Date.now() + run.timeoutMs;
|
|
1800
|
+
// A signal arriving mid-wait means the session is going away: nothing will
|
|
1801
|
+
// retry this command, so the question this invocation opened is retracted.
|
|
1802
|
+
// `process.exit` is deliberate and immediate: the default disposition for
|
|
1803
|
+
// these signals is to die, and a handler that only withdrew would leave the
|
|
1804
|
+
// hook wedged in its poll loop with the harness waiting on it.
|
|
1805
|
+
const onSignal = (signal) => {
|
|
1806
|
+
withdrawPending(run, streams, ownKeys, `the requesting hook process received ${signal} while waiting; the session is ending, so no retry will adopt this request`);
|
|
1807
|
+
process.exit(EXIT_USAGE);
|
|
1808
|
+
};
|
|
1809
|
+
const onTerm = () => onSignal("SIGTERM");
|
|
1810
|
+
const onInt = () => onSignal("SIGINT");
|
|
1811
|
+
process.on("SIGTERM", onTerm);
|
|
1812
|
+
process.on("SIGINT", onInt);
|
|
1813
|
+
/**
|
|
1814
|
+
* Has this invocation already said, on stderr, that the verified view lags
|
|
1815
|
+
* the requests it is waiting on (APRV-294)? Said once per invocation: the
|
|
1816
|
+
* poll runs every second, and a line per poll would bury the one line that
|
|
1817
|
+
* matters under sixty copies of itself.
|
|
1818
|
+
*/
|
|
1819
|
+
let saidLagging = false;
|
|
1820
|
+
try {
|
|
1821
|
+
for (;;) {
|
|
1822
|
+
const read = readVerifiedRecords(run.logPath);
|
|
1823
|
+
if (!read.ok) {
|
|
1824
|
+
withdrawPending(run, streams, ownKeys, `the hook could not read the log while waiting on ${task}`);
|
|
1825
|
+
return sayDeny("hook-io", read.message);
|
|
1826
|
+
}
|
|
1827
|
+
const ts = new Date().toISOString();
|
|
1828
|
+
// Only the keys this invocation is waiting on count. Deriving the set
|
|
1829
|
+
// from the log again would let an empty or foreign result read as
|
|
1830
|
+
// "nothing pending" and fall through to allow; the verified log must show
|
|
1831
|
+
// every one of these keys granted before the hook says yes.
|
|
1832
|
+
const derived = waitKeys.map((key) => ({
|
|
1833
|
+
key,
|
|
1834
|
+
state: requestState(read.records, key, ts, run.ttlMs).state,
|
|
1835
|
+
}));
|
|
1836
|
+
const states = derived.map((entry) => entry.state);
|
|
1837
|
+
/**
|
|
1838
|
+
* Keys this process ESTABLISHED exist, that this read does not carry
|
|
1839
|
+
* (APRV-294).
|
|
1840
|
+
*
|
|
1841
|
+
* Every key in `waitKeys` was seen in a verified read by this process:
|
|
1842
|
+
* `ownKeys` because `request` appended it and returned the record,
|
|
1843
|
+
* `adopted` because intake's verified read found the pending request it
|
|
1844
|
+
* is adopting. So `none` here is never the terminal fact "there is no
|
|
1845
|
+
* such request". A log is append-only; a request that existed does not
|
|
1846
|
+
* stop existing. What `none` says is that the view this read produced
|
|
1847
|
+
* does not yet carry a record this process holds, which is a fact about
|
|
1848
|
+
* the view and not about the request.
|
|
1849
|
+
*
|
|
1850
|
+
* On 2026-09-07 02:00Z, minutes after `approval log sync` replaced the
|
|
1851
|
+
* committed baseline and the daemon restarted, a hook read exactly this
|
|
1852
|
+
* and denied at once: `hook-io: the verified log does not show every
|
|
1853
|
+
* request as granted (states: none, none, none)`. The requests were real
|
|
1854
|
+
* and reached the approver's phone; the view had not caught up. Treating
|
|
1855
|
+
* that as terminal spends the human's answer on nothing and, since it is
|
|
1856
|
+
* a deny, hands the agent a refusal for a question still open.
|
|
1857
|
+
*
|
|
1858
|
+
* So a lagging key waits, exactly as `requested` waits, bounded by the
|
|
1859
|
+
* same timeout — and nothing here reads unverified bytes as verified,
|
|
1860
|
+
* which is the only response to a lag that §11.1 invariant 1 leaves open.
|
|
1861
|
+
* The APRV-287 withdrawal still applies at expiry, over the keys whose
|
|
1862
|
+
* requests the view does carry.
|
|
1863
|
+
*/
|
|
1864
|
+
const lagging = derived
|
|
1865
|
+
.filter((entry) => entry.state === "none")
|
|
1866
|
+
.map((entry) => entry.key);
|
|
1867
|
+
if (lagging.length > 0 && !saidLagging) {
|
|
1868
|
+
saidLagging = true;
|
|
1869
|
+
streams.err(`approval: the verified log does not yet carry ${lagging.join(", ")} (verified head: ${read.head === null ? "empty" : `seq ${String(read.head.seq)}`}). The request(s) were appended by this hook, so this is a view that lags rather than a decision; the hook keeps waiting for the verification to catch up, up to its ${String(run.timeoutMs)}ms wait. A sync or a daemon restart in the last minute is the usual cause (docs/claude-code-hook.md).\n`);
|
|
1870
|
+
}
|
|
1871
|
+
if (!states.includes("requested") && lagging.length === 0) {
|
|
1872
|
+
// Precedence, as `approval wait` fixes it: a human's "no" outranks a
|
|
1873
|
+
// lapse, and both outrank "everything was granted". A withdrawal sits
|
|
1874
|
+
// with the refusals: it is not a decision, but it is terminal, and it
|
|
1875
|
+
// means this key will never be granted.
|
|
1876
|
+
if (states.includes("rejected")) {
|
|
1877
|
+
return sayDeny("hook-rejected", `a human rejected ${task}`);
|
|
1878
|
+
}
|
|
1879
|
+
if (states.includes("revoked")) {
|
|
1880
|
+
return sayDeny("hook-revoked", `approval for ${task} was withdrawn`);
|
|
1881
|
+
}
|
|
1882
|
+
if (states.includes("withdrawn")) {
|
|
1883
|
+
return sayDeny("hook-withdrawn", `the request for ${task} was withdrawn before a decision; nothing is pending and nothing was authorized`);
|
|
1884
|
+
}
|
|
1885
|
+
if (states.includes("expired")) {
|
|
1886
|
+
return sayDeny("hook-expired", `the request for ${task} lapsed before a decision`);
|
|
1887
|
+
}
|
|
1888
|
+
if (states.every((state) => state === "granted")) {
|
|
1889
|
+
// The grants are spent before the allow is printed, so this exact
|
|
1890
|
+
// command cannot ride the same authorization twice.
|
|
1891
|
+
const failed = consumeGrants(run, spendKeys, hash, task);
|
|
1892
|
+
if (failed !== null) {
|
|
1893
|
+
return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
|
|
1894
|
+
}
|
|
1895
|
+
const unverified = verifySpent(run, spendKeys);
|
|
1896
|
+
if (unverified !== null)
|
|
1897
|
+
return sayDeny(unverified.code, unverified.detail);
|
|
1898
|
+
return sayAllow(`granted: ${task} (${classes.join(", ")})${provenance}${note}`);
|
|
1899
|
+
}
|
|
1900
|
+
// Not a wait outcome: the log disagrees with itself about keys this
|
|
1901
|
+
// process is waiting on. Nothing is retracted, because the state that
|
|
1902
|
+
// would justify retracting is the state that could not be established.
|
|
1903
|
+
//
|
|
1904
|
+
// A BACKSTOP since APRV-294, and deliberately kept. `none` no longer
|
|
1905
|
+
// reaches here (it waits, above) and every remaining state is either
|
|
1906
|
+
// terminal and answered above or `granted`, so this is unreachable
|
|
1907
|
+
// through today's `RequestState`. It stands for the state a later
|
|
1908
|
+
// member of that union would arrive as: an outcome this function has no
|
|
1909
|
+
// reading for denies rather than allows.
|
|
1910
|
+
return sayDeny("hook-io", `the verified log does not show every request for ${task} as granted (states: ${states.join(", ")})`);
|
|
1911
|
+
}
|
|
1912
|
+
if (Date.now() >= deadline) {
|
|
1913
|
+
// APRV-117, narrowed by APRV-287. The request stays open for the RETRY
|
|
1914
|
+
// GRACE: a decision inside that window authorizes the retry of this
|
|
1915
|
+
// exact command in this exact directory, once, and withdrawing at the
|
|
1916
|
+
// first expiry would discard the answer the human is about to give.
|
|
1917
|
+
// Past the grace nobody is coming back for it, and a question nothing
|
|
1918
|
+
// will adopt is taken back rather than left for a restarted listener to
|
|
1919
|
+
// re-deliver.
|
|
1920
|
+
const withdrawn = withdrawAbandoned(run, streams, read.records, ts, null, ownKeys);
|
|
1921
|
+
// APRV-294: a wait that ends with the view still short of its own
|
|
1922
|
+
// requests says so. The deny is the same deny — the wait ran out — and
|
|
1923
|
+
// the repair is different from a queue nobody answered: the log this
|
|
1924
|
+
// hook reads is behind the log it wrote to, and `approval log verify`
|
|
1925
|
+
// in the checkout that owns it is where that is established.
|
|
1926
|
+
const stillLagging = lagging.length === 0
|
|
1927
|
+
? ""
|
|
1928
|
+
: ` The verified view still does not carry ${lagging.join(", ")}, which this hook appended: the request(s) exist and the view is behind, so check the log that owns them (\`approval log verify\`, \`approval status\`) rather than reading this as an unanswered question.`;
|
|
1929
|
+
if (withdrawn.length > 0) {
|
|
1930
|
+
return sayDeny("hook-timeout", `no decision on ${waitKeys.join(", ")} within the hook's ${String(run.timeoutMs)}ms wait, and the ${minutesText(run.graceMs)} retry grace has run out: ${withdrawn.join(", ")} WAS WITHDRAWN (reason timeout). A tap on it now authorizes nothing and the channel says so. Run the command again to ask the question fresh.${stillLagging}`);
|
|
1931
|
+
}
|
|
1932
|
+
return sayDeny("hook-timeout", `no decision on ${waitKeys.join(", ")} within the hook's ${String(run.timeoutMs)}ms wait. This tool call is denied and NOTHING WAS WITHDRAWN: the request(s) stay open for the ${minutesText(run.graceMs)} retry grace, and a decision inside that window authorizes a retry of this exact command in this exact directory, once. Retry it after the approver answers; the retry adopts the same question rather than asking a second one. Past the grace the hook takes the question back (approval.withdrawn, reason timeout), so a late retry asks again rather than adopting a question nobody is holding.${stillLagging}`);
|
|
1933
|
+
}
|
|
1934
|
+
sleepSync(Math.min(run.intervalMs, Math.max(0, deadline - Date.now())));
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
catch (cause) {
|
|
1938
|
+
// The thrown path. `commandHarnessHook` turns this into an ordinary
|
|
1939
|
+
// deny. Unlike the timeout, this process cannot say what state it left
|
|
1940
|
+
// behind, so the question it opened is retracted rather than left standing
|
|
1941
|
+
// on a failure nobody diagnosed.
|
|
1942
|
+
withdrawPending(run, streams, ownKeys, `the requesting hook process failed while waiting (${cause instanceof Error ? cause.message : String(cause)})`);
|
|
1943
|
+
throw cause;
|
|
1944
|
+
}
|
|
1945
|
+
finally {
|
|
1946
|
+
process.off("SIGTERM", onTerm);
|
|
1947
|
+
process.off("SIGINT", onInt);
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
// ===========================================================================
|
|
1951
|
+
// The completion counterpart (APRV-145)
|
|
1952
|
+
// ===========================================================================
|
|
1953
|
+
/**
|
|
1954
|
+
* The hook event names that report a tool call's OUTCOME rather than ask about
|
|
1955
|
+
* it, from `docs/claude-code-hook.md`'s pinned contract.
|
|
1956
|
+
*
|
|
1957
|
+
* `PostToolUseFailure` is listed because Claude Code splits the report in two:
|
|
1958
|
+
* a tool call that failed outright fires it instead of `PostToolUse`, and a
|
|
1959
|
+
* counterpart that only knew the success event would record the completions and
|
|
1960
|
+
* silently drop every failure, which is the one direction §11.1 invariant 4
|
|
1961
|
+
* forbids.
|
|
1962
|
+
*/
|
|
1963
|
+
const POST_TOOL_EVENTS = ["PostToolUse", "PostToolUseFailure"];
|
|
1964
|
+
/**
|
|
1965
|
+
* Every line the counterpart can print, closed and machine-readable (§11.1
|
|
1966
|
+
* invariant 7).
|
|
1967
|
+
*
|
|
1968
|
+
* A post-execution hook cannot deny anything — the tool has already run — so
|
|
1969
|
+
* none of these is a verdict, and every one of them prints an EMPTY STDOUT: a
|
|
1970
|
+
* decision object on that stream would be a second answer about a command the
|
|
1971
|
+
* harness already ran. The line goes to stderr instead.
|
|
1972
|
+
*
|
|
1973
|
+
* ## The exit code decides whether anybody reads that line (APRV-303)
|
|
1974
|
+
*
|
|
1975
|
+
* Claude Code's hooks reference states it plainly: stderr from a hook that
|
|
1976
|
+
* exits 0 "goes to the debug log only, never the transcript, and Claude never
|
|
1977
|
+
* sees it", and a post-execution hook that exits 2 has its stderr shown, since
|
|
1978
|
+
* there is nothing left to block. So a refusal reported at exit 0 is a refusal
|
|
1979
|
+
* nobody receives, which is how 22052 unreported starts accumulated on this
|
|
1980
|
+
* project's own log without a single visible complaint.
|
|
1981
|
+
*
|
|
1982
|
+
* Therefore: {@link POST_TOOL_REPORTED} exits 0, because a counterpart that
|
|
1983
|
+
* landed is not news; every other code exits {@link POST_TOOL_SURFACE_EXIT},
|
|
1984
|
+
* because every other code means the outcome of a tool call was not recorded
|
|
1985
|
+
* and somebody has to know. Neither exit is a verdict, and neither blocks
|
|
1986
|
+
* anything.
|
|
1987
|
+
*/
|
|
1988
|
+
export const POST_TOOL_CODES = [
|
|
1989
|
+
/** One or more counterparts were appended. */
|
|
1990
|
+
"post-tool-reported",
|
|
1991
|
+
/** The event names no tool-use id, so no task id can be reconstructed. */
|
|
1992
|
+
"post-tool-unidentified",
|
|
1993
|
+
/** The tool is not one this hook gates, so no start exists to close. */
|
|
1994
|
+
"post-tool-not-gated",
|
|
1995
|
+
/**
|
|
1996
|
+
* The outcome could not be read from the event by the pinned set of readings,
|
|
1997
|
+
* so NOTHING was appended. Recording a failure nobody observed trips an
|
|
1998
|
+
* escalation on noise, and recording a completion nobody observed clears one
|
|
1999
|
+
* on nothing.
|
|
2000
|
+
*/
|
|
2001
|
+
"post-tool-unreadable-outcome",
|
|
2002
|
+
/** No log where the hook was pointed; the hook is a writer, never an initializer. */
|
|
2003
|
+
"post-tool-log-unreachable",
|
|
2004
|
+
/** The gate refused the append; its own frozen code follows a colon. */
|
|
2005
|
+
"post-tool-gate-refused",
|
|
2006
|
+
/** Malformed input, or a filesystem fact that stopped the report. */
|
|
2007
|
+
"post-tool-io",
|
|
2008
|
+
];
|
|
2009
|
+
/** The one code that means the counterpart landed, and the one that exits 0. */
|
|
2010
|
+
const POST_TOOL_REPORTED = "post-tool-reported";
|
|
2011
|
+
/**
|
|
2012
|
+
* The exit code that makes a post-execution hook's stderr visible (APRV-303).
|
|
2013
|
+
*
|
|
2014
|
+
* It is the number Claude Code's hook protocol reserves for "show this line",
|
|
2015
|
+
* and on this one path it means exactly that. It is NOT `EXIT_USAGE`, whose
|
|
2016
|
+
* meaning in `cli/exit-codes.ts` is a malformed invocation: the harness hooks
|
|
2017
|
+
* speak the harness's protocol on both streams already (stdout carries a
|
|
2018
|
+
* decision object no other verb prints), and the exit code is the third field
|
|
2019
|
+
* of that same protocol. Nothing branches on it inside this runtime.
|
|
2020
|
+
*/
|
|
2021
|
+
const POST_TOOL_SURFACE_EXIT = 2;
|
|
2022
|
+
/**
|
|
2023
|
+
* One machine-readable line on stderr. Never a verdict, and never blocking.
|
|
2024
|
+
*
|
|
2025
|
+
* Exit 0 for the report that landed, {@link POST_TOOL_SURFACE_EXIT} for every
|
|
2026
|
+
* other code, so that a report which did NOT land is seen rather than written
|
|
2027
|
+
* to a debug log nobody opens (see {@link POST_TOOL_CODES}).
|
|
2028
|
+
*/
|
|
2029
|
+
function report(streams, code, detail, extra = {}) {
|
|
2030
|
+
streams.err(`${JSON.stringify({ approval: { hook: "post-tool-use", code, detail, ...extra } })}\n`);
|
|
2031
|
+
return code === POST_TOOL_REPORTED ? EXIT_OK : POST_TOOL_SURFACE_EXIT;
|
|
2032
|
+
}
|
|
2033
|
+
/**
|
|
2034
|
+
* Read a tool call's outcome off the reporting event, by a CLOSED set of
|
|
2035
|
+
* readings.
|
|
2036
|
+
*
|
|
2037
|
+
* ## THE EVENT NAME IS THE OUTCOME (APRV-303)
|
|
2038
|
+
*
|
|
2039
|
+
* The reading this replaces was written against a payload Claude Code does not
|
|
2040
|
+
* send. It asked for `tool_response.type` and accepted `text`, `base64` or
|
|
2041
|
+
* `error`, which is the shape of an API content block. What the event actually
|
|
2042
|
+
* carries under `tool_response` is the TOOL'S OWN structured output, verbatim,
|
|
2043
|
+
* and the hooks reference says so in as many words. From the shipped
|
|
2044
|
+
* declarations in `@anthropic-ai/claude-code/sdk-tools.d.ts`:
|
|
2045
|
+
*
|
|
2046
|
+
* - `BashOutput` has `stdout`, `stderr`, `interrupted`, `isImage` and no `type`;
|
|
2047
|
+
* - `FileEditOutput` (Edit, MultiEdit) has `filePath`, `oldString`,
|
|
2048
|
+
* `newString`, `structuredPatch` and no `type`;
|
|
2049
|
+
* - `FileWriteOutput` (Write) does have `type`, whose values are `create` and
|
|
2050
|
+
* `update`;
|
|
2051
|
+
* - `NotebookEditOutput` has no `type` and an optional `error` string.
|
|
2052
|
+
*
|
|
2053
|
+
* So the old reading matched NOTHING a Claude Code session emits, and every
|
|
2054
|
+
* successful tool call was reported unreadable and appended nothing. Measured
|
|
2055
|
+
* on this project's own log on 2026-09-07: 22062 harness starts, 10 reports,
|
|
2056
|
+
* and of the reports the `agent:claude-code` actor filed, nine were failures
|
|
2057
|
+
* and none was a completion. The §10.2 streak became a ratchet that only ever
|
|
2058
|
+
* counts up, so every long session escalated itself to manual and stayed there.
|
|
2059
|
+
*
|
|
2060
|
+
* The contract that IS true is the one the reference states about the events
|
|
2061
|
+
* themselves. `PostToolUse` "runs immediately after a tool completes
|
|
2062
|
+
* successfully". `PostToolUseFailure` runs "when a tool that started executing
|
|
2063
|
+
* fails". Claude Code fires exactly one of the two, neither of them when a
|
|
2064
|
+
* permission decision stopped the call before it ran. The event name is
|
|
2065
|
+
* therefore the whole reading, and it is the reading with the best provenance
|
|
2066
|
+
* available here: it is the harness saying which of its own two code paths ran,
|
|
2067
|
+
* rather than this process inferring an outcome out of a body of text.
|
|
2068
|
+
*
|
|
2069
|
+
* ## The refinements, and their direction
|
|
2070
|
+
*
|
|
2071
|
+
* Two readings of `tool_response` sit on top, and BOTH of them only ever move
|
|
2072
|
+
* the answer away from "completed" (§11.1 invariant 4: a field the reporting
|
|
2073
|
+
* side authors may raise scrutiny and never lower it):
|
|
2074
|
+
*
|
|
2075
|
+
* - `interrupted: true` (`BashOutput`) is UNREADABLE. A command a person
|
|
2076
|
+
* interrupted neither completed nor failed on its own terms; counting it a
|
|
2077
|
+
* failure trips an escalation on somebody's ctrl-C, and counting it a
|
|
2078
|
+
* completion clears a streak on a command that never finished.
|
|
2079
|
+
* - `type: "error"`, or a non-empty `error` string (`NotebookEditOutput`, and
|
|
2080
|
+
* the MCP error result the reference names) is a FAILURE, whatever the event
|
|
2081
|
+
* name claimed.
|
|
2082
|
+
*
|
|
2083
|
+
* Unreadable means append nothing, and that is the safe answer in both
|
|
2084
|
+
* directions at once. A failure nobody observed would trip an escalation on
|
|
2085
|
+
* noise, and a control that trips on noise is one operators learn to silence
|
|
2086
|
+
* (§8 makes this argument about timestamp anomalies). A completion nobody
|
|
2087
|
+
* observed would clear a streak on nothing. Appending nothing leaves the path
|
|
2088
|
+
* exactly as vacuous as it was before this verb existed, for that tool, and
|
|
2089
|
+
* manufactures neither. Since APRV-303 the unreadable arm also SAYS SO on a
|
|
2090
|
+
* stream somebody reads (see {@link report}).
|
|
2091
|
+
*
|
|
2092
|
+
* NOTHING OF THE TOOL'S OUTPUT IS READ. Only the shape: the event name, and
|
|
2093
|
+
* whether two enumerated fields are present and what kind of value they hold.
|
|
2094
|
+
* No text from any of them reaches the log or this function's return.
|
|
2095
|
+
*/
|
|
2096
|
+
function readReportedOutcome(input, adapter) {
|
|
2097
|
+
if (adapter.kind === "codex")
|
|
2098
|
+
return readCodexReportedOutcome(input);
|
|
2099
|
+
const event = input.hookEventName;
|
|
2100
|
+
if (event !== "PostToolUse" && event !== "PostToolUseFailure") {
|
|
2101
|
+
return {
|
|
2102
|
+
ok: false,
|
|
2103
|
+
detail: `hook_event_name is ${event === null ? "absent" : JSON.stringify(event)}, which is neither of the two events this adapter reports an outcome for (PostToolUse, PostToolUseFailure)`,
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
const response = input.toolResponse;
|
|
2107
|
+
// The one thing that unreads an event of either name. `PostToolUseFailure`
|
|
2108
|
+
// carries `is_interrupt` for the same fact and no `tool_response` at all, so
|
|
2109
|
+
// both spellings are checked and neither is trusted to say anything else.
|
|
2110
|
+
if (response?.["interrupted"] === true || input.interrupted === true) {
|
|
2111
|
+
return {
|
|
2112
|
+
ok: false,
|
|
2113
|
+
detail: "the tool call was interrupted, so it neither completed nor failed on its own terms; an interruption is somebody stopping the session rather than a loop to escalate or a recovery to credit",
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
if (event === "PostToolUseFailure")
|
|
2117
|
+
return { ok: true, outcome: "failed" };
|
|
2118
|
+
const errorText = response?.["error"];
|
|
2119
|
+
if (response?.["type"] === "error" ||
|
|
2120
|
+
(typeof errorText === "string" && errorText.length > 0)) {
|
|
2121
|
+
return { ok: true, outcome: "failed" };
|
|
2122
|
+
}
|
|
2123
|
+
return { ok: true, outcome: "completed" };
|
|
2124
|
+
}
|
|
2125
|
+
/**
|
|
2126
|
+
* The post-execution half of `approval hook <harness>` (APRV-145).
|
|
2127
|
+
*
|
|
2128
|
+
* It closes the delegated `execution.started` records the pre-execution half
|
|
2129
|
+
* wrote for this same tool call, so that the harness scopes of amended
|
|
2130
|
+
* SPEC.md §10.2 have a failure signal to accrue at all. Everything that makes
|
|
2131
|
+
* that safe lives in `core/gate.ts`'s `finishHarnessExecution`; what lives here
|
|
2132
|
+
* is the reading of the event and nothing else.
|
|
2133
|
+
*/
|
|
2134
|
+
function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
|
|
2135
|
+
if (input.toolName !== adapter.shellTool && !adapter.fileTools.includes(input.toolName)) {
|
|
2136
|
+
return report(streams, "post-tool-not-gated", `${input.toolName} is not a gated tool, so no execution.started was ever written for it`);
|
|
2137
|
+
}
|
|
2138
|
+
if (input.toolUseId === null) {
|
|
2139
|
+
// The pre-execution half falls back to random bytes when the harness names
|
|
2140
|
+
// no tool-use id, and those bytes are not recoverable from this event. The
|
|
2141
|
+
// start stands, unclosed, and is counted in the coverage row of
|
|
2142
|
+
// `approval status` rather than closed against a guess.
|
|
2143
|
+
return report(streams, "post-tool-unidentified", "the event carries no tool_use_id, so the task id the pre-execution hook minted cannot be reconstructed; nothing was appended");
|
|
2144
|
+
}
|
|
2145
|
+
const reading = readReportedOutcome(input, adapter);
|
|
2146
|
+
if (!reading.ok) {
|
|
2147
|
+
return report(streams, "post-tool-unreadable-outcome", `${reading.detail}; nothing was appended`);
|
|
2148
|
+
}
|
|
2149
|
+
const { logPath, root } = hookScope(flags, cwd);
|
|
2150
|
+
if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
|
|
2151
|
+
return report(streams, "post-tool-log-unreachable", `no log at ${logPath}; the hook writes to an existing log and never creates one. Run \`approval init\` in ${root}`);
|
|
2152
|
+
}
|
|
2153
|
+
const finished = finishHarnessExecution(logPath, {
|
|
2154
|
+
sessionId: adapter.kind === "codex" ? codexBinding(input, cwd).finishSessionId : input.sessionId,
|
|
2155
|
+
toolUseId: adapter.kind === "codex" ? codexBinding(input, cwd).finishToolUseId : input.toolUseId,
|
|
2156
|
+
outcome: reading.outcome,
|
|
2157
|
+
// The one member of the closed set at v0.1. It names the untrusted
|
|
2158
|
+
// reporter and reduces nothing.
|
|
2159
|
+
reportedBy: "post-tool-use",
|
|
2160
|
+
}, actor);
|
|
2161
|
+
if (!finished.ok) {
|
|
2162
|
+
return report(streams, `post-tool-gate-refused:${finished.code}`, finished.message);
|
|
2163
|
+
}
|
|
2164
|
+
return report(streams, POST_TOOL_REPORTED, `recorded ${reading.outcome} for ${String(finished.records.length)} delegated execution(s) of ${finished.task}`, { task: finished.task, outcome: reading.outcome, appended: finished.records.length });
|
|
2165
|
+
}
|
|
2166
|
+
function describeToolCall(input, adapter, protectedPaths, cwd) {
|
|
2167
|
+
if (adapter.kind === "codex" && input.toolName === "apply_patch") {
|
|
2168
|
+
const raw = readString(input.toolInput, "command");
|
|
2169
|
+
if (raw === null) {
|
|
2170
|
+
return { kind: "deny", code: "hook-io", detail: "apply_patch tool_input carries no command string" };
|
|
2171
|
+
}
|
|
2172
|
+
const parsed = parseApplyPatch(raw);
|
|
2173
|
+
if (!parsed.ok)
|
|
2174
|
+
return { kind: "deny", code: "hook-io", detail: parsed.detail };
|
|
2175
|
+
const classified = classifyApplyPatch(parsed, cwd, protectedPaths);
|
|
2176
|
+
if (!classified.ok)
|
|
2177
|
+
return { kind: "deny", code: "hook-io", detail: classified.detail };
|
|
2178
|
+
return {
|
|
2179
|
+
kind: "gated",
|
|
2180
|
+
classes: classified.classes,
|
|
2181
|
+
payload: codexBinding(input, cwd).payload,
|
|
2182
|
+
headline: `apply_patch ${classified.operations.length} operation(s)`,
|
|
2183
|
+
notes: classified.targets.map((target) => `${target.role} ${target.path} (${target.classes.join(", ")})`),
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
if (input.toolName === adapter.shellTool) {
|
|
2187
|
+
const raw = readString(input.toolInput, "command");
|
|
2188
|
+
if (raw === null) {
|
|
2189
|
+
return {
|
|
2190
|
+
kind: "deny",
|
|
2191
|
+
code: "hook-io",
|
|
2192
|
+
detail: `${adapter.shellTool} tool_input carries no command string`,
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
// Unchanged since APRV-117, deliberately: the payload is the WHOLE command
|
|
2196
|
+
// and the directory it runs in, so the FULL PAYLOAD block on the phone
|
|
2197
|
+
// carries every byte the harness will execute. Only `summary` is shortened.
|
|
2198
|
+
const payload = adapter.bindToolName
|
|
2199
|
+
? codexBinding(input, cwd).payload
|
|
2200
|
+
: { command: raw, cwd: input.cwd };
|
|
2201
|
+
// APRV-108: a local rewrite of history this checkout never published is a
|
|
2202
|
+
// commit. APRV-267: a delete confined to the agent's own scratch is not a
|
|
2203
|
+
// decision. Both run in the hook's own cwd, after classification and never
|
|
2204
|
+
// inside it, and neither claims anything it cannot establish from the disk.
|
|
2205
|
+
const refined = classifyForHook(raw, protectedPaths, cwd);
|
|
2206
|
+
const classified = refined.result;
|
|
2207
|
+
if (!classified.ok) {
|
|
2208
|
+
return {
|
|
2209
|
+
kind: "deny",
|
|
2210
|
+
code: `hook-${classified.code}`,
|
|
2211
|
+
detail: `${classified.detail} (segment: ${classified.segment}). Rewrite it as a command the classifier can read, or run the effect through \`approval run\` with a granted token.`,
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
const classes = classified.classes.filter((cls) => cls !== GATE_SELF_CLASS);
|
|
2215
|
+
if (adapter.kind === "codex") {
|
|
2216
|
+
// The pure shell classifier sees each segment independently. Preserve
|
|
2217
|
+
// Codex hook organs when an earlier simple `cd` changes the directory or
|
|
2218
|
+
// when the hook itself runs inside an organ directory by resolving every
|
|
2219
|
+
// later side-effecting segment's words from the effective directory.
|
|
2220
|
+
const possibleCwds = new Set([cwd]);
|
|
2221
|
+
for (const segment of classified.segments) {
|
|
2222
|
+
const parsedWords = commandSegmentWords(segment.text)?.[0];
|
|
2223
|
+
if (parsedWords?.bin === "cd" && parsedWords.args.length !== 1) {
|
|
2224
|
+
return {
|
|
2225
|
+
kind: "deny",
|
|
2226
|
+
code: "hook-io",
|
|
2227
|
+
detail: "Codex Bash cwd changes must use exact `cd <directory>` with no additional words",
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
if (parsedWords?.bin === "cd" &&
|
|
2231
|
+
(parsedWords.args[0] === "-" ||
|
|
2232
|
+
(!isAbsolute(parsedWords.args[0] ?? "") &&
|
|
2233
|
+
parsedWords.args[0] !== "." &&
|
|
2234
|
+
parsedWords.args[0] !== ".." &&
|
|
2235
|
+
!(parsedWords.args[0] ?? "").startsWith("./") &&
|
|
2236
|
+
!(parsedWords.args[0] ?? "").startsWith("../")))) {
|
|
2237
|
+
return {
|
|
2238
|
+
kind: "deny",
|
|
2239
|
+
code: "hook-io",
|
|
2240
|
+
detail: "Codex Bash cwd changes must name an absolute path, `.`, `..`, `./...`, or `../...`; OLDPWD and CDPATH-dependent operands are unsupported",
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
if (isSideEffectingClass(segment.class)) {
|
|
2244
|
+
for (const possibleCwd of possibleCwds) {
|
|
2245
|
+
const cwdSegments = possibleCwd.split(/[/\\]+/u);
|
|
2246
|
+
const cwdClass = cwdSegments.includes(".codex")
|
|
2247
|
+
? "policy.core"
|
|
2248
|
+
: protectedPathClass(possibleCwd, protectedPaths);
|
|
2249
|
+
if (cwdClass !== null && !classes.includes(cwdClass))
|
|
2250
|
+
classes.push(cwdClass);
|
|
2251
|
+
for (const word of parsedWords === undefined ? [] : [parsedWords.bin, ...parsedWords.args]) {
|
|
2252
|
+
const cls = protectedPathClass(resolvePathSegments(possibleCwd, word), protectedPaths);
|
|
2253
|
+
if (cls !== null && !classes.includes(cls))
|
|
2254
|
+
classes.push(cls);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
if (parsedWords?.bin === "cd" && parsedWords.args.length === 1) {
|
|
2259
|
+
// Lists and conditionals may skip a cd. Retain every prior directory
|
|
2260
|
+
// and add each directory the cd could establish; later writes are
|
|
2261
|
+
// checked against their union.
|
|
2262
|
+
const priorCwds = Array.from(possibleCwds);
|
|
2263
|
+
for (const possibleCwd of priorCwds) {
|
|
2264
|
+
const lexical = resolvePathSegments(possibleCwd, parsedWords.args[0] ?? "");
|
|
2265
|
+
possibleCwds.add(lexical);
|
|
2266
|
+
try {
|
|
2267
|
+
possibleCwds.add(realpathSync(lexical));
|
|
2268
|
+
}
|
|
2269
|
+
catch {
|
|
2270
|
+
return {
|
|
2271
|
+
kind: "deny",
|
|
2272
|
+
code: "hook-io",
|
|
2273
|
+
detail: `Codex Bash cd target ${JSON.stringify(parsedWords.args[0])} could not be resolved`,
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
if (possibleCwds.size > 64) {
|
|
2277
|
+
return {
|
|
2278
|
+
kind: "deny",
|
|
2279
|
+
code: "hook-io",
|
|
2280
|
+
detail: "Codex Bash command has more than 64 possible working directories",
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
return {
|
|
2288
|
+
kind: "gated",
|
|
2289
|
+
classes,
|
|
2290
|
+
payload,
|
|
2291
|
+
headline: raw,
|
|
2292
|
+
notes: refined.notes,
|
|
2293
|
+
segments: classified.segments,
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
const gated = fileToolGate(input.toolName, input.toolInput, protectedPaths, cwd);
|
|
2297
|
+
if (gated === null) {
|
|
2298
|
+
return { kind: "allow", reason: `${input.toolName} names no file, so there is nothing to gate` };
|
|
2299
|
+
}
|
|
2300
|
+
return {
|
|
2301
|
+
kind: "gated",
|
|
2302
|
+
classes: [gated.cls],
|
|
2303
|
+
payload: gated.payload,
|
|
2304
|
+
headline: gated.summary,
|
|
2305
|
+
// The tier rides in the verdict's note as well as in the payload, so an
|
|
2306
|
+
// `allow` says which checkout it authorized (APRV-124).
|
|
2307
|
+
notes: gated.protectedPath ? [fileTierNote(gated)] : [],
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2310
|
+
/** The environment variable that turns the sandbox requirement on (APRV-193). */
|
|
2311
|
+
export const REQUIRE_SANDBOX_ENV = "APPROVAL_HOOK_REQUIRE_SANDBOX";
|
|
2312
|
+
/**
|
|
2313
|
+
* Must this command have been written `approval sandbox -- …`? (APRV-193.)
|
|
2314
|
+
*
|
|
2315
|
+
* Returns the deny detail, or `null` to proceed. Four conditions, and every one
|
|
2316
|
+
* of them is a narrowing, so the answer is `null` for everything the operator
|
|
2317
|
+
* did not deliberately ask about:
|
|
2318
|
+
*
|
|
2319
|
+
* 1. the operator set `APPROVAL_HOOK_REQUIRE_SANDBOX=1`;
|
|
2320
|
+
* 2. some segment runs code this runtime did not author
|
|
2321
|
+
* (`CODE_EXECUTING_RULES`: `npm test`, `node x.mjs`, `tsc`, `make`…);
|
|
2322
|
+
* 3. that segment is not already inside the runtime's own wrapper. A
|
|
2323
|
+
* hand-written `sandbox-exec -f mine.sb` does NOT satisfy it, because a
|
|
2324
|
+
* profile a caller wrote can allow everything, and a requirement met by
|
|
2325
|
+
* writing your own permission is not a requirement;
|
|
2326
|
+
* 4. no class of the command is manual. A manual command is going to a human,
|
|
2327
|
+
* and a human's grant over these exact bytes is the authority to reach the
|
|
2328
|
+
* world — the same line `approval run` draws at the token.
|
|
2329
|
+
*
|
|
2330
|
+
* The environment variable is read in the strict direction only: setting it can
|
|
2331
|
+
* refuse commands that would otherwise run, and nothing an agent can set makes
|
|
2332
|
+
* this function return `null` where it would otherwise deny (SPEC.md §11.1
|
|
2333
|
+
* invariant 4).
|
|
2334
|
+
*/
|
|
2335
|
+
export function sandboxRequirement(segments, autonomies, env = process.env) {
|
|
2336
|
+
if (env[REQUIRE_SANDBOX_ENV] !== "1")
|
|
2337
|
+
return null;
|
|
2338
|
+
if (segments === undefined)
|
|
2339
|
+
return null;
|
|
2340
|
+
if (autonomies.some((autonomy) => autonomy === "manual"))
|
|
2341
|
+
return null;
|
|
2342
|
+
const unwrapped = segments.filter((segment) => CODE_EXECUTING_RULES.includes(segment.rule) && segment.sandbox !== "runtime");
|
|
2343
|
+
if (unwrapped.length === 0)
|
|
2344
|
+
return null;
|
|
2345
|
+
const first = unwrapped[0];
|
|
2346
|
+
const external = first.sandbox === "external";
|
|
2347
|
+
return `${REQUIRE_SANDBOX_ENV}=1, and this command runs code the runtime did not author: ${JSON.stringify(first.text)} (rule ${first.rule}), ${external ? "under a profile this runtime did not write, which is a permission you granted yourself" : "with the session's own network"}. A command like this executes whatever is in the files it names, so its class describes what was typed rather than what will happen. Re-run it as \`approval sandbox -- <command>\`: it classifies the same, it is allowed the same, and it runs with no way out to the network (docs/sandboxed-exec.md). Nothing was appended.`;
|
|
2348
|
+
}
|
|
2349
|
+
/**
|
|
2350
|
+
* Is a window open over this log?
|
|
2351
|
+
*
|
|
2352
|
+
* Fails closed on every axis and reports NOTHING when it does. An absent log,
|
|
2353
|
+
* an unreadable one, a torn tail, a chain that does not verify: each yields no
|
|
2354
|
+
* window, and the caller falls through to the path it has always taken, where
|
|
2355
|
+
* `hook-log-unreachable` and `hook-io` fire in the same words at the same
|
|
2356
|
+
* places. A bypass derived from bytes nobody verified would be a bypass anyone
|
|
2357
|
+
* able to write the file could grant themselves, which is the whole reason the
|
|
2358
|
+
* window's state lives in the log rather than beside it.
|
|
2359
|
+
*
|
|
2360
|
+
* The existence probe is the same one the gated path makes further down, and it
|
|
2361
|
+
* is made FIRST so that a hook pointed at a directory with no log does no
|
|
2362
|
+
* verification work before saying so.
|
|
2363
|
+
*/
|
|
2364
|
+
function lookupWindow(logPath) {
|
|
2365
|
+
if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
|
|
2366
|
+
return { window: null, records: null, head: null };
|
|
2367
|
+
}
|
|
2368
|
+
const read = readVerifiedRecords(logPath);
|
|
2369
|
+
if (!read.ok)
|
|
2370
|
+
return { window: null, records: null, head: null };
|
|
2371
|
+
return { window: openGateWindow(read.records), records: read.records, head: read.head };
|
|
2372
|
+
}
|
|
2373
|
+
/**
|
|
2374
|
+
* The banner every bypassed call prints to STDERR.
|
|
2375
|
+
*
|
|
2376
|
+
* Loud, and on stderr rather than in the decision reason, because the two have
|
|
2377
|
+
* different readers: the reason is read by the harness and by the agent, and
|
|
2378
|
+
* this is read by the person who opened the window and may have forgotten it is
|
|
2379
|
+
* open. It names the seq of the record that authorized the bypass and the one
|
|
2380
|
+
* that recorded it, so both ends are greppable from the log alone.
|
|
2381
|
+
*/
|
|
2382
|
+
function bypassBanner(window, classes, seq) {
|
|
2383
|
+
return [
|
|
2384
|
+
"!! APPROVAL GATE OPEN — this command was NOT approved !!",
|
|
2385
|
+
` window seq ${String(window.seq)}, opened by ${window.openedBy}, expires ${window.expiresAt}`,
|
|
2386
|
+
` reason: ${window.reason}`,
|
|
2387
|
+
` classes: ${classes.join(", ")}; recorded as gate.bypassed seq ${String(seq)}`,
|
|
2388
|
+
" close it with `approval gate close`",
|
|
2389
|
+
"",
|
|
2390
|
+
].join("\n");
|
|
2391
|
+
}
|
|
2392
|
+
/**
|
|
2393
|
+
* The bypass path: classify anyway, refuse the things the window never reaches,
|
|
2394
|
+
* record the call, and only then allow it.
|
|
2395
|
+
*
|
|
2396
|
+
* ### What the window does NOT reach, and why each one stays
|
|
2397
|
+
*
|
|
2398
|
+
* - **A command the classifier cannot read** (`hook-opaque`,
|
|
2399
|
+
* `hook-unclassified`, `hook-unparseable`). The window is a suspension of the
|
|
2400
|
+
* policy's ANSWER, and an opaque command has no question to suspend: nothing
|
|
2401
|
+
* here can establish that a `bash -c` string does not write into the log.
|
|
2402
|
+
* - **`log.mutate`.** The window suspends the policy; the log is what the
|
|
2403
|
+
* window itself is derived from, and a bypass that could rewrite the log
|
|
2404
|
+
* could rewrite its own authorization. Refused unconditionally, with no
|
|
2405
|
+
* policy consulted, because this rule is not the policy's to relax.
|
|
2406
|
+
* - **Any class the policy reserves to human hands** (§11.1 invariant 9). A
|
|
2407
|
+
* human-only class is inert to agents by construction, and a window opened by
|
|
2408
|
+
* a human does not lend an agent the human's hands.
|
|
2409
|
+
*
|
|
2410
|
+
* ### The policy is loaded, best-effort
|
|
2411
|
+
*
|
|
2412
|
+
* For the protected-path set the classifier needs, and for the human-only
|
|
2413
|
+
* check. A policy that will not load is exactly the failure a window is opened
|
|
2414
|
+
* to repair, so a load failure is a NOTE on the verdict rather than a refusal;
|
|
2415
|
+
* the protected-path set is then empty and the human-only check has nothing to
|
|
2416
|
+
* resolve against, which the note says in as many words.
|
|
2417
|
+
*
|
|
2418
|
+
* ### Record, then allow
|
|
2419
|
+
*
|
|
2420
|
+
* §11.1 invariant 8, and the same order `recordUnattended` uses: a bypassed
|
|
2421
|
+
* command that ran and left no record is the one state this feature must not be
|
|
2422
|
+
* able to reach, so an append failure is a deny.
|
|
2423
|
+
*/
|
|
2424
|
+
function runBypass(streams, input, adapter, cwd, logPath, flags, actor, window,
|
|
2425
|
+
/**
|
|
2426
|
+
* The verified read `window` was derived from (APRV-294), handed on to the
|
|
2427
|
+
* append so the same records answer "is a window open" and "which head does
|
|
2428
|
+
* this record chain onto". `null` is not reachable from the caller — a window
|
|
2429
|
+
* implies a read that produced it — and is accepted so the seam has one
|
|
2430
|
+
* shape.
|
|
2431
|
+
*/
|
|
2432
|
+
decidedOn) {
|
|
2433
|
+
const codexCommand = adapter.kind === "codex" ? codexBinding(input, cwd).payload.command : undefined;
|
|
2434
|
+
const scope = hookScope(flags, cwd);
|
|
2435
|
+
const load = loadPolicy(scope.options.policy?.file === undefined
|
|
2436
|
+
? { dir: scope.options.policy?.dir ?? cwd }
|
|
2437
|
+
: { file: scope.options.policy.file });
|
|
2438
|
+
const protectedPaths = load.ok ? (load.policy.protected_paths ?? []) : [];
|
|
2439
|
+
const policyNote = load.ok
|
|
2440
|
+
? null
|
|
2441
|
+
: `the policy did not load (${load.code}: ${load.message}), so no protected path beyond the built-ins was known here and no class could be resolved to human-only`;
|
|
2442
|
+
const described = describeToolCall(input, adapter, protectedPaths, cwd);
|
|
2443
|
+
if (described.kind === "deny") {
|
|
2444
|
+
return deny(streams, described.code, `${described.detail} The open window does not reach this: a command the classifier cannot read is a command nothing here can establish is safe to run unapproved.`, adapter.kind);
|
|
2445
|
+
}
|
|
2446
|
+
if (described.kind === "allow") {
|
|
2447
|
+
return allow(streams, described.reason, adapter.kind, codexCommand);
|
|
2448
|
+
}
|
|
2449
|
+
const classes = described.classes;
|
|
2450
|
+
if (classes.length === 0) {
|
|
2451
|
+
// The gate's own CLI, including `approval gate close`. Allowed with no
|
|
2452
|
+
// record for the reason it is allowed outside a window: gating the gate
|
|
2453
|
+
// with the gate recurses, and a window that recorded its own closing verb
|
|
2454
|
+
// would be recording the act that ends it.
|
|
2455
|
+
return allow(streams, "the approval CLI is the gate itself and is not gated by it", adapter.kind, codexCommand);
|
|
2456
|
+
}
|
|
2457
|
+
const mutation = classes.find((cls) => cls === "log.mutate");
|
|
2458
|
+
if (mutation !== undefined) {
|
|
2459
|
+
return deny(streams, "hook-class-human-only", `${mutation} is never reachable through the open window: the window suspends the POLICY, and the log is what the window itself is derived from. A bypass able to write the log could rewrite its own authorization. Nothing was appended; a human writes the log directory by hand or not at all.`, adapter.kind);
|
|
2460
|
+
}
|
|
2461
|
+
if (load.ok) {
|
|
2462
|
+
const reserved = classes.find((cls) => resolvePolicy(load, cls).autonomy === "human-only");
|
|
2463
|
+
if (reserved !== undefined) {
|
|
2464
|
+
return deny(streams, "hook-class-human-only", `${humanOnlyRefusal(reserved, "this command may not run under an agent")} An open window does not reach it: the window suspends what the policy DECIDES, and a human-only class is one the policy reserves to human hands, which a window opened by a human does not lend to an agent (SPEC.md §11.1 invariant 9).`, adapter.kind);
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
// APRV-227, resolved here because here is where a record is written. The
|
|
2468
|
+
// window path is the one a human comes back to read, so the binary that
|
|
2469
|
+
// printed the allow is named on it.
|
|
2470
|
+
const provenance = harnessProvenance(adapter.kind, input.harnessVersion);
|
|
2471
|
+
const recorded = recordGateBypass(logPath, {
|
|
2472
|
+
tool: input.toolName,
|
|
2473
|
+
summary: truncate(described.headline, SUMMARY_LIMIT),
|
|
2474
|
+
classes,
|
|
2475
|
+
payloadHash: payloadHash(described.payload),
|
|
2476
|
+
...(input.sessionId === UNKNOWN_SESSION ? {} : { sessionId: input.sessionId }),
|
|
2477
|
+
...(input.toolUseId === null ? {} : { toolUseId: input.toolUseId }),
|
|
2478
|
+
...(input.cwd.length === 0 ? {} : { cwd: input.cwd }),
|
|
2479
|
+
...(provenance === null ? {} : { harness: provenance }),
|
|
2480
|
+
}, actor, {},
|
|
2481
|
+
// APRV-294: the window this verdict was decided under, and the read it was
|
|
2482
|
+
// decided on. The append uses both, so a window that ended in between is
|
|
2483
|
+
// reported as the thing that happened rather than as "no window is open".
|
|
2484
|
+
{
|
|
2485
|
+
openedSeq: window.seq,
|
|
2486
|
+
...(decidedOn === null ? {} : { read: decidedOn }),
|
|
2487
|
+
});
|
|
2488
|
+
if (!recorded.ok) {
|
|
2489
|
+
// Invariant 8: the record lands before the allow, so a refusal here is a
|
|
2490
|
+
// deny even though a window is open. `append-failed` reaches the caller
|
|
2491
|
+
// through the family reserved for a code the writer produced, and so does
|
|
2492
|
+
// `gate-window-closed` (APRV-294), which says the window stood when this
|
|
2493
|
+
// process classified the command and does not stand now.
|
|
2494
|
+
return deny(streams, `hook-gate-refused:${recorded.code}`, `${recorded.message} A window being open does not let a call run unrecorded: the record is what makes the bypass reviewable, so nothing runs without it.`, adapter.kind);
|
|
2495
|
+
}
|
|
2496
|
+
streams.err(bypassBanner(window, classes, recorded.record.seq));
|
|
2497
|
+
const notes = [...described.notes, ...(policyNote === null ? [] : [policyNote])];
|
|
2498
|
+
return allow(streams, `gate-open: ${classes.join(", ")} bypassed by the window opened at seq ${String(window.seq)} by ${window.openedBy} (expires ${window.expiresAt}); recorded as gate.bypassed seq ${String(recorded.record.seq)}${notes.length === 0 ? "" : ` (${notes.join("; ")})`}`, adapter.kind, codexCommand);
|
|
2499
|
+
}
|
|
2500
|
+
function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
2501
|
+
const configurationError = (message) => adapter.kind === "codex" ? deny(streams, "hook-io", message, adapter.kind) : usageError(streams, message);
|
|
2502
|
+
const parsed = parseFlags(argv, {
|
|
2503
|
+
...COMMON_FLAGS,
|
|
2504
|
+
...POLICY_FLAGS,
|
|
2505
|
+
"--log": "string",
|
|
2506
|
+
"--as": "string",
|
|
2507
|
+
"--timeout": "string",
|
|
2508
|
+
"--interval": "string",
|
|
2509
|
+
"--retry-grace": "string",
|
|
2510
|
+
});
|
|
2511
|
+
if (!parsed.ok)
|
|
2512
|
+
return configurationError(parsed.message);
|
|
2513
|
+
if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
|
|
2514
|
+
streams.out(`${HOOK_HELP}\n`);
|
|
2515
|
+
return EXIT_OK;
|
|
2516
|
+
}
|
|
2517
|
+
const extra = parsed.positionals[0];
|
|
2518
|
+
if (extra !== undefined) {
|
|
2519
|
+
return configurationError(`unexpected argument ${JSON.stringify(extra)}`);
|
|
2520
|
+
}
|
|
2521
|
+
const asFlag = stringFlag(parsed.flags, "--as");
|
|
2522
|
+
const actor = asFlag ?? adapter.defaultActor;
|
|
2523
|
+
if (!PRINCIPAL_ACTOR.test(actor)) {
|
|
2524
|
+
return configurationError(`--as expects agent:<id> or human:<id>, got ${JSON.stringify(asFlag)}`);
|
|
2525
|
+
}
|
|
2526
|
+
const timeoutText = stringFlag(parsed.flags, "--timeout") ?? DEFAULT_TIMEOUT;
|
|
2527
|
+
const timeoutMs = parseDuration(timeoutText);
|
|
2528
|
+
if (timeoutMs === null) {
|
|
2529
|
+
return configurationError(`--timeout expects a duration like 30s, 9m, got ${JSON.stringify(timeoutText)}`);
|
|
2530
|
+
}
|
|
2531
|
+
const intervalText = stringFlag(parsed.flags, "--interval");
|
|
2532
|
+
const intervalMs = intervalText === null ? DEFAULT_INTERVAL_MS : parseDuration(intervalText);
|
|
2533
|
+
if (intervalMs === null) {
|
|
2534
|
+
return configurationError(`--interval expects a duration like 500ms, 2s, got ${JSON.stringify(intervalText)}`);
|
|
2535
|
+
}
|
|
2536
|
+
// APRV-287. How long the question outlives the wait, for the retry that
|
|
2537
|
+
// adopts it. The duration grammar has no zero, so the shortest window is
|
|
2538
|
+
// `1ms`, which withdraws as the wait expires.
|
|
2539
|
+
const graceText = stringFlag(parsed.flags, "--retry-grace");
|
|
2540
|
+
const graceMs = graceText === null ? HOOK_RETRY_GRACE_MS : parseDuration(graceText);
|
|
2541
|
+
if (graceMs === null) {
|
|
2542
|
+
return configurationError(`--retry-grace expects a duration like 5m, 30s, 1ms, got ${JSON.stringify(graceText)}`);
|
|
2543
|
+
}
|
|
2544
|
+
const parsedInput = parseHookInput(readStdin());
|
|
2545
|
+
if (!parsedInput.ok)
|
|
2546
|
+
return deny(streams, "hook-io", parsedInput.detail, adapter.kind);
|
|
2547
|
+
const input = parsedInput.input;
|
|
2548
|
+
if (adapter.kind === "codex") {
|
|
2549
|
+
const checked = checkCodexHookInput(input, cwd);
|
|
2550
|
+
if (!checked.ok)
|
|
2551
|
+
return deny(streams, "hook-io", checked.detail, adapter.kind);
|
|
2552
|
+
}
|
|
2553
|
+
const codexCommand = adapter.kind === "codex" ? codexBinding(input, cwd).payload.command : undefined;
|
|
2554
|
+
// APRV-145: WHICH EVENT THIS IS, read first and read at all. One command is
|
|
2555
|
+
// registered for two events, and they do opposite things — one answers before
|
|
2556
|
+
// the tool runs, the other records how it went — so the dispatch is the first
|
|
2557
|
+
// decision the verb makes.
|
|
2558
|
+
//
|
|
2559
|
+
// Anything that is not a post-execution event takes the pre-execution path,
|
|
2560
|
+
// including an event carrying no name at all. That is the strict direction: a
|
|
2561
|
+
// harness whose event this runtime does not recognize is a harness about to
|
|
2562
|
+
// run a command, and treating an unknown name as a no-op would be an ungated
|
|
2563
|
+
// one.
|
|
2564
|
+
const postToolEvent = adapter.kind === "codex"
|
|
2565
|
+
? input.hookEventName === CODEX_POST_TOOL_EVENT
|
|
2566
|
+
: input.hookEventName !== null && POST_TOOL_EVENTS.includes(input.hookEventName);
|
|
2567
|
+
if (postToolEvent) {
|
|
2568
|
+
// APRV-303. `commandHarnessHook`'s catch turns a throw into a DENY, which is
|
|
2569
|
+
// the right answer for a call that has not run yet and exactly the wrong one
|
|
2570
|
+
// here: it would print a verdict object about a tool call the harness has
|
|
2571
|
+
// already finished, and the reason the counterpart did not land would be
|
|
2572
|
+
// dressed as a permission decision. A throw on this path is `post-tool-io`,
|
|
2573
|
+
// on stderr, at the exit code that makes the line visible.
|
|
2574
|
+
try {
|
|
2575
|
+
return runPostToolUse(parsed.flags, streams, cwd, input, actor, adapter);
|
|
2576
|
+
}
|
|
2577
|
+
catch (cause) {
|
|
2578
|
+
return report(streams, "post-tool-io", `the counterpart failed: ${cause instanceof Error ? cause.message : String(cause)}; nothing was appended, so the start this event would have closed is still open`);
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
// Codex 0.152.1 can execute Bash in a per-call working directory that is
|
|
2582
|
+
// absent from tool_input while both the event cwd and this hook process stay
|
|
2583
|
+
// at the session root (APRV-310 native v6). A decision over the visible
|
|
2584
|
+
// `{command, cwd}` would therefore bind different bytes from the action the
|
|
2585
|
+
// harness executes. Refuse before the open-window, gate-self, carry, or
|
|
2586
|
+
// registration paths; none of those can supply the missing directory fact.
|
|
2587
|
+
if (adapter.kind === "codex" && input.toolName === "Bash") {
|
|
2588
|
+
return deny(streams, "hook-io", "Codex Bash is disabled because the native hook contract does not expose the effective per-call working directory; no policy or open window can authorize bytes the hook cannot bind", adapter.kind);
|
|
2589
|
+
}
|
|
2590
|
+
if (input.toolName !== adapter.shellTool && !adapter.fileTools.includes(input.toolName)) {
|
|
2591
|
+
return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
|
|
2592
|
+
}
|
|
2593
|
+
// APRV-188. From here on this process may resume a verified read behind the
|
|
2594
|
+
// snapshot the daemon published, instead of walking the chain from genesis:
|
|
2595
|
+
// the one thing a fresh process per gated tool call cannot amortize, and the
|
|
2596
|
+
// only term in a hook's cost that grows with the log. Turned on HERE rather
|
|
2597
|
+
// than at the CLI's entry point, so it covers exactly the gated path and no
|
|
2598
|
+
// other verb — `approval log verify` and every audit read stay cold.
|
|
2599
|
+
//
|
|
2600
|
+
// It changes what a read COSTS and nothing about what a read PROVES: the
|
|
2601
|
+
// prefix is admitted only against a SHA-256 this process computes over the
|
|
2602
|
+
// bytes it read itself, the head and the line count are re-derived from its
|
|
2603
|
+
// own parse, and the appended tail is walked in full. A snapshot that is
|
|
2604
|
+
// absent, stale, foreign, or wrong in any of those is ignored, and the walk
|
|
2605
|
+
// happens exactly as it does today. See `core/verified-snapshot.ts`.
|
|
2606
|
+
useVerifiedSnapshots(true);
|
|
2607
|
+
const { logPath, root, options } = hookScope(parsed.flags, cwd);
|
|
2608
|
+
// APRV-214, amended SPEC.md §5.2: the open window, looked up HERE — after the
|
|
2609
|
+
// scope is resolved and before the policy is loaded — because the whole point
|
|
2610
|
+
// of it is to be reachable when the things below are broken. A window opened
|
|
2611
|
+
// by a human puts every gated tool call through `runBypass` instead, so an
|
|
2612
|
+
// unparseable policy, a drifted attestation, a loop floor, a dark channel and
|
|
2613
|
+
// a hung daemon are all bypassed.
|
|
2614
|
+
//
|
|
2615
|
+
// The lookup is SELF-GATING, which is what makes placing it this early safe:
|
|
2616
|
+
// it reads the same verified log every enforcement path reads, and an absent,
|
|
2617
|
+
// torn or unverifiable log yields no window at all. The hook then falls
|
|
2618
|
+
// through to the path it has always taken and refuses there, in the same
|
|
2619
|
+
// words. The window suspends the POLICY; it never suspends the log.
|
|
2620
|
+
const looked = lookupWindow(logPath);
|
|
2621
|
+
if (looked.window !== null) {
|
|
2622
|
+
return runBypass(streams, input, adapter, cwd, logPath, parsed.flags, actor, looked.window,
|
|
2623
|
+
// APRV-294. The records this window was derived from travel with it: the
|
|
2624
|
+
// bypass record is appended against the head they ended at, so the
|
|
2625
|
+
// verdict and the record are one read of the log.
|
|
2626
|
+
looked.records === null ? null : { records: looked.records, head: looked.head });
|
|
2627
|
+
}
|
|
2628
|
+
// The policy is read BEFORE the command is classified (APRV-107): the
|
|
2629
|
+
// protected-path set is built-ins plus `policy.protected_paths`, so what
|
|
2630
|
+
// counts as a protected path is a policy question and the classifier cannot be
|
|
2631
|
+
// asked it without the answer in hand.
|
|
2632
|
+
//
|
|
2633
|
+
// An unloadable policy resolves everything to manual, and a manual request
|
|
2634
|
+
// needs a log this hook may not be pointed at. Fail closed and say so, rather
|
|
2635
|
+
// than opening a request nobody configured a channel for.
|
|
2636
|
+
const load = loadPolicy(options.policy?.file === undefined
|
|
2637
|
+
? { dir: options.policy?.dir ?? cwd }
|
|
2638
|
+
: { file: options.policy.file });
|
|
2639
|
+
if (!load.ok) {
|
|
2640
|
+
return deny(streams, "hook-policy-unavailable", `${load.code}: ${load.message}; every class resolves to manual and the hook cannot verify a decision`, adapter.kind);
|
|
2641
|
+
}
|
|
2642
|
+
const protectedPaths = load.policy.protected_paths ?? [];
|
|
2643
|
+
// What is being asked for, as one or more classes. One description site for
|
|
2644
|
+
// both paths since APRV-214 (see `describeToolCall`): the open window
|
|
2645
|
+
// classifies exactly as the closed one does, and a second copy of this would
|
|
2646
|
+
// be a second answer to "what is this command".
|
|
2647
|
+
const described = describeToolCall(input, adapter, protectedPaths, cwd);
|
|
2648
|
+
if (described.kind === "deny") {
|
|
2649
|
+
return deny(streams, described.code, described.detail, adapter.kind);
|
|
2650
|
+
}
|
|
2651
|
+
if (described.kind === "allow") {
|
|
2652
|
+
return allow(streams, described.reason, adapter.kind, codexCommand);
|
|
2653
|
+
}
|
|
2654
|
+
const { classes, payload, headline } = described;
|
|
2655
|
+
/** What the history-rewrite refinement did, for the decision reason. */
|
|
2656
|
+
const notes = [...described.notes];
|
|
2657
|
+
if (classes.length === 0) {
|
|
2658
|
+
return allow(streams, "the approval CLI is the gate itself and is not gated by it", adapter.kind, codexCommand);
|
|
2659
|
+
}
|
|
2660
|
+
// Every path from here needs the log, the fast paths included (APRV-139):
|
|
2661
|
+
// attestation and loop-escalation are facts about the log, so the
|
|
2662
|
+
// log-unreachable deny now sits above the autonomous verdict rather than
|
|
2663
|
+
// below it. A hook that could not reach the log used to allow whatever the
|
|
2664
|
+
// on-disk policy called autonomous; it now denies, which is the same answer
|
|
2665
|
+
// it already gave every other class.
|
|
2666
|
+
if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
|
|
2667
|
+
return deny(streams, "hook-log-unreachable", `no log at ${logPath}; the hook writes to an existing log and never creates one. Run \`approval init\` (then \`approval policy attest\`) in ${root}, or pass --log <path> to point the hook at the log that already exists`, adapter.kind);
|
|
2668
|
+
}
|
|
2669
|
+
// Minted once, here, and carried into `gateAndWait`: the loop-escalation
|
|
2670
|
+
// check below and any registration that follows must name the same task.
|
|
2671
|
+
const task = adapter.kind === "codex"
|
|
2672
|
+
? codexBinding(input, cwd).task
|
|
2673
|
+
: `hook:${input.sessionId}:${input.toolUseId ?? randomBytes(8).toString("hex")}`;
|
|
2674
|
+
const run = {
|
|
2675
|
+
logPath,
|
|
2676
|
+
options,
|
|
2677
|
+
actor,
|
|
2678
|
+
timeoutMs,
|
|
2679
|
+
intervalMs,
|
|
2680
|
+
graceMs,
|
|
2681
|
+
ttlMs: load.durations.approvalTtlMs,
|
|
2682
|
+
harness: adapter.kind,
|
|
2683
|
+
originApp: adapter.originApp,
|
|
2684
|
+
...(codexCommand === undefined ? {} : { codexCommand }),
|
|
2685
|
+
eventVersion: input.harnessVersion,
|
|
2686
|
+
// Off the policy this function already loaded and validated, so the names
|
|
2687
|
+
// printed are the names a channel process would serve (APRV-281). Sorted
|
|
2688
|
+
// for a stable line; `Object.keys` order is the file's, and a line that
|
|
2689
|
+
// changed when an operator reordered their policy would read as a change of
|
|
2690
|
+
// state.
|
|
2691
|
+
channels: Object.keys(load.policy.channels ?? {}).sort(),
|
|
2692
|
+
};
|
|
2693
|
+
const autonomies = classes.map((cls) => resolvePolicy(load, cls).autonomy);
|
|
2694
|
+
// APRV-185, amended SPEC.md §5.2, and the first verdict this function reaches
|
|
2695
|
+
// once the classes have autonomies. A command touching a class the policy
|
|
2696
|
+
// reserves to human hands is denied outright: no request is opened, no task is
|
|
2697
|
+
// registered, nothing is appended, and no human is asked — because the policy
|
|
2698
|
+
// has already answered, and there is no decision anyone could make that would
|
|
2699
|
+
// let this process run the command.
|
|
2700
|
+
//
|
|
2701
|
+
// Above the loop floor and the unattended guard deliberately. Those two route
|
|
2702
|
+
// a command TO a human's gate, and this class has no gate to be routed to; a
|
|
2703
|
+
// floor applied first would open a request nobody may grant. A command whose
|
|
2704
|
+
// classes are mixed is denied on the strength of the one human-only class, per
|
|
2705
|
+
// the classifier's existing rule that the whole command is answered by the
|
|
2706
|
+
// strictest thing in it.
|
|
2707
|
+
const reserved = classes.find((_cls, index) => autonomies[index] === "human-only");
|
|
2708
|
+
if (reserved !== undefined) {
|
|
2709
|
+
return deny(streams, "hook-class-human-only", `${humanOnlyRefusal(reserved, "this command may not run under an agent")} The gate's own code for this fact is \`class-human-only\`.`, adapter.kind);
|
|
2710
|
+
}
|
|
2711
|
+
// APRV-193, and BELOW the human-only deny for the same reason that one sits
|
|
2712
|
+
// above the floor: a class no agent may run is answered before a question
|
|
2713
|
+
// about which room it would run in. Above everything that appends, so a
|
|
2714
|
+
// refused command leaves the log exactly as it found it.
|
|
2715
|
+
const unsandboxed = sandboxRequirement(described.segments, autonomies);
|
|
2716
|
+
if (unsandboxed !== null) {
|
|
2717
|
+
return deny(streams, "hook-sandbox-required", unsandboxed, adapter.kind);
|
|
2718
|
+
}
|
|
2719
|
+
// APRV-145, amended SPEC.md §10.2: loop safety on a surface that mints a
|
|
2720
|
+
// fresh task id per tool call. The floor is applied AFTER class resolution and
|
|
2721
|
+
// never inside it, exactly as §7's irreversibility floor is: `resolve` is pure
|
|
2722
|
+
// over policy text, and a failure streak is a projection over the log.
|
|
2723
|
+
//
|
|
2724
|
+
// The remedy is a floor and not a deny. Escalation escalates TO manual (§10.2,
|
|
2725
|
+
// and `core/loop.ts`'s own header), and the only thing that clears a streak is
|
|
2726
|
+
// an execution that completes — so a deny would leave an escalated session
|
|
2727
|
+
// with no way back, and a class the policy calls autonomous has no manual
|
|
2728
|
+
// sibling to fall back on. Every SIDE-EFFECTING class that would otherwise
|
|
2729
|
+
// have proceeded is routed to the human gate for this invocation (APRV-297
|
|
2730
|
+
// narrowed it to those); a class that already resolves manual is untouched,
|
|
2731
|
+
// because it was already going there.
|
|
2732
|
+
const floored = harnessFloor(logPath, task, actor, looked.records);
|
|
2733
|
+
if (!floored.ok)
|
|
2734
|
+
return deny(streams, "hook-io", floored.detail, adapter.kind);
|
|
2735
|
+
/**
|
|
2736
|
+
* The streak the log shows, before the read carve-out (APRV-297).
|
|
2737
|
+
*
|
|
2738
|
+
* Kept separate from the floor that is APPLIED because the verdict has to be
|
|
2739
|
+
* able to say "a floor is standing and it was not applied here". Collapsing
|
|
2740
|
+
* the two would leave an agent reading an ordinary autonomous allow with no
|
|
2741
|
+
* way to tell that the session it is in is three failed writes deep.
|
|
2742
|
+
*/
|
|
2743
|
+
const tripped = floored.floor;
|
|
2744
|
+
/**
|
|
2745
|
+
* Is every class of this command a read? (APRV-297, amended SPEC.md §10.2.)
|
|
2746
|
+
*
|
|
2747
|
+
* The predicate is `core/loop.ts`'s own, the same one that decides what
|
|
2748
|
+
* ACCRUES, so what the floor counts and what it routes cannot come apart. A
|
|
2749
|
+
* class this build has never heard of is side-effecting by construction, so an
|
|
2750
|
+
* unknown class is routed exactly as it is counted.
|
|
2751
|
+
*/
|
|
2752
|
+
const readsOnly = classes.every((cls) => !isSideEffectingClass(cls));
|
|
2753
|
+
/**
|
|
2754
|
+
* The floor as this invocation applies it: `null` for a command that only
|
|
2755
|
+
* looks, whatever the streak says.
|
|
2756
|
+
*
|
|
2757
|
+
* A read cannot cause the harm the floor bounds. The floor exists to stop an
|
|
2758
|
+
* agent retrying a side effect that keeps failing, so routing a `grep` to a
|
|
2759
|
+
* phone buys no safety and spends the two things the floor is supposed to be
|
|
2760
|
+
* conserving: a human's attention, and the session's ability to find out what
|
|
2761
|
+
* went wrong. On 2026-09-06/07 a tripped floor sent every read to the gate and
|
|
2762
|
+
* a session that could not get an answer could not even search the repository.
|
|
2763
|
+
*/
|
|
2764
|
+
const floor = readsOnly ? null : tripped;
|
|
2765
|
+
if (tripped !== null && floor === null) {
|
|
2766
|
+
notes.push(`loop-escalated (amended SPEC.md §10.2) NOT APPLIED to this call: ${tripped.scope} ${tripped.key} has ${String(tripped.consecutiveFailures)} consecutive failed side-effecting harness tool calls, and every class of this command is a read (${classes.join(", ")}). Escalation raises scrutiny on side effects only, so this command is answered by the policy; the floor still routes the session's side-effecting calls to a human. ${loopClearance(tripped.scope, tripped.key)}`);
|
|
2767
|
+
}
|
|
2768
|
+
if (floor !== null) {
|
|
2769
|
+
// The decision trace: the verdict this invocation prints says that a floor
|
|
2770
|
+
// rather than the matched rule decided it, and names the scope and the
|
|
2771
|
+
// count that tripped, the way `core/execute.ts` names the irreversibility
|
|
2772
|
+
// floor beside a resolution's provenance.
|
|
2773
|
+
notes.push(`loop-escalated (amended SPEC.md §10.2): ${floor.scope} ${floor.key} has ${String(floor.consecutiveFailures)} consecutive failed side-effecting harness tool calls, so every class of this command is routed to a human for this invocation regardless of policy`);
|
|
2774
|
+
}
|
|
2775
|
+
/**
|
|
2776
|
+
* Appended to every verdict this invocation prints: the history-rewrite
|
|
2777
|
+
* refinement's own words, and the loop floor's when one applied.
|
|
2778
|
+
*/
|
|
2779
|
+
const note = notes.length === 0 ? "" : ` (${notes.join("; ")})`;
|
|
2780
|
+
/** No class here needs a human, so nothing downstream will ask for one. */
|
|
2781
|
+
const unattended = floor === null && autonomies.every((autonomy) => autonomy !== "manual");
|
|
2782
|
+
if (unattended) {
|
|
2783
|
+
const refused = unattendedGuard(logPath, load.source.path, task, looked.records);
|
|
2784
|
+
if (refused !== null)
|
|
2785
|
+
return deny(streams, refused.code, refused.detail, adapter.kind);
|
|
2786
|
+
}
|
|
2787
|
+
if (floor === null && autonomies.every((autonomy) => autonomy === "autonomous")) {
|
|
2788
|
+
// No approval lifecycle: an autonomous action has none (amended SPEC.md
|
|
2789
|
+
// §6.3), so nothing is requested, decided or granted here. What IS appended
|
|
2790
|
+
// since APRV-141 is the execution record itself — the moment the policy
|
|
2791
|
+
// authorized this command — because a budget the busiest path does not
|
|
2792
|
+
// charge is not a budget. See `recordUnattended`.
|
|
2793
|
+
const charged = recordUnattended(run, task, classes, payloadHash(payload));
|
|
2794
|
+
if (charged !== null) {
|
|
2795
|
+
return deny(streams, `hook-gate-refused:${charged.code}`, charged.message, adapter.kind);
|
|
2796
|
+
}
|
|
2797
|
+
return allow(streams, `autonomous: ${classes.join(", ")}${note}`, adapter.kind, codexCommand);
|
|
2798
|
+
}
|
|
2799
|
+
// Past here the hook appends. It writes to a log that already exists and
|
|
2800
|
+
// creates none: a log the hook scaffolded where it happened to be standing
|
|
2801
|
+
// would be a second chain, forked from the real one's tail, and hash chains
|
|
2802
|
+
// do not survive a merge. An initialized-but-empty `.approval/log/` counts as
|
|
2803
|
+
// reachable — an audit trail that has recorded nothing is an empty log, not a
|
|
2804
|
+
// missing one (see `preflightLog`) — and `register` appends the first line.
|
|
2805
|
+
return gateAndWait(streams, run, classes, payload, headline, task, note, floor);
|
|
2806
|
+
}
|
|
2807
|
+
function commandHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
2808
|
+
try {
|
|
2809
|
+
return runHarnessHook(argv, streams, cwd, readStdin, adapter);
|
|
2810
|
+
}
|
|
2811
|
+
catch (cause) {
|
|
2812
|
+
// A hook that throws is a hook the harness treats as a non-blocking error,
|
|
2813
|
+
// which would let the command through. Every unexpected failure becomes an
|
|
2814
|
+
// ordinary deny instead. Cursor additionally needs failClosed on the
|
|
2815
|
+
// hooks.json entry so a crash of this process still blocks.
|
|
2816
|
+
return deny(streams, "hook-io", `the hook failed: ${cause instanceof Error ? cause.message : String(cause)}`, adapter.kind);
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
// ===========================================================================
|
|
2820
|
+
// Dispatch
|
|
2821
|
+
// ===========================================================================
|
|
2822
|
+
/** Read the whole of stdin, synchronously. */
|
|
2823
|
+
function defaultStdin() {
|
|
2824
|
+
return readFileSync(0, "utf8");
|
|
2825
|
+
}
|
|
2826
|
+
export function commandHook(argv, streams, cwd, readStdin = defaultStdin) {
|
|
2827
|
+
const sub = argv[0];
|
|
2828
|
+
const rest = argv.slice(1);
|
|
2829
|
+
if (sub === undefined) {
|
|
2830
|
+
return usageError(streams, "missing subcommand for `approval hook`");
|
|
2831
|
+
}
|
|
2832
|
+
if (sub === "--help" || sub === "-h" || sub === "help") {
|
|
2833
|
+
streams.out(`${HOOK_HELP}\n`);
|
|
2834
|
+
return EXIT_OK;
|
|
2835
|
+
}
|
|
2836
|
+
switch (sub) {
|
|
2837
|
+
case "claude-code":
|
|
2838
|
+
return commandHarnessHook(rest, streams, cwd, readStdin, CLAUDE_ADAPTER);
|
|
2839
|
+
case "cursor":
|
|
2840
|
+
return commandHarnessHook(rest, streams, cwd, readStdin, CURSOR_ADAPTER);
|
|
2841
|
+
case "codex":
|
|
2842
|
+
return commandHarnessHook(rest, streams, cwd, readStdin, CODEX_ADAPTER);
|
|
2843
|
+
case "classify":
|
|
2844
|
+
return commandClassify(rest, streams, cwd);
|
|
2845
|
+
default:
|
|
2846
|
+
return usageError(streams, `unknown subcommand ${JSON.stringify(sub)} for \`approval hook\``);
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
//# sourceMappingURL=hook.js.map
|