approval-md 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +629 -559
- package/SPEC.md +99 -24
- package/dist/src/adapters/agentmail.d.ts +426 -0
- package/dist/src/adapters/agentmail.js +2 -2
- package/dist/src/adapters/conformance.d.ts +149 -0
- package/dist/src/adapters/contract.d.ts +628 -0
- package/dist/src/adapters/contract.js +110 -16
- package/dist/src/adapters/contract.js.map +1 -1
- package/dist/src/adapters/email.d.ts +324 -0
- package/dist/src/adapters/env-passphrase.d.ts +93 -0
- package/dist/src/adapters/public.d.ts +11 -0
- package/dist/src/adapters/public.js +11 -0
- package/dist/src/adapters/public.js.map +1 -0
- package/dist/src/adapters/registry.d.ts +59 -0
- package/dist/src/adapters/registry.js +2 -1
- package/dist/src/adapters/registry.js.map +1 -1
- package/dist/src/adapters/smtp.d.ts +213 -0
- package/dist/src/adapters/vault-provider.d.ts +114 -0
- package/dist/src/adapters/vault-provider.js +3 -3
- package/dist/src/adapters/zzz.d.ts +66 -0
- package/dist/src/adapters/zzz.js +299 -0
- package/dist/src/adapters/zzz.js.map +1 -0
- package/dist/src/channels/batch.d.ts +109 -0
- package/dist/src/channels/cli.d.ts +193 -0
- package/dist/src/channels/conformance.d.ts +92 -0
- package/dist/src/channels/contract.d.ts +656 -0
- package/dist/src/channels/contract.js +200 -7
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/payload-view.d.ts +35 -0
- package/dist/src/channels/render-queue.d.ts +149 -0
- package/dist/src/channels/tagging.d.ts +196 -0
- package/dist/src/channels/telegram.d.ts +1944 -0
- package/dist/src/channels/telegram.js +218 -23
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/channels/web.d.ts +350 -0
- package/dist/src/channels/web.js +17 -0
- package/dist/src/channels/web.js.map +1 -1
- package/dist/src/cli/adapter.d.ts +90 -0
- package/dist/src/cli/adapter.js +25 -15
- package/dist/src/cli/adapter.js.map +1 -1
- package/dist/src/cli/amend.d.ts +59 -0
- package/dist/src/cli/amend.js +214 -30
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/args.d.ts +43 -0
- package/dist/src/cli/attest.d.ts +50 -0
- package/dist/src/cli/attest.js +134 -7
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/audit-card.d.ts +62 -0
- package/dist/src/cli/audit.d.ts +59 -0
- package/dist/src/cli/channel-telegram.d.ts +879 -0
- package/dist/src/cli/channel-telegram.js +311 -13
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel-web.d.ts +131 -0
- package/dist/src/cli/channel.d.ts +80 -0
- package/dist/src/cli/channel.js +9 -0
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/checkpoint-tap.d.ts +169 -0
- package/dist/src/cli/codex-bridge.d.ts +819 -0
- package/dist/src/cli/codex-bridge.js +1607 -0
- package/dist/src/cli/codex-bridge.js.map +1 -0
- package/dist/src/cli/codex.d.ts +2 -0
- package/dist/src/cli/codex.js +469 -0
- package/dist/src/cli/codex.js.map +1 -0
- package/dist/src/cli/coverage.d.ts +61 -0
- package/dist/src/cli/daemon.d.ts +120 -0
- package/dist/src/cli/daemon.js +4 -1
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +129 -0
- package/dist/src/cli/doctor.js +586 -17
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/env.d.ts +65 -0
- package/dist/src/cli/execute.d.ts +202 -0
- package/dist/src/cli/execute.js +25 -2
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +73 -0
- package/dist/src/cli/feedback.d.ts +60 -0
- package/dist/src/cli/gate-window.d.ts +40 -0
- package/dist/src/cli/gate.d.ts +68 -0
- package/dist/src/cli/git-scope.d.ts +190 -0
- package/dist/src/cli/gloss-attach.d.ts +85 -0
- package/dist/src/cli/gloss-codex-child.d.ts +9 -0
- package/dist/src/cli/gloss-codex.d.ts +24 -0
- package/dist/src/cli/gloss-options.d.ts +42 -0
- package/dist/src/cli/gloss.d.ts +265 -0
- package/dist/src/cli/help.d.ts +107 -0
- package/dist/src/cli/help.js +320 -93
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook-codex.d.ts +126 -0
- package/dist/src/cli/hook-codex.js +226 -0
- package/dist/src/cli/hook-codex.js.map +1 -0
- package/dist/src/cli/hook.d.ts +787 -0
- package/dist/src/cli/hook.js +1235 -181
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/import.d.ts +35 -0
- package/dist/src/cli/import.js +1 -1
- package/dist/src/cli/import.js.map +1 -1
- package/dist/src/cli/init.d.ts +84 -0
- package/dist/src/cli/init.js +2 -2
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.d.ts +23 -0
- package/dist/src/cli/journal.d.ts +41 -0
- package/dist/src/cli/log-advance.d.ts +287 -0
- package/dist/src/cli/log-advance.js +102 -11
- package/dist/src/cli/log-advance.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +176 -0
- package/dist/src/cli/log-checkpoint.d.ts +22 -0
- package/dist/src/cli/log-sync.d.ts +243 -0
- package/dist/src/cli/log-verbs.d.ts +16 -0
- package/dist/src/cli/log-verbs.js +7 -1
- package/dist/src/cli/log-verbs.js.map +1 -1
- package/dist/src/cli/long-help.d.ts +70 -0
- package/dist/src/cli/main.d.ts +77 -0
- package/dist/src/cli/main.js +159 -7
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/mcp.d.ts +52 -0
- package/dist/src/cli/paths.d.ts +56 -0
- package/dist/src/cli/payload.d.ts +58 -0
- package/dist/src/cli/policy-apply.d.ts +195 -0
- package/dist/src/cli/policy-apply.js +573 -0
- package/dist/src/cli/policy-apply.js.map +1 -0
- package/dist/src/cli/policy.d.ts +43 -0
- package/dist/src/cli/policy.js +14 -1
- package/dist/src/cli/policy.js.map +1 -1
- package/dist/src/cli/preflight.d.ts +501 -0
- package/dist/src/cli/preflight.js +689 -45
- package/dist/src/cli/preflight.js.map +1 -1
- package/dist/src/cli/progress.d.ts +78 -0
- package/dist/src/cli/prompt.d.ts +209 -0
- package/dist/src/cli/quickstart.d.ts +46 -0
- package/dist/src/cli/quickstart.js +297 -0
- package/dist/src/cli/quickstart.js.map +1 -0
- package/dist/src/cli/records.d.ts +34 -0
- package/dist/src/cli/render.d.ts +22 -0
- package/dist/src/cli/sandbox.d.ts +51 -0
- package/dist/src/cli/sandbox.js +17 -1
- package/dist/src/cli/sandbox.js.map +1 -1
- package/dist/src/cli/scaffold.d.ts +79 -0
- package/dist/src/cli/scaffold.js +1 -1
- package/dist/src/cli/setup-adapter.d.ts +137 -0
- package/dist/src/cli/setup-adapter.js +38 -4
- package/dist/src/cli/setup-adapter.js.map +1 -1
- package/dist/src/cli/setup-channel.d.ts +126 -0
- package/dist/src/cli/setup-channel.js +28 -1
- package/dist/src/cli/setup-channel.js.map +1 -1
- package/dist/src/cli/setup-checkpoint.d.ts +57 -0
- package/dist/src/cli/setup-common.d.ts +277 -0
- package/dist/src/cli/setup-common.js +3 -2
- package/dist/src/cli/setup-common.js.map +1 -1
- package/dist/src/cli/setup-flow.d.ts +287 -0
- package/dist/src/cli/setup-service.d.ts +96 -0
- package/dist/src/cli/setup.d.ts +204 -0
- package/dist/src/cli/setup.js +94 -2
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/style.d.ts +320 -0
- package/dist/src/cli/token.d.ts +39 -0
- package/dist/src/cli/up.d.ts +155 -0
- package/dist/src/cli/up.js +119 -53
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/usage.d.ts +37 -0
- package/dist/src/cli/values.d.ts +40 -0
- package/dist/src/cli/values.js +3 -4
- package/dist/src/cli/values.js.map +1 -1
- package/dist/src/cli/vault.d.ts +59 -0
- package/dist/src/cli/vault.js +2 -2
- package/dist/src/cli/vault.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +76 -0
- package/dist/src/cli/verb-registry.js +344 -11
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +31 -0
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/codex/broker.d.ts +229 -0
- package/dist/src/codex/broker.js +548 -0
- package/dist/src/codex/broker.js.map +1 -0
- package/dist/src/codex/doctor.d.ts +13 -0
- package/dist/src/codex/doctor.js +41 -0
- package/dist/src/codex/doctor.js.map +1 -0
- package/dist/src/codex/manifest.d.ts +49 -0
- package/dist/src/codex/manifest.js +103 -0
- package/dist/src/codex/manifest.js.map +1 -0
- package/dist/src/codex/runner.d.ts +178 -0
- package/dist/src/codex/runner.js +231 -0
- package/dist/src/codex/runner.js.map +1 -0
- package/dist/src/codex/serve.d.ts +56 -0
- package/dist/src/codex/serve.js +98 -0
- package/dist/src/codex/serve.js.map +1 -0
- package/dist/src/codex/templates.d.ts +41 -0
- package/dist/src/codex/templates.js +319 -0
- package/dist/src/codex/templates.js.map +1 -0
- package/dist/src/codex/trust.d.ts +19 -0
- package/dist/src/codex/trust.js +183 -0
- package/dist/src/codex/trust.js.map +1 -0
- package/dist/src/codex/workspace-commit.d.ts +219 -0
- package/dist/src/codex/workspace-commit.js +549 -0
- package/dist/src/codex/workspace-commit.js.map +1 -0
- package/dist/src/codex/workspace-plan.d.ts +131 -0
- package/dist/src/codex/workspace-plan.js +561 -0
- package/dist/src/codex/workspace-plan.js.map +1 -0
- package/dist/src/core/actor.d.ts +2 -0
- package/dist/src/core/actor.js +5 -0
- package/dist/src/core/actor.js.map +1 -0
- package/dist/src/core/advance-cycle.d.ts +221 -0
- package/dist/src/core/advance-cycle.js +66 -2
- package/dist/src/core/advance-cycle.js.map +1 -1
- package/dist/src/core/agents-md.d.ts +278 -0
- package/dist/src/core/agents-md.js +33 -31
- package/dist/src/core/agents-md.js.map +1 -1
- package/dist/src/core/apply-patch.d.ts +49 -0
- package/dist/src/core/apply-patch.js +266 -0
- package/dist/src/core/apply-patch.js.map +1 -0
- package/dist/src/core/attest.d.ts +635 -0
- package/dist/src/core/attest.js +326 -4
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +510 -0
- package/dist/src/core/audit.js +13 -0
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/budgets.d.ts +238 -0
- package/dist/src/core/channel-owner.d.ts +213 -0
- package/dist/src/core/channel-owner.js +358 -0
- package/dist/src/core/channel-owner.js.map +1 -0
- package/dist/src/core/checkpoint.d.ts +500 -0
- package/dist/src/core/child-env.d.ts +88 -0
- package/dist/src/core/clock.d.ts +52 -0
- package/dist/src/core/command-class.d.ts +697 -0
- package/dist/src/core/command-class.js +713 -25
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/commit-guard.d.ts +272 -0
- package/dist/src/core/commit-guard.js +424 -0
- package/dist/src/core/commit-guard.js.map +1 -0
- package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
- package/dist/src/core/coverage-sources/gh.d.ts +48 -0
- package/dist/src/core/coverage-sources/git.d.ts +101 -0
- package/dist/src/core/coverage.d.ts +217 -0
- package/dist/src/core/credential-spec.d.ts +72 -0
- package/dist/src/core/daemon-actor.d.ts +45 -0
- package/dist/src/core/daemon-actor.js +54 -0
- package/dist/src/core/daemon-actor.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +432 -0
- package/dist/src/core/dark-session.js +266 -82
- package/dist/src/core/dark-session.js.map +1 -1
- package/dist/src/core/decision-refusal.d.ts +206 -0
- package/dist/src/core/decision-refusal.js +24 -2
- package/dist/src/core/decision-refusal.js.map +1 -1
- package/dist/src/core/env-file.d.ts +455 -0
- package/dist/src/core/env-file.js +60 -1
- package/dist/src/core/env-file.js.map +1 -1
- package/dist/src/core/execute.d.ts +871 -0
- package/dist/src/core/execute.js +59 -8
- package/dist/src/core/execute.js.map +1 -1
- package/dist/src/core/frontmatter.d.ts +78 -0
- package/dist/src/core/gate-window.d.ts +312 -0
- package/dist/src/core/gate.d.ts +1449 -0
- package/dist/src/core/gate.js +149 -14
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/gesture-refusal.d.ts +166 -0
- package/dist/src/core/gesture-refusal.js +188 -0
- package/dist/src/core/gesture-refusal.js.map +1 -0
- package/dist/src/core/git-run.d.ts +73 -0
- package/dist/src/core/harness-version.d.ts +157 -0
- package/dist/src/core/harness-version.js +4 -1
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +55 -0
- package/dist/src/core/head-retry.d.ts +107 -0
- package/dist/src/core/instance.d.ts +310 -0
- package/dist/src/core/instance.js +113 -0
- package/dist/src/core/instance.js.map +1 -1
- package/dist/src/core/intake-limits.d.ts +247 -0
- package/dist/src/core/jcs.d.ts +52 -0
- package/dist/src/core/journal.d.ts +144 -0
- package/dist/src/core/live-draw.d.ts +436 -0
- package/dist/src/core/log-reconcile.d.ts +89 -0
- package/dist/src/core/log-subscribe.d.ts +36 -0
- package/dist/src/core/log-subscribe.js +162 -0
- package/dist/src/core/log-subscribe.js.map +1 -0
- package/dist/src/core/log.d.ts +316 -0
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/loop.d.ts +274 -0
- package/dist/src/core/loop.js +11 -0
- package/dist/src/core/loop.js.map +1 -1
- package/dist/src/core/md-fence.d.ts +41 -0
- package/dist/src/core/money.d.ts +147 -0
- package/dist/src/core/payload-census.d.ts +74 -0
- package/dist/src/core/payload-store.d.ts +175 -0
- package/dist/src/core/payload.d.ts +71 -0
- package/dist/src/core/policy-diff.d.ts +292 -0
- package/dist/src/core/policy-diff.js +27 -4
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-expectations.d.ts +199 -0
- package/dist/src/core/policy-explain.d.ts +160 -0
- package/dist/src/core/policy-explain.js +63 -3
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +567 -0
- package/dist/src/core/policy-load.js +36 -6
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +324 -0
- package/dist/src/core/policy-match.js +72 -9
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +317 -0
- package/dist/src/core/policy-proposal.js +102 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/prompt-layout.d.ts +221 -0
- package/dist/src/core/protected-path-guard.d.ts +566 -0
- package/dist/src/core/protected-path-guard.js +848 -55
- package/dist/src/core/protected-path-guard.js.map +1 -1
- package/dist/src/core/question-preempted.d.ts +141 -0
- package/dist/src/core/question-preempted.js +152 -0
- package/dist/src/core/question-preempted.js.map +1 -0
- package/dist/src/core/read-scope.d.ts +172 -0
- package/dist/src/core/read-scope.js +252 -0
- package/dist/src/core/read-scope.js.map +1 -0
- package/dist/src/core/registration.d.ts +25 -0
- package/dist/src/core/reindex.d.ts +99 -0
- package/dist/src/core/sampler.d.ts +313 -0
- package/dist/src/core/sandbox.d.ts +371 -0
- package/dist/src/core/sandbox.js +190 -1
- package/dist/src/core/sandbox.js.map +1 -1
- package/dist/src/core/seal.d.ts +165 -0
- package/dist/src/core/sender-identity.d.ts +476 -0
- package/dist/src/core/sender-identity.js +572 -0
- package/dist/src/core/sender-identity.js.map +1 -0
- package/dist/src/core/shlex.d.ts +102 -0
- package/dist/src/core/shlex.js +159 -0
- package/dist/src/core/shlex.js.map +1 -0
- package/dist/src/core/state.d.ts +505 -0
- package/dist/src/core/task-file.d.ts +185 -0
- package/dist/src/core/telegram-config.d.ts +93 -0
- package/dist/src/core/token.d.ts +409 -0
- package/dist/src/core/token.js +21 -38
- package/dist/src/core/token.js.map +1 -1
- package/dist/src/core/validate.d.ts +138 -0
- package/dist/src/core/values.d.ts +147 -0
- package/dist/src/core/values.js +36 -1
- package/dist/src/core/values.js.map +1 -1
- package/dist/src/core/vault.d.ts +291 -0
- package/dist/src/core/verified-snapshot.d.ts +204 -0
- package/dist/src/core/verify.d.ts +336 -0
- package/dist/src/core/version.d.ts +8 -0
- package/dist/src/core/wysiwys.d.ts +370 -0
- package/dist/src/daemon/advance-child.d.ts +39 -0
- package/dist/src/daemon/advance.d.ts +476 -0
- package/dist/src/daemon/advance.js +25 -4
- package/dist/src/daemon/advance.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +87 -0
- package/dist/src/daemon/daemon.d.ts +1180 -0
- package/dist/src/daemon/daemon.js +9 -0
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/dark-session.d.ts +64 -0
- package/dist/src/daemon/draw-child.d.ts +36 -0
- package/dist/src/daemon/draw.d.ts +154 -0
- package/dist/src/daemon/git-evidence.d.ts +173 -0
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +180 -0
- package/dist/src/daemon/prune.d.ts +207 -0
- package/dist/src/mcp/http.d.ts +113 -0
- package/dist/src/mcp/server.d.ts +265 -0
- package/dist/src/mcp/server.js +17 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/docs/adapter-api.md +106 -0
- package/docs/cli-reference.md +1316 -63
- package/docs/codex-enforced-session.md +103 -0
- package/docs/codex-workspace-broker.md +118 -0
- package/package.json +14 -2
- package/schema/codex-instance.schema.json +82 -0
- package/schema/event.schema.json +539 -9
- package/schema/fixtures/codex-instance/invalid/unpinned-codex-version.json +40 -0
- package/schema/fixtures/codex-instance/valid/canonical.json +40 -0
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
- package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
- package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
- package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
- package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
- package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
- package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
- package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
- package/schema/fixtures/policy/valid/canonical.json +1 -1
- package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
- package/schema/fixtures/policy-md/valid/canonical.md +1 -1
- package/schema/fixtures/policy-md/valid/with-values.md +5 -7
- package/schema/fixtures/values/invalid/class-shaped.json +1 -1
- package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
- package/schema/fixtures/values/invalid/non-string-item.json +1 -1
- package/schema/fixtures/values/invalid/over-cap.json +1 -1
- package/schema/fixtures/values/invalid/unknown-key.json +1 -1
- package/schema/fixtures/values/invalid/version-float.json +1 -0
- package/schema/fixtures/values/invalid/version-integer.json +1 -0
- package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +2 -3
- package/schema/fixtures/values/valid/full.json +5 -7
- package/schema/fixtures/values/valid/minimal.json +1 -1
- package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
- package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
- package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
- package/schema/fixtures/values-md/invalid/version-1.md +69 -0
- package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
- package/schema/fixtures/values-md/valid/absent.md +1 -1
- package/schema/fixtures/values-md/valid/with-values.md +5 -7
- package/schema/policy.schema.json +75 -3
- package/schema/values.schema.json +7 -11
- package/templates/codex/README.md +9 -0
- package/schema/fixtures/values/invalid/version-string.json +0 -1
package/dist/src/cli/hook.js
CHANGED
|
@@ -78,24 +78,27 @@ import { tmpdir } from "node:os";
|
|
|
78
78
|
import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments, sep, } from "node:path";
|
|
79
79
|
import { attestationRefusal, checkAttestation } from "../core/attest.js";
|
|
80
80
|
import { childEnvironment } from "../core/child-env.js";
|
|
81
|
-
import { classifyCommand, commandSegmentWords, CODE_EXECUTING_RULES, GATE_SELF_CLASS, protectedPathClass, } from "../core/command-class.js";
|
|
81
|
+
import { classifyCommand, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, protectedPathClass, } from "../core/command-class.js";
|
|
82
82
|
import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, register, request, startHarnessExecution, withdraw, } from "../core/gate.js";
|
|
83
83
|
import { openGateWindow, recordGateBypass, } from "../core/gate-window.js";
|
|
84
|
-
import { harnessProvenance, } from "../core/harness-version.js";
|
|
84
|
+
import { harnessProvenance, isHarnessKind, } from "../core/harness-version.js";
|
|
85
85
|
import { abandonedAfterMs, HOOK_DEFAULT_WAIT, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
|
|
86
86
|
import { harnessLoopFloor, isLoopEscalated, isSideEffectingClass, loopClearance, UNKNOWN_SESSION, } from "../core/loop.js";
|
|
87
87
|
import { drawSocketPathFor, drawSocketUsable } from "../core/live-draw.js";
|
|
88
88
|
import { payloadHash } from "../core/payload.js";
|
|
89
|
+
import { classifyApplyPatch, parseApplyPatch } from "../core/apply-patch.js";
|
|
89
90
|
import { loadPolicy, parseDuration } from "../core/policy-load.js";
|
|
90
|
-
import {
|
|
91
|
+
import { READ_OUT_OF_SCOPE_CLASS, effectiveReadRoots, isInReadScope, readTargetsOf, renderReadRoots, } from "../core/read-scope.js";
|
|
92
|
+
import { harnessLaunchNeedsRule, harnessLaunchUnruledRefusal, humanOnlyRefusal, resolve as resolvePolicy, } from "../core/policy-match.js";
|
|
91
93
|
import { payloadOf, readVerifiedRecords, requestState, useVerifiedSnapshots, } from "../core/state.js";
|
|
92
94
|
import { boolFlag, parseFlags, stringFlag } from "./args.js";
|
|
93
95
|
import { EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
|
|
94
96
|
import { primaryRoot as resolvePrimaryRoot } from "./git-scope.js";
|
|
95
|
-
import { HOOK_HELP } from "./help.js";
|
|
97
|
+
import { HOOK_GROK_HELP, HOOK_HELP, HOOK_MUSE_HELP } from "./help.js";
|
|
96
98
|
import { DEFAULT_LOG_PATH } from "./paths.js";
|
|
97
99
|
import { refusal as renderRefusal, style, table } from "./style.js";
|
|
98
100
|
import { usageErrorText } from "./usage.js";
|
|
101
|
+
import { checkCodexHookInput, codexArgvDisagrees, codexBinding, CODEX_POST_TOOL_EVENT, readCodexReportedOutcome, } from "./hook-codex.js";
|
|
99
102
|
/** Identity accepted for the proposing side: a person or an agent. */
|
|
100
103
|
const PRINCIPAL_ACTOR = /^(human|agent):.+/u;
|
|
101
104
|
/**
|
|
@@ -149,6 +152,29 @@ export const HOOK_DENY_CODES = [
|
|
|
149
152
|
* rejection: nobody decided anything, so there is nothing to ask again.
|
|
150
153
|
*/
|
|
151
154
|
"hook-class-human-only",
|
|
155
|
+
/**
|
|
156
|
+
* A `harness.launch.*` class that no rule of this policy names (APRV-354).
|
|
157
|
+
*
|
|
158
|
+
* SPEC.md §7 says the family is never inferred autonomous; this is the
|
|
159
|
+
* stronger reading the family needs, which is that it is never inferred at
|
|
160
|
+
* all. A launch resolves only under a rule an operator wrote, and a policy
|
|
161
|
+
* that names neither `harness.launch.*` nor the specific member refuses.
|
|
162
|
+
*
|
|
163
|
+
* It exists because of the window the softer reading opens. Before the family
|
|
164
|
+
* existed, `codex …` and `muse …` were `hook-unclassified`: refused outright.
|
|
165
|
+
* Letting the new class fall to `defaults.autonomy` would have made every
|
|
166
|
+
* harness launch grantable by one approval in every project whose defaults
|
|
167
|
+
* are manual, the moment they upgraded — a capability arriving by upgrade
|
|
168
|
+
* rather than by decision. What that approval would cover is a whole second
|
|
169
|
+
* agent whose own actions this gate never sees.
|
|
170
|
+
*
|
|
171
|
+
* Distinct from `hook-unclassified`, which says the CLASSIFIER has nothing to
|
|
172
|
+
* say about the command; here the classifier was clear and the POLICY is
|
|
173
|
+
* silent. Distinct from `hook-class-human-only`, which is a policy that has
|
|
174
|
+
* spoken and reserved the class: the repair there is for a person to run the
|
|
175
|
+
* command, and the repair here is to write a line.
|
|
176
|
+
*/
|
|
177
|
+
"hook-harness-launch-unruled",
|
|
152
178
|
/** A construct whose effect cannot be read off the text (`bash -c`, `eval`). */
|
|
153
179
|
"hook-opaque",
|
|
154
180
|
/** The command line could not be tokenized at all. */
|
|
@@ -217,6 +243,46 @@ export const HOOK_DENY_CODES = [
|
|
|
217
243
|
* merges do not reconcile hash chains (APRV-101).
|
|
218
244
|
*/
|
|
219
245
|
"hook-log-unreachable",
|
|
246
|
+
/**
|
|
247
|
+
* The harness does not tell this hook where the call will run, so no verdict
|
|
248
|
+
* over the visible bytes can bind the action (APRV-311, native evidence in
|
|
249
|
+
* APRV-310 v6/v7).
|
|
250
|
+
*
|
|
251
|
+
* Native Codex 0.152.1 honours a per-call Bash working directory that appears
|
|
252
|
+
* in no field of the event: `tool_input` carries `command` alone, and the
|
|
253
|
+
* event cwd and the hook process cwd both stay at the session root. A
|
|
254
|
+
* decision over `{command, session root}` would therefore authorize different
|
|
255
|
+
* bytes from the `{command, effective directory}` the harness executes, and a
|
|
256
|
+
* relative path in an approved command can name a protected organ in a
|
|
257
|
+
* directory the classifier never saw.
|
|
258
|
+
*
|
|
259
|
+
* Distinct from `hook-io`, which this used to borrow, and the distinction is
|
|
260
|
+
* the repair. `hook-io` says THIS event was malformed and a well-formed one
|
|
261
|
+
* would be answered; this says every event of this shape is refused on this
|
|
262
|
+
* harness version, and the fix is a harness contract that exposes the
|
|
263
|
+
* effective execution directory, not a retry, a policy edit, or an open
|
|
264
|
+
* window. Nothing appends on this path and no gate lifecycle opens.
|
|
265
|
+
*/
|
|
266
|
+
"hook-unsupported-execution-context",
|
|
267
|
+
/**
|
|
268
|
+
* The session names a Contributor-tier model, so every tool call is refused
|
|
269
|
+
* (APRV-350).
|
|
270
|
+
*
|
|
271
|
+
* Meta sells a Contributor variant of the Muse Spark family that "trades a
|
|
272
|
+
* lower price for permission to train on your prompts and completions". A
|
|
273
|
+
* session on one discloses every byte it reads, so the refusal is above the
|
|
274
|
+
* policy: no class resolution and no grant widens it, and an absent or
|
|
275
|
+
* unrecognised `model` is refused for the same reason an unparseable event is.
|
|
276
|
+
*
|
|
277
|
+
* Distinct from `hook-class-human-only`, which says a HUMAN must do this
|
|
278
|
+
* action; this says nothing may do it in this session, and the repair is to
|
|
279
|
+
* change the model in Muse's picker rather than to ask anybody. Distinct from
|
|
280
|
+
* `hook-io` because the event was perfectly well formed.
|
|
281
|
+
*
|
|
282
|
+
* What it cannot do is stated wherever it is documented: it stops tool calls,
|
|
283
|
+
* and it cannot recall a prompt the model has already been sent.
|
|
284
|
+
*/
|
|
285
|
+
"hook-muse-contributor-model",
|
|
220
286
|
/** Malformed hook input, or a log/filesystem fact that stopped the check. */
|
|
221
287
|
"hook-io",
|
|
222
288
|
];
|
|
@@ -265,7 +331,7 @@ const primaryRoot = resolvePrimaryRoot;
|
|
|
265
331
|
* (`--policy` for the policy, `--log` for the log); otherwise both follow
|
|
266
332
|
* `--dir`, and with no flags at all both follow the primary checkout.
|
|
267
333
|
*/
|
|
268
|
-
function hookScope(flags, cwd) {
|
|
334
|
+
export function hookScope(flags, cwd) {
|
|
269
335
|
const policyFlag = stringFlag(flags, "--policy");
|
|
270
336
|
const logFlag = stringFlag(flags, "--log");
|
|
271
337
|
const dirFlag = stringFlag(flags, "--dir");
|
|
@@ -276,12 +342,30 @@ function hookScope(flags, cwd) {
|
|
|
276
342
|
const logPath = logFlag === null ? join(root, DEFAULT_LOG_PATH) : absolute(logFlag, cwd);
|
|
277
343
|
return { logPath, root, options };
|
|
278
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* The GATE ROOT: the directory holding the policy file that was actually read
|
|
347
|
+
* (APRV-347).
|
|
348
|
+
*
|
|
349
|
+
* This is the anchor of the read scope, and it is deliberately the loaded
|
|
350
|
+
* policy's own directory rather than the hook's cwd or the harness's `cwd`
|
|
351
|
+
* field. A muse session started in `~/dev/muse` with its own `APPROVAL.md` gets
|
|
352
|
+
* `~/dev/muse`, and every sibling under `~/dev` is outside the scope with no
|
|
353
|
+
* extra grammar anywhere. A policy that did not load falls back to the scope
|
|
354
|
+
* root, which is the same directory the loader was pointed at.
|
|
355
|
+
*/
|
|
356
|
+
function policyRootOf(load, fallback) {
|
|
357
|
+
return load.ok ? dirname(load.source.path) : fallback;
|
|
358
|
+
}
|
|
279
359
|
const CLAUDE_ADAPTER = {
|
|
280
360
|
kind: "claude-code",
|
|
281
361
|
originApp: "claude-code-hook",
|
|
282
362
|
defaultActor: "agent:claude-code",
|
|
283
363
|
shellTool: "Bash",
|
|
284
364
|
fileTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
|
|
365
|
+
// Claude Code's three path-carrying readers. `Read` names the file in
|
|
366
|
+
// `file_path` (or `notebook_path`), `Glob` and `Grep` name the directory they
|
|
367
|
+
// search in `path` and default to the workspace when it is absent.
|
|
368
|
+
readTools: ["Read", "Glob", "Grep"],
|
|
285
369
|
};
|
|
286
370
|
const CURSOR_ADAPTER = {
|
|
287
371
|
kind: "cursor",
|
|
@@ -289,15 +373,156 @@ const CURSOR_ADAPTER = {
|
|
|
289
373
|
defaultActor: "agent:cursor",
|
|
290
374
|
shellTool: "Shell",
|
|
291
375
|
fileTools: ["Write", "Delete"],
|
|
376
|
+
// Cursor's own hook documentation names `Shell`, `Write` and `Delete` as the
|
|
377
|
+
// matchable tools, and no read tool among them; `Read` is the name its agent
|
|
378
|
+
// surface uses. It is listed here because an entry that Cursor never sends is
|
|
379
|
+
// INERT — an unmatched tool takes the path it took before this task — while
|
|
380
|
+
// an entry missing for a tool Cursor does send would be an unscoped read. If
|
|
381
|
+
// Cursor names it otherwise, `cat` through `Shell` is still scoped by the
|
|
382
|
+
// classifier, which is the floor. See docs/cursor-hook.md.
|
|
383
|
+
readTools: ["Read"],
|
|
384
|
+
};
|
|
385
|
+
const CODEX_ADAPTER = {
|
|
386
|
+
kind: "codex",
|
|
387
|
+
originApp: "codex-hook",
|
|
388
|
+
defaultActor: "agent:codex",
|
|
389
|
+
shellTool: "Bash",
|
|
390
|
+
fileTools: ["apply_patch"],
|
|
391
|
+
// Empty, and not an oversight. The Codex native hook contract exposes exactly
|
|
392
|
+
// two tools, `Bash` and `apply_patch` (docs/codex-hook.md); it has no
|
|
393
|
+
// separate read tool to gate. Codex `Bash` is refused outright today because
|
|
394
|
+
// the contract does not expose the per-call working directory (APRV-310), so
|
|
395
|
+
// a Codex read arrives as a shell command and is scoped by the classifier or
|
|
396
|
+
// it does not arrive at all.
|
|
397
|
+
readTools: [],
|
|
398
|
+
bindToolName: true,
|
|
399
|
+
};
|
|
400
|
+
/**
|
|
401
|
+
* Grok Build (APRV-243).
|
|
402
|
+
*
|
|
403
|
+
* Its PreToolUse hook is modelled on Claude Code's, with three differences
|
|
404
|
+
* that matter here: the envelope keys are camelCase, the verdict is
|
|
405
|
+
* `{decision, reason}` rather than the nested `hookSpecificOutput`, and a deny
|
|
406
|
+
* is EXIT 2 rather than exit 0 with a JSON body. The tool names are Claude
|
|
407
|
+
* Code's, which is what its documentation says and what the compatibility read
|
|
408
|
+
* of `.claude/settings.json` implies; `docs/grok-hook.md` records that this
|
|
409
|
+
* half is unverified until the live probe runs.
|
|
410
|
+
*
|
|
411
|
+
* The reason this adapter exists at all is a hazard rather than a feature.
|
|
412
|
+
* Grok Build states that it reads `.claude/settings.json` for compatibility.
|
|
413
|
+
* If that read fires this repository's committed claude-code entry under a
|
|
414
|
+
* Grok session, the claude-code adapter answers a deny by printing the nested
|
|
415
|
+
* Claude envelope and exiting 0, and Grok reads exit 0 as ALLOW. Every command
|
|
416
|
+
* would look gated and none would be.
|
|
417
|
+
*/
|
|
418
|
+
const GROK_ADAPTER = {
|
|
419
|
+
kind: "grok",
|
|
420
|
+
originApp: "grok-hook",
|
|
421
|
+
defaultActor: "agent:grok",
|
|
422
|
+
shellTool: "Bash",
|
|
423
|
+
fileTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
|
|
424
|
+
// Claude Code's three path-carrying readers, for the same reason the file
|
|
425
|
+
// tools are Claude Code's: Grok Build's tool vocabulary follows Claude
|
|
426
|
+
// Code's, and it READS `.claude/settings.json` for compatibility, so the
|
|
427
|
+
// names a Grok session sends are the names that file matches on. The
|
|
428
|
+
// asymmetry with Cursor is deliberate — Cursor documents its own smaller set,
|
|
429
|
+
// Grok documents Claude's.
|
|
430
|
+
//
|
|
431
|
+
// Both directions of the guess are safe in the way APRV-347 makes them safe.
|
|
432
|
+
// A read tool listed here that Grok never sends is INERT: an unmatched tool
|
|
433
|
+
// takes the path it took before. A read tool Grok sends that is NOT listed
|
|
434
|
+
// would be an unscoped read, which is the direction that matters, so the
|
|
435
|
+
// wider Claude set is the fail-closed guess. And a Grok read that arrives as
|
|
436
|
+
// a shell command through `Bash` is scoped by the classifier regardless,
|
|
437
|
+
// which is the floor under all of this.
|
|
438
|
+
//
|
|
439
|
+
// UNVERIFIED, like the file-tool list beside it, and `docs/grok-hook.md`
|
|
440
|
+
// says so: the live probe of APRV-243 AC1 records the tool names an actual
|
|
441
|
+
// session sends, and both lists are corrected to match before the register
|
|
442
|
+
// entry moves from parked.
|
|
443
|
+
readTools: ["Read", "Glob", "Grep"],
|
|
444
|
+
camelCaseEnvelope: true,
|
|
445
|
+
};
|
|
446
|
+
/**
|
|
447
|
+
* Meta Muse Code (APRV-350).
|
|
448
|
+
*
|
|
449
|
+
* Every field here is OBSERVED, from a live run on `muse-bin-1.3.0-R3233.1`
|
|
450
|
+
* whose 139 captured envelopes are the evidence. Nothing in this entry is a
|
|
451
|
+
* guess, which is the difference between it and the Grok entry above.
|
|
452
|
+
*
|
|
453
|
+
* The envelope is snake_case, so no `camelCaseEnvelope`. It carries
|
|
454
|
+
* `hook_event_name`, `tool_name`, `tool_input`, `tool_use_id`, `session_id`,
|
|
455
|
+
* `turn_id`, `cwd`, `transcript_path`, `model`, `permission_mode` and
|
|
456
|
+
* `model_provider`; `PostToolUse` adds `tool_response`. So Muse sends BOTH
|
|
457
|
+
* facts Codex lacks: a per-call working directory and a real outcome.
|
|
458
|
+
*
|
|
459
|
+
* ## What it cannot do, and why this adapter still ships
|
|
460
|
+
*
|
|
461
|
+
* Muse FAILS OPEN. A hook that crashes, hangs past its timeout, or prints
|
|
462
|
+
* anything Muse cannot parse is a hook Muse ignores, and the tool call
|
|
463
|
+
* proceeds. Worse, an output mixing verdict dialects is itself unparseable, so
|
|
464
|
+
* the belt-and-braces answer that satisfies every harness at once satisfies
|
|
465
|
+
* this one not at all: the probe's `deny` trial printed every dialect AND
|
|
466
|
+
* exited 2, and the write completed in under 80 ms.
|
|
467
|
+
*
|
|
468
|
+
* Therefore this adapter emits EXACTLY ONE dialect and nothing else: the nested
|
|
469
|
+
* `hookSpecificOutput` form at exit 0, which the default arm of
|
|
470
|
+
* {@link decision} already produces. Three forms were measured to block
|
|
471
|
+
* (nested at exit 0; `{decision:"block"}` at exit 0; empty stdout at exit 2)
|
|
472
|
+
* and the nested one is chosen because it is the only one that carries a REASON
|
|
473
|
+
* the model is shown, so a refused agent learns why instead of retrying blind.
|
|
474
|
+
* The probe watched an agent respond to an unexplained deny by shelling out to
|
|
475
|
+
* inspect the hook's own state file, which is the behaviour a silent refusal
|
|
476
|
+
* buys. `docs/muse-hook.md` records the choice and the two rejected forms.
|
|
477
|
+
*/
|
|
478
|
+
const MUSE_ADAPTER = {
|
|
479
|
+
kind: "muse",
|
|
480
|
+
originApp: "muse-hook",
|
|
481
|
+
defaultActor: "agent:muse",
|
|
482
|
+
shellTool: "bash",
|
|
483
|
+
fileTools: ["write_file"],
|
|
484
|
+
// `read_file` names one path (relative to the session cwd, or absolute);
|
|
485
|
+
// `search` names an ARRAY under `paths`, and the live capture caught it
|
|
486
|
+
// reaching outside the workspace entirely — Muse applies no workspace
|
|
487
|
+
// confinement in `permission_mode: "default"`, so this is the read jail's
|
|
488
|
+
// whole reason for existing here.
|
|
489
|
+
readTools: ["read_file", "search"],
|
|
490
|
+
passThroughTools: ["submit_reminder_decision"],
|
|
491
|
+
shellCwdKey: "workdir",
|
|
492
|
+
contributorModelGuard: true,
|
|
493
|
+
};
|
|
494
|
+
/**
|
|
495
|
+
* Every harness this runtime speaks a hook protocol for, by kind (APRV-358).
|
|
496
|
+
*
|
|
497
|
+
* The table is `Record<HarnessKind, HarnessAdapter>` rather than a list of
|
|
498
|
+
* consts and a switch, and the type is the point: a kind added to
|
|
499
|
+
* `HARNESS_KINDS` with no adapter beside it fails to compile, so the two lists
|
|
500
|
+
* cannot drift by forgetting. The subcommand dispatch below reads this map, so
|
|
501
|
+
* `approval hook <kind>` is answerable for exactly the kinds named here.
|
|
502
|
+
*
|
|
503
|
+
* The kinds that are enumerated OUTSIDE this module — the schema's
|
|
504
|
+
* `payload.harness` enum, the verb registry's `hook` subcommands, the MCP
|
|
505
|
+
* exclusions, the help — are pinned set-equal to `HARNESS_KINDS` by
|
|
506
|
+
* `tests/harness-enum.test.ts`, which exists because `grok` shipped an adapter
|
|
507
|
+
* in APRV-243 and reached none of them. A Grok session's manual-class
|
|
508
|
+
* registration was refused at the write boundary for eleven days and nothing
|
|
509
|
+
* failed.
|
|
510
|
+
*/
|
|
511
|
+
export const HARNESS_ADAPTERS = {
|
|
512
|
+
"claude-code": CLAUDE_ADAPTER,
|
|
513
|
+
cursor: CURSOR_ADAPTER,
|
|
514
|
+
codex: CODEX_ADAPTER,
|
|
515
|
+
grok: GROK_ADAPTER,
|
|
516
|
+
muse: MUSE_ADAPTER,
|
|
292
517
|
};
|
|
293
518
|
/**
|
|
294
519
|
* The decision object the harness reads from stdout.
|
|
295
520
|
*
|
|
296
521
|
* Claude Code wants the nested PreToolUse envelope. Cursor native hooks want
|
|
297
|
-
* `{permission, user_message, agent_message}`.
|
|
298
|
-
* harness, still never `ask`.
|
|
522
|
+
* `{permission, user_message, agent_message}`. Grok Build wants
|
|
523
|
+
* `{decision, reason}`. One construction site per harness, still never `ask`.
|
|
299
524
|
*/
|
|
300
|
-
function decision(permission, reason, harness) {
|
|
525
|
+
function decision(permission, reason, harness, codexCommand) {
|
|
301
526
|
if (harness === "cursor") {
|
|
302
527
|
return `${JSON.stringify({
|
|
303
528
|
permission,
|
|
@@ -305,26 +530,109 @@ function decision(permission, reason, harness) {
|
|
|
305
530
|
agent_message: reason,
|
|
306
531
|
})}\n`;
|
|
307
532
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
533
|
+
if (harness === "grok") {
|
|
534
|
+
return `${JSON.stringify({ decision: permission, reason })}\n`;
|
|
535
|
+
}
|
|
536
|
+
const hookSpecificOutput = {
|
|
537
|
+
hookEventName: "PreToolUse",
|
|
538
|
+
permissionDecision: permission,
|
|
539
|
+
permissionDecisionReason: reason,
|
|
540
|
+
};
|
|
541
|
+
if (harness === "codex" && permission === "allow") {
|
|
542
|
+
if (codexCommand !== undefined)
|
|
543
|
+
hookSpecificOutput["updatedInput"] = { command: codexCommand };
|
|
544
|
+
}
|
|
545
|
+
return `${JSON.stringify({ hookSpecificOutput })}\n`;
|
|
315
546
|
}
|
|
316
|
-
function allow(streams, reason, harness) {
|
|
317
|
-
|
|
547
|
+
function allow(streams, reason, harness, codexCommand) {
|
|
548
|
+
if (harness === "codex" && codexCommand === undefined) {
|
|
549
|
+
return deny(streams, "hook-io", "the Codex allow lost its exact bound tool_input.command", harness);
|
|
550
|
+
}
|
|
551
|
+
streams.out(decision("allow", reason, harness, codexCommand));
|
|
318
552
|
return EXIT_OK;
|
|
319
553
|
}
|
|
554
|
+
/**
|
|
555
|
+
* The exit code a DENY carries.
|
|
556
|
+
*
|
|
557
|
+
* Claude Code, Cursor and Codex read the verdict out of stdout and treat a
|
|
558
|
+
* non-zero exit as a broken hook, so a deny there exits 0 with a JSON body.
|
|
559
|
+
* Grok Build reads exit 2 as the deny (APRV-243), and reads exit 0 as ALLOW
|
|
560
|
+
* whatever stdout said. Printing the body AND exiting 2 satisfies both halves
|
|
561
|
+
* of its documented contract and leaves the reason where a person can read it.
|
|
562
|
+
*/
|
|
563
|
+
const GROK_DENY_EXIT = 2;
|
|
320
564
|
function deny(streams, code, detail, harness) {
|
|
321
565
|
streams.out(decision("deny", `${code}: ${detail}`, harness));
|
|
322
|
-
return EXIT_OK;
|
|
566
|
+
return harness === "grok" ? GROK_DENY_EXIT : EXIT_OK;
|
|
567
|
+
}
|
|
568
|
+
/** The machine-readable code a Contributor-tier session is refused with. */
|
|
569
|
+
export const MUSE_CONTRIBUTOR_REFUSAL = "hook-muse-contributor-model";
|
|
570
|
+
/**
|
|
571
|
+
* Refuse every tool call on a Contributor-tier model, whatever the policy says.
|
|
572
|
+
*
|
|
573
|
+
* Meta sells two tiers of the Muse Spark family and marks the difference in the
|
|
574
|
+
* model id: a Contributor variant "trades a lower price for permission to train
|
|
575
|
+
* on your prompts and completions", against a Standard variant documented as
|
|
576
|
+
* never trained on. So a Contributor session discloses every file it reads, and
|
|
577
|
+
* a policy that would autonomously allow a read of the workspace was not
|
|
578
|
+
* written with that in mind.
|
|
579
|
+
*
|
|
580
|
+
* ## Why this is not a policy line
|
|
581
|
+
*
|
|
582
|
+
* A policy grants and withholds authority over CLASSES. This is not about the
|
|
583
|
+
* class of the action; the same `read.file` is fine on one model and a
|
|
584
|
+
* disclosure on another. Encoding it as policy would mean every operator's
|
|
585
|
+
* `APPROVAL.md` had to name Meta's tiering to be safe, and one that did not
|
|
586
|
+
* would be silently unsafe. So the adapter refuses, above the policy, and no
|
|
587
|
+
* policy can widen it. That is a strictness increase only, which is the one
|
|
588
|
+
* direction a hook may move a verdict on its own (SPEC §11.1 invariant 4).
|
|
589
|
+
*
|
|
590
|
+
* ## The self-reported field
|
|
591
|
+
*
|
|
592
|
+
* `model` is the harness's claim about itself, and SPEC §11.1 says a
|
|
593
|
+
* self-reported field never REDUCES scrutiny. This only ever raises it: the
|
|
594
|
+
* string can refuse a call, never authorize one. An absent, empty or
|
|
595
|
+
* unparseable `model` is refused too, because a session that will not say what
|
|
596
|
+
* it is running is exactly the one not to trust with a read.
|
|
597
|
+
*
|
|
598
|
+
* ## What it cannot do
|
|
599
|
+
*
|
|
600
|
+
* It stops tool calls. It cannot recall the prompt that was already sent — a
|
|
601
|
+
* hook fires after the model has seen it — and it cannot know what the harness
|
|
602
|
+
* attached as context before the first tool call. `docs/muse-hook.md` opens
|
|
603
|
+
* with this, because a guard people over-trust is worse than no guard.
|
|
604
|
+
*/
|
|
605
|
+
function contributorModelRefusal(model) {
|
|
606
|
+
if (model === null || model.trim() === "") {
|
|
607
|
+
return "the envelope names no model, and a session that will not say what it is running cannot be recognised as non-contributor";
|
|
608
|
+
}
|
|
609
|
+
if (model.trim().toLowerCase().includes(CONTRIBUTOR_SUFFIX.replace(/^-/u, ""))) {
|
|
610
|
+
return `${model.trim()} is a Contributor-tier model: Meta trains on this tier's prompts and completions, so every file this session reads is disclosed. Refused regardless of policy; no grant widens it.`;
|
|
611
|
+
}
|
|
612
|
+
return null;
|
|
323
613
|
}
|
|
324
614
|
function readString(source, key) {
|
|
325
615
|
const value = source[key];
|
|
326
616
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
327
617
|
}
|
|
618
|
+
/**
|
|
619
|
+
* The snake_case key, or the camelCase one when the adapter speaks that
|
|
620
|
+
* dialect (APRV-243).
|
|
621
|
+
*
|
|
622
|
+
* snake_case is read FIRST in both dialects, so an envelope carrying both
|
|
623
|
+
* spellings resolves the same way for every harness and cannot be used to show
|
|
624
|
+
* one command to the classifier and another to the harness.
|
|
625
|
+
*/
|
|
626
|
+
function readDialect(source, snake, camel, camelCase) {
|
|
627
|
+
const value = source[snake];
|
|
628
|
+
if (value !== undefined)
|
|
629
|
+
return value;
|
|
630
|
+
return camelCase ? source[camel] : undefined;
|
|
631
|
+
}
|
|
632
|
+
function readDialectString(source, snake, camel, camelCase) {
|
|
633
|
+
const value = readDialect(source, snake, camel, camelCase);
|
|
634
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
635
|
+
}
|
|
328
636
|
/**
|
|
329
637
|
* Parse the PreToolUse JSON.
|
|
330
638
|
*
|
|
@@ -334,7 +642,7 @@ function readString(source, key) {
|
|
|
334
642
|
* and a gate that read the subject's own account of its intent would be letting
|
|
335
643
|
* a self-reported field reduce scrutiny (SPEC.md §11.1).
|
|
336
644
|
*/
|
|
337
|
-
function parseHookInput(raw) {
|
|
645
|
+
function parseHookInput(raw, camelCase = false) {
|
|
338
646
|
if (raw.trim().length === 0)
|
|
339
647
|
return { ok: false, detail: "hook stdin was empty" };
|
|
340
648
|
let parsed;
|
|
@@ -351,31 +659,42 @@ function parseHookInput(raw) {
|
|
|
351
659
|
return { ok: false, detail: "hook stdin is not a JSON object" };
|
|
352
660
|
}
|
|
353
661
|
const fields = parsed;
|
|
354
|
-
const toolName =
|
|
355
|
-
if (toolName === null)
|
|
356
|
-
return {
|
|
357
|
-
|
|
662
|
+
const toolName = readDialectString(fields, "tool_name", "toolName", camelCase);
|
|
663
|
+
if (toolName === null) {
|
|
664
|
+
return {
|
|
665
|
+
ok: false,
|
|
666
|
+
detail: camelCase ? "hook input has no tool_name or toolName" : "hook input has no tool_name",
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
const toolInputValue = readDialect(fields, "tool_input", "toolInput", camelCase);
|
|
358
670
|
const toolInput = typeof toolInputValue === "object" && toolInputValue !== null && !Array.isArray(toolInputValue)
|
|
359
671
|
? toolInputValue
|
|
360
672
|
: {};
|
|
361
|
-
const responseValue = fields
|
|
673
|
+
const responseValue = readDialect(fields, "tool_response", "toolResponse", camelCase);
|
|
674
|
+
const sessionId = readDialectString(fields, "session_id", "sessionId", camelCase);
|
|
362
675
|
return {
|
|
363
676
|
ok: true,
|
|
364
677
|
input: {
|
|
365
678
|
// The ONE shared bucket for an unreadable session (`core/loop.ts`'s
|
|
366
679
|
// `UNKNOWN_SESSION`): absence accrues faster than a readable id and never
|
|
367
680
|
// slower, which is the fail-closed direction.
|
|
368
|
-
sessionId:
|
|
681
|
+
sessionId: sessionId ?? UNKNOWN_SESSION,
|
|
682
|
+
sessionIdPresent: sessionId !== null,
|
|
683
|
+
// Grok Build sends `workspaceRoot` beside `cwd`. Only `cwd` is read:
|
|
684
|
+
// the classifier resolves paths against the directory the command will
|
|
685
|
+
// actually run in, and a workspace root is a different fact.
|
|
369
686
|
cwd: readString(fields, "cwd") ?? "",
|
|
370
687
|
toolName,
|
|
371
688
|
toolInput,
|
|
372
|
-
toolUseId:
|
|
373
|
-
hookEventName:
|
|
689
|
+
toolUseId: readDialectString(fields, "tool_use_id", "toolUseId", camelCase),
|
|
690
|
+
hookEventName: readDialectString(fields, "hook_event_name", "hookEventName", camelCase),
|
|
691
|
+
model: readDialectString(fields, "model", "model", camelCase),
|
|
374
692
|
harnessVersion: readString(fields, "version"),
|
|
375
|
-
interrupted: fields["is_interrupt"] === true,
|
|
693
|
+
interrupted: fields["is_interrupt"] === true || (camelCase && fields["isInterrupt"] === true),
|
|
376
694
|
toolResponse: typeof responseValue === "object" && responseValue !== null && !Array.isArray(responseValue)
|
|
377
695
|
? responseValue
|
|
378
696
|
: null,
|
|
697
|
+
toolResponseRaw: responseValue,
|
|
379
698
|
},
|
|
380
699
|
};
|
|
381
700
|
}
|
|
@@ -823,22 +1142,166 @@ export function refineScratchDelete(result, roots) {
|
|
|
823
1142
|
}
|
|
824
1143
|
return { result: { ok: true, segments, classes }, notes };
|
|
825
1144
|
}
|
|
1145
|
+
// ===========================================================================
|
|
1146
|
+
// Read scope, the disk half (APRV-347)
|
|
1147
|
+
// ===========================================================================
|
|
1148
|
+
/**
|
|
1149
|
+
* ## Why reads need a second pass too
|
|
1150
|
+
*
|
|
1151
|
+
* The pure classifier can settle an ABSOLUTE read target against the roots and
|
|
1152
|
+
* nothing else. `cat ../other/secrets`, `grep -r needle ./link-to-elsewhere`
|
|
1153
|
+
* and a bare `ls` all mean something only once a working directory and the
|
|
1154
|
+
* filesystem's own links are in hand, and those are exactly the spellings an
|
|
1155
|
+
* agent reaches for. So the read rule has the same two-part shape the delete
|
|
1156
|
+
* rule has: the text decides what text can decide, and this pass — which
|
|
1157
|
+
* resolves against the hook's OWN directory, follows links, and answers `null`
|
|
1158
|
+
* for anything it cannot resolve — tightens `read.shell` back to
|
|
1159
|
+
* `read.file.out_of_scope`.
|
|
1160
|
+
*
|
|
1161
|
+
* It only ever moves a segment toward the stricter class. A caller that skipped
|
|
1162
|
+
* it would be no more permissive than one that runs it, which is what lets
|
|
1163
|
+
* `hook classify` and `hook <harness>` share it without either becoming the
|
|
1164
|
+
* authority.
|
|
1165
|
+
*/
|
|
1166
|
+
/** The rule a read tightened by this pass reports. */
|
|
1167
|
+
const READ_SCOPE_REJECTED_RULE = "read-out-of-scope-resolved";
|
|
1168
|
+
/**
|
|
1169
|
+
* The read roots this process may vouch for, resolved.
|
|
1170
|
+
*
|
|
1171
|
+
* The gate root is the directory the hook resolved its POLICY from, never the
|
|
1172
|
+
* harness-supplied `cwd`: a scope the subject of the gate could choose is not a
|
|
1173
|
+
* scope (SPEC.md §11.1, self-reported fields never reduce scrutiny). The
|
|
1174
|
+
* scratchpad and temp roots are the ones `resolveScratchRoots` already computes
|
|
1175
|
+
* and already guards, so the two rules cannot disagree about where the agent's
|
|
1176
|
+
* own scratch is. `declared` is `read_scope.roots` out of the loaded policy,
|
|
1177
|
+
* which may only widen this set.
|
|
1178
|
+
*/
|
|
1179
|
+
export function resolveReadRoots(cwd, gateRoot, declared) {
|
|
1180
|
+
const roots = effectiveReadRoots({
|
|
1181
|
+
gateRoot: resolvedPath(gateRoot) ?? gateRoot,
|
|
1182
|
+
systemRoots: resolveScratchRoots(cwd),
|
|
1183
|
+
...(declared === undefined ? {} : { declared }),
|
|
1184
|
+
});
|
|
1185
|
+
const out = [];
|
|
1186
|
+
for (const root of roots) {
|
|
1187
|
+
const resolved = resolvedPath(root) ?? root;
|
|
1188
|
+
if (!out.includes(resolved))
|
|
1189
|
+
out.push(resolved);
|
|
1190
|
+
}
|
|
1191
|
+
return out;
|
|
1192
|
+
}
|
|
826
1193
|
/**
|
|
827
|
-
*
|
|
1194
|
+
* Where this target really is, or `null` when nothing can say.
|
|
1195
|
+
*
|
|
1196
|
+
* The same walk `targetStaysInScratch` does, and for the same reason: the file
|
|
1197
|
+
* may not exist yet (or at all), so the nearest EXISTING ancestor is resolved
|
|
1198
|
+
* and the unresolved tail re-appended. A symlink anywhere in that chain
|
|
1199
|
+
* therefore cannot smuggle a read out of the root, which is the escape the pure
|
|
1200
|
+
* half cannot see.
|
|
1201
|
+
*/
|
|
1202
|
+
function resolvedReadTarget(target, cwd) {
|
|
1203
|
+
let existing = isAbsolute(target) ? target : resolvePathSegments(cwd, target);
|
|
1204
|
+
const tail = [];
|
|
1205
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
1206
|
+
if (existsSync(existing))
|
|
1207
|
+
break;
|
|
1208
|
+
const up = dirname(existing);
|
|
1209
|
+
if (up === existing)
|
|
1210
|
+
return null;
|
|
1211
|
+
tail.unshift(basename(existing));
|
|
1212
|
+
existing = up;
|
|
1213
|
+
}
|
|
1214
|
+
const resolved = resolvedPath(existing);
|
|
1215
|
+
if (resolved === null)
|
|
1216
|
+
return null;
|
|
1217
|
+
return tail.length === 0 ? resolved : join(resolved, ...tail);
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Tighten a `read.shell` segment to `read.file.out_of_scope` wherever the disk
|
|
1221
|
+
* disagrees with the text.
|
|
1222
|
+
*
|
|
1223
|
+
* IMPURE by design and by contract. `roots` empty means the caller asked for no
|
|
1224
|
+
* read scoping at all, and every segment is returned untouched — the same
|
|
1225
|
+
* "absent yields today's answer" the classifier context promises.
|
|
1226
|
+
*/
|
|
1227
|
+
export function refineReadScope(result, roots, cwd) {
|
|
1228
|
+
if (!result.ok)
|
|
1229
|
+
return { result, notes: [] };
|
|
1230
|
+
if (roots.length === 0)
|
|
1231
|
+
return { result, notes: [] };
|
|
1232
|
+
if (!result.segments.some((segment) => segment.class === "read.shell")) {
|
|
1233
|
+
return { result, notes: [] };
|
|
1234
|
+
}
|
|
1235
|
+
const notes = [];
|
|
1236
|
+
const segments = result.segments.map((segment) => {
|
|
1237
|
+
if (segment.class !== "read.shell")
|
|
1238
|
+
return segment;
|
|
1239
|
+
const words = commandSegmentWords(segment.text);
|
|
1240
|
+
const parsed = words === null ? undefined : words[0];
|
|
1241
|
+
if (parsed === undefined)
|
|
1242
|
+
return segment;
|
|
1243
|
+
const positionals = parsed.args.filter((arg) => !arg.startsWith("-") || arg === "-");
|
|
1244
|
+
const declaredTargets = readTargetsOf(parsed.bin, positionals, parsed.args);
|
|
1245
|
+
if (declaredTargets === null)
|
|
1246
|
+
return segment;
|
|
1247
|
+
// No operand is not "no read": `ls`, `find` and a piped `grep needle` all
|
|
1248
|
+
// read the working directory, so the working directory is what is checked.
|
|
1249
|
+
const targets = declaredTargets.length === 0 ? ["."] : declaredTargets;
|
|
1250
|
+
const reject = (path, detail) => {
|
|
1251
|
+
notes.push(`${READ_SCOPE_REJECTED_RULE}: ${detail}`);
|
|
1252
|
+
return {
|
|
1253
|
+
...segment,
|
|
1254
|
+
class: READ_OUT_OF_SCOPE_CLASS,
|
|
1255
|
+
rule: READ_SCOPE_REJECTED_RULE,
|
|
1256
|
+
path,
|
|
1257
|
+
};
|
|
1258
|
+
};
|
|
1259
|
+
for (const target of targets) {
|
|
1260
|
+
const resolved = resolvedReadTarget(target, cwd);
|
|
1261
|
+
if (resolved === null) {
|
|
1262
|
+
return reject(target, `${target} does not resolve to any path this hook can see, so \`${segment.text}\` is ${READ_OUT_OF_SCOPE_CLASS}`);
|
|
1263
|
+
}
|
|
1264
|
+
if (!isInReadScope(resolved, roots)) {
|
|
1265
|
+
return reject(resolved, `${target} resolves to ${resolved}, which is outside the read scope (${renderReadRoots(roots)}), so \`${segment.text}\` is ${READ_OUT_OF_SCOPE_CLASS}`);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return segment;
|
|
1269
|
+
});
|
|
1270
|
+
if (notes.length === 0)
|
|
1271
|
+
return { result, notes };
|
|
1272
|
+
const classes = [];
|
|
1273
|
+
for (const segment of segments) {
|
|
1274
|
+
if (!classes.includes(segment.class))
|
|
1275
|
+
classes.push(segment.class);
|
|
1276
|
+
}
|
|
1277
|
+
return { result: { ok: true, segments, classes }, notes };
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* The classifier, its context, and all three impure refinements, in the one
|
|
828
1281
|
* order every caller must use.
|
|
829
1282
|
*
|
|
830
1283
|
* `hook classify` printing a different class from the one `hook claude-code`
|
|
831
1284
|
* decides would make the explainer a different program (APRV-108's note), and
|
|
832
|
-
* that stays true now there are
|
|
1285
|
+
* that stays true now there are three refinements in the chain.
|
|
1286
|
+
*
|
|
1287
|
+
* `readRoots` is the one argument whose ABSENCE is the loose answer rather than
|
|
1288
|
+
* the strict one (APRV-347), so it is passed explicitly at every call site: an
|
|
1289
|
+
* empty list means "do not scope reads", which is what every caller outside a
|
|
1290
|
+
* resolved gate scope wants and what this classifier did before the field
|
|
1291
|
+
* existed.
|
|
833
1292
|
*/
|
|
834
|
-
export function classifyForHook(command, protectedPaths, cwd) {
|
|
1293
|
+
export function classifyForHook(command, protectedPaths, cwd, readRoots = []) {
|
|
835
1294
|
const roots = resolveScratchRoots(cwd);
|
|
836
|
-
const classified = classifyCommand(command, protectedPaths, {
|
|
1295
|
+
const classified = classifyCommand(command, protectedPaths, {
|
|
1296
|
+
scratchRoots: roots,
|
|
1297
|
+
...(readRoots.length === 0 ? {} : { readRoots }),
|
|
1298
|
+
});
|
|
837
1299
|
const rewritten = refineRewrite(classified, cwd);
|
|
838
1300
|
const scratched = refineScratchDelete(rewritten.result, roots);
|
|
1301
|
+
const scoped = refineReadScope(scratched.result, readRoots, cwd);
|
|
839
1302
|
return {
|
|
840
|
-
result:
|
|
841
|
-
notes: [...rewritten.notes, ...scratched.notes],
|
|
1303
|
+
result: scoped.result,
|
|
1304
|
+
notes: [...rewritten.notes, ...scratched.notes, ...scoped.notes],
|
|
842
1305
|
};
|
|
843
1306
|
}
|
|
844
1307
|
// ===========================================================================
|
|
@@ -894,7 +1357,7 @@ function commandClassify(argv, streams, cwd) {
|
|
|
894
1357
|
if (command.length === 0) {
|
|
895
1358
|
return usageError(streams, "missing <command> argument for `approval hook classify`");
|
|
896
1359
|
}
|
|
897
|
-
const { options } = hookScope(parsed.flags, cwd);
|
|
1360
|
+
const { root, options } = hookScope(parsed.flags, cwd);
|
|
898
1361
|
const load = loadPolicy(options.policy?.file === undefined
|
|
899
1362
|
? { dir: options.policy?.dir ?? cwd }
|
|
900
1363
|
: { file: options.policy.file });
|
|
@@ -903,10 +1366,13 @@ function commandClassify(argv, streams, cwd) {
|
|
|
903
1366
|
}
|
|
904
1367
|
const protectedPaths = load.ok ? (load.policy.protected_paths ?? []) : [];
|
|
905
1368
|
// The same impure refinements `hook claude-code` applies (APRV-108,
|
|
906
|
-
// APRV-267), run against the same directory
|
|
907
|
-
// pure class where the hook decides a refined one
|
|
908
|
-
// different program.
|
|
909
|
-
|
|
1369
|
+
// APRV-267, APRV-347), run against the same directory and the same roots: an
|
|
1370
|
+
// explainer that printed the pure class where the hook decides a refined one
|
|
1371
|
+
// would be explaining a different program. A policy that did not load
|
|
1372
|
+
// contributes no `read_scope`, so the scope is the built-in roots alone,
|
|
1373
|
+
// which is the narrower answer and is what the hook itself would refuse on.
|
|
1374
|
+
const readRoots = resolveReadRoots(cwd, policyRootOf(load, root), load.ok ? load.policy.read_scope?.roots : undefined);
|
|
1375
|
+
streams.out(renderClassification(classifyForHook(command, protectedPaths, cwd, readRoots).result, boolFlag(parsed.flags, "--json")));
|
|
910
1376
|
return EXIT_OK;
|
|
911
1377
|
}
|
|
912
1378
|
function sleepSync(ms) {
|
|
@@ -1034,16 +1500,141 @@ function tierOf(target, cwd) {
|
|
|
1034
1500
|
* non-protected target, which is why the loop floor could not see an Edit.
|
|
1035
1501
|
*/
|
|
1036
1502
|
const WORKSPACE_WRITE_CLASS = "files.write.workspace";
|
|
1503
|
+
/**
|
|
1504
|
+
* The Codex app-server's change set, when a call carries one (APRV-363,
|
|
1505
|
+
* APRV-379).
|
|
1506
|
+
*
|
|
1507
|
+
* TWO SHAPES, because the protocol has two. The LEGACY `applyPatchApproval`
|
|
1508
|
+
* carries `fileChanges`, a map of path to change, inline on the request. The
|
|
1509
|
+
* ITEM-BASED API puts the same material on an earlier `item/started` frame as
|
|
1510
|
+
* an ARRAY of `{path, kind, diff}`, and the bridge correlates that frame to the
|
|
1511
|
+
* approval request by item id before handing it here (APRV-379). Both arrive as
|
|
1512
|
+
* the server sent them and neither is re-rendered into the other.
|
|
1513
|
+
*
|
|
1514
|
+
* `null` for every other `apply_patch` call, which keeps the envelope path
|
|
1515
|
+
* exactly as it was: the native hook's `apply_patch` tool sends a command
|
|
1516
|
+
* string and reaches this function's `null` on the first test.
|
|
1517
|
+
*/
|
|
1518
|
+
function codexFileChanges(toolInput) {
|
|
1519
|
+
const value = toolInput["file_changes"] ?? toolInput["fileChanges"];
|
|
1520
|
+
if (value === null || typeof value !== "object")
|
|
1521
|
+
return null;
|
|
1522
|
+
if (Array.isArray(value))
|
|
1523
|
+
return value.length === 0 ? null : value;
|
|
1524
|
+
const map = value;
|
|
1525
|
+
return Object.keys(map).length === 0 ? null : map;
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* The paths a change set names, in the order this runtime will classify them,
|
|
1529
|
+
* or `null` when an entry names none (APRV-379).
|
|
1530
|
+
*
|
|
1531
|
+
* The map's keys ARE its paths, sorted so one change set is one description
|
|
1532
|
+
* whatever key order the server used. The array's entries each carry a `path`
|
|
1533
|
+
* string, and the order is the server's own: an array is a sequence and
|
|
1534
|
+
* reordering it would be this runtime describing a change set nobody sent. An
|
|
1535
|
+
* entry that is not an object, or whose `path` is not a string, yields `null`
|
|
1536
|
+
* and the caller refuses the whole set, because a change set with one
|
|
1537
|
+
* unreadable member is a change set this runtime cannot say the extent of.
|
|
1538
|
+
*/
|
|
1539
|
+
function codexChangePaths(changes) {
|
|
1540
|
+
if (!Array.isArray(changes))
|
|
1541
|
+
return Object.keys(changes).sort();
|
|
1542
|
+
const paths = [];
|
|
1543
|
+
for (const entry of changes) {
|
|
1544
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
|
|
1545
|
+
return null;
|
|
1546
|
+
const declared = entry["path"];
|
|
1547
|
+
if (typeof declared !== "string")
|
|
1548
|
+
return null;
|
|
1549
|
+
paths.push(declared);
|
|
1550
|
+
}
|
|
1551
|
+
return paths;
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* What a change SET asks for: one class per path it names, and the change bound
|
|
1555
|
+
* whole (APRV-363, APRV-379).
|
|
1556
|
+
*
|
|
1557
|
+
* The class rule is the file tools' rule and not a second one: a protected
|
|
1558
|
+
* target takes its derived protected class, every other target is
|
|
1559
|
+
* `files.write.workspace`, and the policy decides the autonomy of either. The
|
|
1560
|
+
* payload is the change rather than the touch, for the reason
|
|
1561
|
+
* {@link fileToolGate} states at length, plus `content_sha256` over the set as
|
|
1562
|
+
* it ARRIVED, so a human's grant binds the bytes the server sent and a later
|
|
1563
|
+
* reader can check that it did. The set goes into the payload in the shape it
|
|
1564
|
+
* arrived in, map or array; nothing here reads `diff`, `kind` or any other
|
|
1565
|
+
* member, because one observed `add` is not a licence to parse Codex patch
|
|
1566
|
+
* semantics in this repository.
|
|
1567
|
+
*
|
|
1568
|
+
* Fails closed on a path this runtime cannot place: one that lands outside the
|
|
1569
|
+
* directory the server named, or one carrying a NUL. A change whose target
|
|
1570
|
+
* cannot be resolved cannot be classified, and a classification against the
|
|
1571
|
+
* wrong tree is the failure this whole file exists to avoid.
|
|
1572
|
+
*
|
|
1573
|
+
* An ABSOLUTE path is accepted when it resolves INSIDE that directory, which
|
|
1574
|
+
* APRV-379 changed: the observed item frame names absolute paths
|
|
1575
|
+
* (`docs/codex-app-server-bridge.md`, question 1), and an absolute path inside
|
|
1576
|
+
* the directory is exactly as placeable as a relative one. Containment is what
|
|
1577
|
+
* was ever doing the work here, and it still decides: a path outside is refused
|
|
1578
|
+
* whichever way it was spelled.
|
|
1579
|
+
*/
|
|
1580
|
+
function describeCodexFileChanges(changes, protectedPaths, cwd) {
|
|
1581
|
+
const classes = [];
|
|
1582
|
+
const notes = [];
|
|
1583
|
+
const paths = [];
|
|
1584
|
+
const declaredPaths = codexChangePaths(changes);
|
|
1585
|
+
if (declaredPaths === null) {
|
|
1586
|
+
return {
|
|
1587
|
+
kind: "deny",
|
|
1588
|
+
code: "hook-io",
|
|
1589
|
+
detail: "the file change set carries an entry that names no path string, so the extent of the change cannot be stated; nothing was classified",
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
for (const declared of declaredPaths) {
|
|
1593
|
+
if (declared.length === 0 || declared.includes("\0")) {
|
|
1594
|
+
return {
|
|
1595
|
+
kind: "deny",
|
|
1596
|
+
code: "hook-io",
|
|
1597
|
+
detail: `the file change names ${JSON.stringify(declared)}, which is not a path this runtime can place; a change whose target cannot be placed cannot be classified`,
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
const resolved = resolvePathSegments(cwd, declared);
|
|
1601
|
+
if (resolved !== cwd && !resolved.startsWith(`${cwd}${sep}`)) {
|
|
1602
|
+
return {
|
|
1603
|
+
kind: "deny",
|
|
1604
|
+
code: "hook-io",
|
|
1605
|
+
detail: `the file change names ${JSON.stringify(declared)}, which resolves outside ${cwd}`,
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
paths.push(declared);
|
|
1609
|
+
const cls = protectedPathClass(resolved, protectedPaths) ?? WORKSPACE_WRITE_CLASS;
|
|
1610
|
+
if (!classes.includes(cls))
|
|
1611
|
+
classes.push(cls);
|
|
1612
|
+
notes.push(`change ${declared} (${cls})`);
|
|
1613
|
+
}
|
|
1614
|
+
return {
|
|
1615
|
+
kind: "gated",
|
|
1616
|
+
classes,
|
|
1617
|
+
payload: {
|
|
1618
|
+
tool: "apply_patch",
|
|
1619
|
+
rule: "codex app-server file change",
|
|
1620
|
+
cwd,
|
|
1621
|
+
paths,
|
|
1622
|
+
content_sha256: payloadHash(changes),
|
|
1623
|
+
changes,
|
|
1624
|
+
},
|
|
1625
|
+
headline: `apply_patch ${String(paths.length)} file change(s)`,
|
|
1626
|
+
notes,
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1037
1629
|
/**
|
|
1038
1630
|
* What a non-Bash tool call asks for, or `null` when it names no file at all.
|
|
1039
1631
|
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
* latency to reach a foregone conclusion.
|
|
1632
|
+
* Every file edit is a gate question. Protected targets take their derived
|
|
1633
|
+
* protected class; every other target is `files.write.workspace`. The policy
|
|
1634
|
+
* decides the autonomy of either class, so changing the ordinary-file rule to
|
|
1635
|
+
* manual, supervised or human-only changes the hook verdict too (APRV-304).
|
|
1045
1636
|
*
|
|
1046
|
-
* ## The ordinary edit
|
|
1637
|
+
* ## The ordinary edit gets a class and follows it (APRV-303, APRV-304)
|
|
1047
1638
|
*
|
|
1048
1639
|
* It used to get none: an unprotected target returned `null` here, and
|
|
1049
1640
|
* `describeToolCall` answered `allow` from a branch that sits ABOVE the loop
|
|
@@ -1053,12 +1644,10 @@ const WORKSPACE_WRITE_CLASS = "files.write.workspace";
|
|
|
1053
1644
|
* eight edits to the same file, under a standing floor, none of them routed and
|
|
1054
1645
|
* none of them counted.
|
|
1055
1646
|
*
|
|
1056
|
-
*
|
|
1057
|
-
*
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
* establishment. The floor predicate is now one predicate over one class for
|
|
1061
|
-
* every tool kind, which is what amended SPEC.md §10.2 asks for.
|
|
1647
|
+
* APRV-303 made the floor predicate one predicate over one class for every tool
|
|
1648
|
+
* kind. APRV-304 carries the same class through the rest of the shared path:
|
|
1649
|
+
* policy resolution, human-only refusal, budgets, registration and execution
|
|
1650
|
+
* accounting. An open window records the bypass before allowing the edit.
|
|
1062
1651
|
*
|
|
1063
1652
|
* ## The payload is the change (APRV-124)
|
|
1064
1653
|
*
|
|
@@ -1133,6 +1722,68 @@ function fileToolGate(toolName, toolInput, protectedPaths, cwd) {
|
|
|
1133
1722
|
summary: summaryFor(tier, toolName, file),
|
|
1134
1723
|
};
|
|
1135
1724
|
}
|
|
1725
|
+
/**
|
|
1726
|
+
* The first entry of a `paths` array that falls outside the read scope, or
|
|
1727
|
+
* `null` when every entry is inside it (APRV-350).
|
|
1728
|
+
*
|
|
1729
|
+
* Separate from the single-path arm because the ANSWER is different: one path
|
|
1730
|
+
* either is or is not in scope, while a list is out of scope if ANY member is.
|
|
1731
|
+
* Returning the offending member rather than a boolean keeps the gated question
|
|
1732
|
+
* specific — an approver is asked about the directory that actually left the
|
|
1733
|
+
* scope, not about the whole list.
|
|
1734
|
+
*/
|
|
1735
|
+
function firstOutOfScope(toolInput, roots, cwd) {
|
|
1736
|
+
const paths = toolInput["paths"];
|
|
1737
|
+
if (!Array.isArray(paths))
|
|
1738
|
+
return null;
|
|
1739
|
+
for (const entry of paths) {
|
|
1740
|
+
if (typeof entry !== "string" || entry.trim() === "")
|
|
1741
|
+
continue;
|
|
1742
|
+
const resolved = resolvedReadTarget(entry, cwd);
|
|
1743
|
+
if (resolved === null || !isInReadScope(resolved, roots))
|
|
1744
|
+
return entry;
|
|
1745
|
+
}
|
|
1746
|
+
return null;
|
|
1747
|
+
}
|
|
1748
|
+
function readToolGate(toolName, toolInput, roots, cwd) {
|
|
1749
|
+
if (roots.length === 0)
|
|
1750
|
+
return null;
|
|
1751
|
+
const declared = readString(toolInput, "file_path") ??
|
|
1752
|
+
readString(toolInput, "notebook_path") ??
|
|
1753
|
+
readString(toolInput, "path") ??
|
|
1754
|
+
// APRV-350: Muse's `search` names an ARRAY under `paths`, and the live
|
|
1755
|
+
// capture caught one pointing clean out of the workspace. The FIRST entry
|
|
1756
|
+
// that resolves outside the scope is the one gated: a search over five
|
|
1757
|
+
// directories where one is out of scope is an out-of-scope read, and
|
|
1758
|
+
// gating the first in-scope entry instead would have let it through. An
|
|
1759
|
+
// unreadable entry counts as out of scope for the same fail-closed reason
|
|
1760
|
+
// the single-path arm below resolves that way.
|
|
1761
|
+
firstOutOfScope(toolInput, roots, cwd);
|
|
1762
|
+
if (declared === null)
|
|
1763
|
+
return null;
|
|
1764
|
+
// FAIL CLOSED on an unresolvable path (the task's AC1, and SPEC.md §11.1):
|
|
1765
|
+
// a target nothing on this disk can place is a target nothing can say is
|
|
1766
|
+
// inside the scope, so it is out of it.
|
|
1767
|
+
const resolved = resolvedReadTarget(declared, cwd);
|
|
1768
|
+
if (resolved !== null && isInReadScope(resolved, roots))
|
|
1769
|
+
return null;
|
|
1770
|
+
const file = resolved ?? absolute(declared, cwd);
|
|
1771
|
+
const input = { ...toolInput };
|
|
1772
|
+
delete input["description"];
|
|
1773
|
+
return {
|
|
1774
|
+
file,
|
|
1775
|
+
declared,
|
|
1776
|
+
// The binding bytes name the RESOLVED path as well as the tool's own input,
|
|
1777
|
+
// so an approver reads where the data actually comes from and a grant over
|
|
1778
|
+
// one spelling cannot be spent on another.
|
|
1779
|
+
payload: { tool: toolName, rule: "read-scope", file, input },
|
|
1780
|
+
summary: `${toolName} ${file} (outside the read scope)`,
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
/** The verdict note for a gated read: what was asked for, and against what. */
|
|
1784
|
+
function readScopeNote(gated, roots) {
|
|
1785
|
+
return `read-scope: ${gated.declared} resolves to ${gated.file}, which is outside the read scope (${renderReadRoots(roots)})`;
|
|
1786
|
+
}
|
|
1136
1787
|
/** The headline for a tier: the qualifier first, the touch after it. */
|
|
1137
1788
|
function summaryFor(tier, toolName, file) {
|
|
1138
1789
|
if (tier.worktree !== null) {
|
|
@@ -1551,44 +2202,19 @@ function announceWait(streams, run, waiting) {
|
|
|
1551
2202
|
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`);
|
|
1552
2203
|
}
|
|
1553
2204
|
/**
|
|
1554
|
-
* The
|
|
1555
|
-
*
|
|
1556
|
-
* whatever verdict it printed.
|
|
1557
|
-
*
|
|
1558
|
-
* ## Requests are keyed by bytes, not by invocation (APRV-117)
|
|
1559
|
-
*
|
|
1560
|
-
* The action key is still `hook:<session>:<tool-use id>:<class>` and is still
|
|
1561
|
-
* unique per invocation — what changed is that intake LOOKS for an earlier
|
|
1562
|
-
* request about the same `{command, cwd}` before opening a new one, matching on
|
|
1563
|
-
* the `payload_hash` recorded on `approval.requested`. Three outcomes per class,
|
|
1564
|
-
* decided by `core/gate.ts`'s `findHarnessCarry`:
|
|
1565
|
-
*
|
|
1566
|
-
* - nothing to carry: register and request, exactly as before;
|
|
1567
|
-
* - a pending request: **adopt** it — wait out the remainder of this
|
|
1568
|
-
* invocation's window on somebody else's key, opening nothing. The approver's
|
|
1569
|
-
* phone never shows two prompts for one command, because there is only ever
|
|
1570
|
-
* one question;
|
|
1571
|
-
* - an unspent grant inside the TTL: **carry** it — no wait, no prompt, and
|
|
1572
|
-
* the grant is spent (once) before the allow is printed.
|
|
2205
|
+
* The hook's own rendering of a verdict: one decision object on stdout, in the
|
|
2206
|
+
* dialect of the harness that asked.
|
|
1573
2207
|
*
|
|
1574
|
-
*
|
|
1575
|
-
*
|
|
1576
|
-
*
|
|
1577
|
-
* call was a new request with a new key and a late tap therefore authorized
|
|
1578
|
-
* nothing: the human spent attention on a question whose asker had left. The
|
|
1579
|
-
* carryover above removes the premise. A late tap now authorizes the retry, so
|
|
1580
|
-
* the request stays open for the policy's TTL and the timeout says so.
|
|
1581
|
-
*
|
|
1582
|
-
* What still withdraws is every path where nothing can adopt the question: a
|
|
1583
|
-
* SIGTERM or SIGINT (the session is going away), a thrown failure, and an intake
|
|
1584
|
-
* refusal partway through a multi-class command (the command cannot proceed on
|
|
1585
|
-
* any retry, so the classes already opened are noise in a human's queue). The
|
|
1586
|
-
* signal handlers are installed for the duration of the wait ONLY, and removed
|
|
1587
|
-
* in `finally`: a hook process is short-lived and borrowing the harness's
|
|
1588
|
-
* signal disposition for longer than the loop would be a side effect nobody
|
|
1589
|
-
* asked for.
|
|
2208
|
+
* The one place a verdict becomes bytes, so the Codex guard inside
|
|
2209
|
+
* {@link allow} — an allow that lost its exact bound `tool_input.command` is an
|
|
2210
|
+
* I/O failure rather than a permission — still stands over every path.
|
|
1590
2211
|
*/
|
|
1591
|
-
function
|
|
2212
|
+
function renderVerdict(streams, adapter, codexCommand, verdict) {
|
|
2213
|
+
return verdict.permission === "allow"
|
|
2214
|
+
? allow(streams, verdict.reason, adapter.kind, codexCommand)
|
|
2215
|
+
: deny(streams, verdict.code, verdict.detail, adapter.kind);
|
|
2216
|
+
}
|
|
2217
|
+
export function gateHarnessCall(streams, run, classes,
|
|
1592
2218
|
/**
|
|
1593
2219
|
* The bytes the grant binds to: `{command, cwd}` for a Bash call, the change
|
|
1594
2220
|
* itself for a file tool (APRV-124). Whatever this is, it is what reaches the
|
|
@@ -1639,18 +2265,22 @@ floor = null) {
|
|
|
1639
2265
|
const floorApplies = (cls) => floor !== null && isSideEffectingClass(cls);
|
|
1640
2266
|
const hash = payloadHash(payload);
|
|
1641
2267
|
const summary = truncate(headline, SUMMARY_LIMIT);
|
|
1642
|
-
const sayAllow = (reason) => allow
|
|
2268
|
+
const sayAllow = (reason) => ({ permission: "allow", reason });
|
|
1643
2269
|
/**
|
|
1644
|
-
* Every deny this function can
|
|
2270
|
+
* Every deny this function can reach, with the floor's own sentence appended
|
|
1645
2271
|
* when a floor is what routed the command here (APRV-280). One wrapper rather
|
|
1646
2272
|
* than a sentence bolted onto the timeout alone: a floored invocation that
|
|
1647
2273
|
* ends in a rejection, a lapse or an I/O fault leaves the agent in exactly the
|
|
1648
2274
|
* same place, and the operator reading the harness's error stream needs the
|
|
1649
2275
|
* scope key either way.
|
|
1650
2276
|
*/
|
|
1651
|
-
const sayDeny = (code, detail) =>
|
|
1652
|
-
|
|
1653
|
-
|
|
2277
|
+
const sayDeny = (code, detail) => ({
|
|
2278
|
+
permission: "deny",
|
|
2279
|
+
code,
|
|
2280
|
+
detail: floor === null
|
|
2281
|
+
? detail
|
|
2282
|
+
: `${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)}`,
|
|
2283
|
+
});
|
|
1654
2284
|
// Intake reads the VERIFIED log, once, before anything is written: an
|
|
1655
2285
|
// enforcement path reads nothing else (SPEC.md §11.1), and a carry decided
|
|
1656
2286
|
// from unverified bytes would be a grant invented by whoever could write the
|
|
@@ -2077,7 +2707,71 @@ function report(streams, code, detail, extra = {}) {
|
|
|
2077
2707
|
* whether two enumerated fields are present and what kind of value they hold.
|
|
2078
2708
|
* No text from any of them reaches the log or this function's return.
|
|
2079
2709
|
*/
|
|
2080
|
-
|
|
2710
|
+
/**
|
|
2711
|
+
* Muse's outcome, which it actually sends (APRV-350).
|
|
2712
|
+
*
|
|
2713
|
+
* The shell tool's `tool_response` is a JSON STRING, not an object, carrying
|
|
2714
|
+
* `exit_code`, `terminal_status`, `output` and `truncated`. The generic reader
|
|
2715
|
+
* above would see a string, find neither `interrupted` nor `error` on it, and
|
|
2716
|
+
* call every command completed — including the ones that failed. So Muse gets
|
|
2717
|
+
* its own reading, and it is the richer one: this harness sends both facts
|
|
2718
|
+
* Codex lacks.
|
|
2719
|
+
*
|
|
2720
|
+
* `terminal_status` leads because it distinguishes a command that ran from one
|
|
2721
|
+
* that was killed or timed out; `exit_code` decides the rest. Anything this
|
|
2722
|
+
* cannot read is UNREADABLE rather than assumed complete, which appends nothing
|
|
2723
|
+
* and leaves the path as vacuous as it was — the same choice the generic reader
|
|
2724
|
+
* makes, for the same reason. Nothing of `output` is read.
|
|
2725
|
+
*/
|
|
2726
|
+
function readMuseReportedOutcome(input) {
|
|
2727
|
+
const raw = input.toolResponseRaw;
|
|
2728
|
+
// The file tools answer with an object (`filePath`, `structuredPatch`) and
|
|
2729
|
+
// the read tools with a plain string of file content. Neither carries an
|
|
2730
|
+
// outcome, and the event name is the only fact available for them.
|
|
2731
|
+
if (typeof raw !== "string") {
|
|
2732
|
+
return { ok: true, outcome: "completed" };
|
|
2733
|
+
}
|
|
2734
|
+
let body;
|
|
2735
|
+
try {
|
|
2736
|
+
body = JSON.parse(raw);
|
|
2737
|
+
}
|
|
2738
|
+
catch {
|
|
2739
|
+
// A non-JSON string is a read tool's content. It says nothing about
|
|
2740
|
+
// success, and the event fired at all, so the event name stands.
|
|
2741
|
+
return { ok: true, outcome: "completed" };
|
|
2742
|
+
}
|
|
2743
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
2744
|
+
return { ok: true, outcome: "completed" };
|
|
2745
|
+
}
|
|
2746
|
+
const fields = body;
|
|
2747
|
+
const status = fields["terminal_status"];
|
|
2748
|
+
if (typeof status === "string" && status !== "completed") {
|
|
2749
|
+
// `aborted`, `timed_out`, and whatever else Meta adds: a command that did
|
|
2750
|
+
// not finish on its own terms neither completed nor failed, exactly as an
|
|
2751
|
+
// interrupt does not.
|
|
2752
|
+
return {
|
|
2753
|
+
ok: false,
|
|
2754
|
+
detail: `terminal_status is ${JSON.stringify(status)}, so the command neither completed nor failed on its own terms`,
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
const exitCode = fields["exit_code"];
|
|
2758
|
+
if (typeof exitCode === "number") {
|
|
2759
|
+
return { ok: true, outcome: exitCode === 0 ? "completed" : "failed" };
|
|
2760
|
+
}
|
|
2761
|
+
return { ok: true, outcome: "completed" };
|
|
2762
|
+
}
|
|
2763
|
+
function readReportedOutcome(input, adapter) {
|
|
2764
|
+
if (adapter.kind === "codex")
|
|
2765
|
+
return readCodexReportedOutcome(input);
|
|
2766
|
+
if (adapter.kind === "muse") {
|
|
2767
|
+
if (input.hookEventName !== "PostToolUse") {
|
|
2768
|
+
return {
|
|
2769
|
+
ok: false,
|
|
2770
|
+
detail: `hook_event_name is ${input.hookEventName === null ? "absent" : JSON.stringify(input.hookEventName)}, which is not the event this adapter reports an outcome for (PostToolUse)`,
|
|
2771
|
+
};
|
|
2772
|
+
}
|
|
2773
|
+
return readMuseReportedOutcome(input);
|
|
2774
|
+
}
|
|
2081
2775
|
const event = input.hookEventName;
|
|
2082
2776
|
if (event !== "PostToolUse" && event !== "PostToolUseFailure") {
|
|
2083
2777
|
return {
|
|
@@ -2114,7 +2808,12 @@ function readReportedOutcome(input) {
|
|
|
2114
2808
|
* is the reading of the event and nothing else.
|
|
2115
2809
|
*/
|
|
2116
2810
|
function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
|
|
2117
|
-
if (input.toolName !== adapter.shellTool &&
|
|
2811
|
+
if (input.toolName !== adapter.shellTool &&
|
|
2812
|
+
!adapter.fileTools.includes(input.toolName) &&
|
|
2813
|
+
// APRV-347: a read tool MAY have had a start written for it (one outside
|
|
2814
|
+
// the scope), so it belongs in this set. A read inside the scope wrote
|
|
2815
|
+
// nothing, and the close below finds no start and says so in its own words.
|
|
2816
|
+
!adapter.readTools.includes(input.toolName)) {
|
|
2118
2817
|
return report(streams, "post-tool-not-gated", `${input.toolName} is not a gated tool, so no execution.started was ever written for it`);
|
|
2119
2818
|
}
|
|
2120
2819
|
if (input.toolUseId === null) {
|
|
@@ -2124,28 +2823,80 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
|
|
|
2124
2823
|
// `approval status` rather than closed against a guess.
|
|
2125
2824
|
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");
|
|
2126
2825
|
}
|
|
2127
|
-
|
|
2826
|
+
// APRV-311. The id the pre-execution half minted for this same call, derived
|
|
2827
|
+
// here from the same two native fields it derived it from. It rides the
|
|
2828
|
+
// unreadable arm so that the line naming a start nobody closed also names
|
|
2829
|
+
// WHICH start: on Codex that arm is the only arm, and a diagnostic that
|
|
2830
|
+
// cannot be joined to an `execution.started` leaves an operator grepping a
|
|
2831
|
+
// log for a record they cannot identify. It is the correlation and nothing
|
|
2832
|
+
// more — no outcome is read, inferred, or appended on this path.
|
|
2833
|
+
const task = adapter.kind === "codex"
|
|
2834
|
+
? codexBinding(input, cwd).task
|
|
2835
|
+
: `hook:${input.sessionId}:${input.toolUseId}`;
|
|
2836
|
+
const reading = readReportedOutcome(input, adapter);
|
|
2128
2837
|
if (!reading.ok) {
|
|
2129
|
-
return report(streams, "post-tool-unreadable-outcome", `${reading.detail}; nothing was appended
|
|
2838
|
+
return report(streams, "post-tool-unreadable-outcome", `${reading.detail}; nothing was appended`, { task });
|
|
2130
2839
|
}
|
|
2131
2840
|
const { logPath, root } = hookScope(flags, cwd);
|
|
2132
2841
|
if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
|
|
2133
2842
|
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}`);
|
|
2134
2843
|
}
|
|
2135
2844
|
const finished = finishHarnessExecution(logPath, {
|
|
2136
|
-
sessionId: input.sessionId,
|
|
2137
|
-
toolUseId: input.toolUseId,
|
|
2845
|
+
sessionId: adapter.kind === "codex" ? codexBinding(input, cwd).finishSessionId : input.sessionId,
|
|
2846
|
+
toolUseId: adapter.kind === "codex" ? codexBinding(input, cwd).finishToolUseId : input.toolUseId,
|
|
2138
2847
|
outcome: reading.outcome,
|
|
2139
2848
|
// The one member of the closed set at v0.1. It names the untrusted
|
|
2140
2849
|
// reporter and reduces nothing.
|
|
2141
2850
|
reportedBy: "post-tool-use",
|
|
2142
2851
|
}, actor);
|
|
2143
2852
|
if (!finished.ok) {
|
|
2144
|
-
return report(streams, `post-tool-gate-refused:${finished.code}`, finished.message);
|
|
2853
|
+
return report(streams, `post-tool-gate-refused:${finished.code}`, finished.message, { task });
|
|
2145
2854
|
}
|
|
2146
2855
|
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 });
|
|
2147
2856
|
}
|
|
2148
|
-
function describeToolCall(input, adapter, protectedPaths, cwd) {
|
|
2857
|
+
function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
|
|
2858
|
+
if (adapter.kind === "codex" && input.toolName === "apply_patch") {
|
|
2859
|
+
// APRV-363. The app-server's LEGACY `applyPatchApproval` carries its change
|
|
2860
|
+
// as a MAP of path to change, inline on the request, and never as an
|
|
2861
|
+
// `apply_patch` envelope. Rendering the map into an envelope so the parser
|
|
2862
|
+
// below could read it would classify bytes this runtime wrote rather than
|
|
2863
|
+
// bytes the server sent, which is the hazard APRV-362 exists for on the
|
|
2864
|
+
// command side, so the map is classified as a map: by the paths it names,
|
|
2865
|
+
// through the same protected-path rules every other file tool uses, with
|
|
2866
|
+
// the change itself bound verbatim. It is described HERE rather than by the
|
|
2867
|
+
// caller because one describer answers "what is this call", and a caller
|
|
2868
|
+
// that could hand this module its own classes would be the party under
|
|
2869
|
+
// oversight choosing its own scrutiny (SPEC §11.1 invariant 4).
|
|
2870
|
+
//
|
|
2871
|
+
// APRV-379 widened the material this branch reads and changed nothing about
|
|
2872
|
+
// the reading. The ITEM-BASED API delivers the same change set as an ARRAY
|
|
2873
|
+
// on an earlier `item/started` frame, and the bridge correlates that frame
|
|
2874
|
+
// to the approval request by item id before calling in here. One describer
|
|
2875
|
+
// still answers the question for both APIs, which is the point: two
|
|
2876
|
+
// describers would be two answers to "what is this call", and the second
|
|
2877
|
+
// one would be the one nobody reviewed.
|
|
2878
|
+
const changes = codexFileChanges(input.toolInput);
|
|
2879
|
+
if (changes !== null) {
|
|
2880
|
+
return describeCodexFileChanges(changes, protectedPaths, cwd);
|
|
2881
|
+
}
|
|
2882
|
+
const raw = readString(input.toolInput, "command");
|
|
2883
|
+
if (raw === null) {
|
|
2884
|
+
return { kind: "deny", code: "hook-io", detail: "apply_patch tool_input carries no command string" };
|
|
2885
|
+
}
|
|
2886
|
+
const parsed = parseApplyPatch(raw);
|
|
2887
|
+
if (!parsed.ok)
|
|
2888
|
+
return { kind: "deny", code: "hook-io", detail: parsed.detail };
|
|
2889
|
+
const classified = classifyApplyPatch(parsed, cwd, protectedPaths);
|
|
2890
|
+
if (!classified.ok)
|
|
2891
|
+
return { kind: "deny", code: "hook-io", detail: classified.detail };
|
|
2892
|
+
return {
|
|
2893
|
+
kind: "gated",
|
|
2894
|
+
classes: classified.classes,
|
|
2895
|
+
payload: codexBinding(input, cwd).payload,
|
|
2896
|
+
headline: `apply_patch ${classified.operations.length} operation(s)`,
|
|
2897
|
+
notes: classified.targets.map((target) => `${target.role} ${target.path} (${target.classes.join(", ")})`),
|
|
2898
|
+
};
|
|
2899
|
+
}
|
|
2149
2900
|
if (input.toolName === adapter.shellTool) {
|
|
2150
2901
|
const raw = readString(input.toolInput, "command");
|
|
2151
2902
|
if (raw === null) {
|
|
@@ -2155,15 +2906,55 @@ function describeToolCall(input, adapter, protectedPaths, cwd) {
|
|
|
2155
2906
|
detail: `${adapter.shellTool} tool_input carries no command string`,
|
|
2156
2907
|
};
|
|
2157
2908
|
}
|
|
2909
|
+
// APRV-362. A call may state the argv its command renders, and the app-server
|
|
2910
|
+
// bridge does, so the payload can name the words the kernel receives rather
|
|
2911
|
+
// than a string somebody still has to parse. Two accounts of one call that
|
|
2912
|
+
// disagree describe no call at all, so the disagreement is refused here
|
|
2913
|
+
// instead of being resolved in favour of either. `codexArgv` accepts an
|
|
2914
|
+
// argv only when the command splits to exactly it, which is why the only
|
|
2915
|
+
// reachable outcomes are "absent", "agrees" and this.
|
|
2916
|
+
if (adapter.kind === "codex" && codexArgvDisagrees(input.toolInput, raw)) {
|
|
2917
|
+
return {
|
|
2918
|
+
kind: "deny",
|
|
2919
|
+
code: "hook-io",
|
|
2920
|
+
detail: "the call states an argv that its own command string does not split to; a payload cannot bind two readings of one command, so nothing was classified",
|
|
2921
|
+
};
|
|
2922
|
+
}
|
|
2923
|
+
// APRV-350: the PER-CALL working directory, where the harness sends one.
|
|
2924
|
+
//
|
|
2925
|
+
// Muse's `bash` carries `workdir`, and it is the directory the command will
|
|
2926
|
+
// actually run in; the top-level `cwd` is the session root and the two can
|
|
2927
|
+
// differ. The classifier resolves relative paths against this, so binding
|
|
2928
|
+
// the session root instead would classify `rm -rf docs` against the wrong
|
|
2929
|
+
// tree. Only an ABSOLUTE value is taken: a relative `workdir` is the
|
|
2930
|
+
// harness describing a location this runtime cannot place, and a
|
|
2931
|
+
// self-reported field may not talk its way into a narrower answer
|
|
2932
|
+
// (SPEC §11.1), so the fallback is the value that was already trusted.
|
|
2933
|
+
// `null` for every adapter that declares no key, which leaves both the
|
|
2934
|
+
// payload and the classification EXACTLY as they were. That is not
|
|
2935
|
+
// defensiveness: `input.cwd` and the hook's own `cwd` are different values,
|
|
2936
|
+
// and a first version of this that used the envelope's `cwd` for every
|
|
2937
|
+
// adapter re-classified Grok's commands against a directory that does not
|
|
2938
|
+
// exist on disk and denied them all.
|
|
2939
|
+
const perCallCwd = adapter.shellCwdKey === undefined
|
|
2940
|
+
? null
|
|
2941
|
+
: readString(input.toolInput, adapter.shellCwdKey);
|
|
2942
|
+
const shellCwd = perCallCwd !== null && isAbsolute(perCallCwd) ? perCallCwd : null;
|
|
2158
2943
|
// Unchanged since APRV-117, deliberately: the payload is the WHOLE command
|
|
2159
2944
|
// and the directory it runs in, so the FULL PAYLOAD block on the phone
|
|
2160
2945
|
// carries every byte the harness will execute. Only `summary` is shortened.
|
|
2161
|
-
const payload =
|
|
2946
|
+
const payload = adapter.bindToolName
|
|
2947
|
+
? codexBinding(input, cwd).payload
|
|
2948
|
+
: { command: raw, cwd: shellCwd ?? input.cwd };
|
|
2162
2949
|
// APRV-108: a local rewrite of history this checkout never published is a
|
|
2163
2950
|
// commit. APRV-267: a delete confined to the agent's own scratch is not a
|
|
2164
2951
|
// decision. Both run in the hook's own cwd, after classification and never
|
|
2165
2952
|
// inside it, and neither claims anything it cannot establish from the disk.
|
|
2166
|
-
|
|
2953
|
+
// Classified against the directory the command will RUN in, not the
|
|
2954
|
+
// hook's own, so a relative path in the command resolves the way the shell
|
|
2955
|
+
// will resolve it. For every adapter without a per-call working directory
|
|
2956
|
+
// this is `input.cwd` or the hook's own cwd exactly as before.
|
|
2957
|
+
const refined = classifyForHook(raw, protectedPaths, shellCwd ?? cwd, readRoots);
|
|
2167
2958
|
const classified = refined.result;
|
|
2168
2959
|
if (!classified.ok) {
|
|
2169
2960
|
return {
|
|
@@ -2172,29 +2963,111 @@ function describeToolCall(input, adapter, protectedPaths, cwd) {
|
|
|
2172
2963
|
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.`,
|
|
2173
2964
|
};
|
|
2174
2965
|
}
|
|
2966
|
+
const classes = classified.classes.filter((cls) => cls !== GATE_SELF_CLASS);
|
|
2967
|
+
if (adapter.kind === "codex") {
|
|
2968
|
+
// The pure shell classifier sees each segment independently. Preserve
|
|
2969
|
+
// Codex hook organs when an earlier simple `cd` changes the directory or
|
|
2970
|
+
// when the hook itself runs inside an organ directory by resolving every
|
|
2971
|
+
// later side-effecting segment's words from the effective directory.
|
|
2972
|
+
const possibleCwds = new Set([cwd]);
|
|
2973
|
+
for (const segment of classified.segments) {
|
|
2974
|
+
const parsedWords = commandSegmentWords(segment.text)?.[0];
|
|
2975
|
+
if (parsedWords?.bin === "cd" && parsedWords.args.length !== 1) {
|
|
2976
|
+
return {
|
|
2977
|
+
kind: "deny",
|
|
2978
|
+
code: "hook-io",
|
|
2979
|
+
detail: "Codex Bash cwd changes must use exact `cd <directory>` with no additional words",
|
|
2980
|
+
};
|
|
2981
|
+
}
|
|
2982
|
+
if (parsedWords?.bin === "cd" &&
|
|
2983
|
+
(parsedWords.args[0] === "-" ||
|
|
2984
|
+
(!isAbsolute(parsedWords.args[0] ?? "") &&
|
|
2985
|
+
parsedWords.args[0] !== "." &&
|
|
2986
|
+
parsedWords.args[0] !== ".." &&
|
|
2987
|
+
!(parsedWords.args[0] ?? "").startsWith("./") &&
|
|
2988
|
+
!(parsedWords.args[0] ?? "").startsWith("../")))) {
|
|
2989
|
+
return {
|
|
2990
|
+
kind: "deny",
|
|
2991
|
+
code: "hook-io",
|
|
2992
|
+
detail: "Codex Bash cwd changes must name an absolute path, `.`, `..`, `./...`, or `../...`; OLDPWD and CDPATH-dependent operands are unsupported",
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
if (isSideEffectingClass(segment.class)) {
|
|
2996
|
+
for (const possibleCwd of possibleCwds) {
|
|
2997
|
+
const cwdSegments = possibleCwd.split(/[/\\]+/u);
|
|
2998
|
+
const cwdClass = cwdSegments.includes(".codex")
|
|
2999
|
+
? "policy.core"
|
|
3000
|
+
: protectedPathClass(possibleCwd, protectedPaths);
|
|
3001
|
+
if (cwdClass !== null && !classes.includes(cwdClass))
|
|
3002
|
+
classes.push(cwdClass);
|
|
3003
|
+
for (const word of parsedWords === undefined ? [] : [parsedWords.bin, ...parsedWords.args]) {
|
|
3004
|
+
const cls = protectedPathClass(resolvePathSegments(possibleCwd, word), protectedPaths);
|
|
3005
|
+
if (cls !== null && !classes.includes(cls))
|
|
3006
|
+
classes.push(cls);
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
if (parsedWords?.bin === "cd" && parsedWords.args.length === 1) {
|
|
3011
|
+
// Lists and conditionals may skip a cd. Retain every prior directory
|
|
3012
|
+
// and add each directory the cd could establish; later writes are
|
|
3013
|
+
// checked against their union.
|
|
3014
|
+
const priorCwds = Array.from(possibleCwds);
|
|
3015
|
+
for (const possibleCwd of priorCwds) {
|
|
3016
|
+
const lexical = resolvePathSegments(possibleCwd, parsedWords.args[0] ?? "");
|
|
3017
|
+
possibleCwds.add(lexical);
|
|
3018
|
+
try {
|
|
3019
|
+
possibleCwds.add(realpathSync(lexical));
|
|
3020
|
+
}
|
|
3021
|
+
catch {
|
|
3022
|
+
return {
|
|
3023
|
+
kind: "deny",
|
|
3024
|
+
code: "hook-io",
|
|
3025
|
+
detail: `Codex Bash cd target ${JSON.stringify(parsedWords.args[0])} could not be resolved`,
|
|
3026
|
+
};
|
|
3027
|
+
}
|
|
3028
|
+
if (possibleCwds.size > 64) {
|
|
3029
|
+
return {
|
|
3030
|
+
kind: "deny",
|
|
3031
|
+
code: "hook-io",
|
|
3032
|
+
detail: "Codex Bash command has more than 64 possible working directories",
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
2175
3039
|
return {
|
|
2176
3040
|
kind: "gated",
|
|
2177
|
-
classes
|
|
3041
|
+
classes,
|
|
2178
3042
|
payload,
|
|
2179
3043
|
headline: raw,
|
|
2180
3044
|
notes: refined.notes,
|
|
2181
3045
|
segments: classified.segments,
|
|
2182
3046
|
};
|
|
2183
3047
|
}
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
3048
|
+
// APRV-347, above the file-tool branch because the two lists are disjoint and
|
|
3049
|
+
// a read is the cheaper question to answer: a read inside the scope, or one
|
|
3050
|
+
// naming no path at all, returns `null` here and takes the allow below.
|
|
3051
|
+
if (adapter.readTools.includes(input.toolName)) {
|
|
3052
|
+
const read = readToolGate(input.toolName, input.toolInput, readRoots, cwd);
|
|
3053
|
+
if (read === null) {
|
|
3054
|
+
return {
|
|
3055
|
+
kind: "allow",
|
|
3056
|
+
reason: `${input.toolName} is not a gated tool`,
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
2189
3059
|
return {
|
|
2190
3060
|
kind: "gated",
|
|
2191
|
-
classes: [
|
|
2192
|
-
payload:
|
|
2193
|
-
headline:
|
|
2194
|
-
notes: [],
|
|
2195
|
-
passthrough: `${input.toolName} is not a gated edit`,
|
|
3061
|
+
classes: [READ_OUT_OF_SCOPE_CLASS],
|
|
3062
|
+
payload: read.payload,
|
|
3063
|
+
headline: read.summary,
|
|
3064
|
+
notes: [readScopeNote(read, readRoots)],
|
|
2196
3065
|
};
|
|
2197
3066
|
}
|
|
3067
|
+
const gated = fileToolGate(input.toolName, input.toolInput, protectedPaths, cwd);
|
|
3068
|
+
if (gated === null) {
|
|
3069
|
+
return { kind: "allow", reason: `${input.toolName} names no file, so there is nothing to gate` };
|
|
3070
|
+
}
|
|
2198
3071
|
return {
|
|
2199
3072
|
kind: "gated",
|
|
2200
3073
|
classes: [gated.cls],
|
|
@@ -2202,7 +3075,7 @@ function describeToolCall(input, adapter, protectedPaths, cwd) {
|
|
|
2202
3075
|
headline: gated.summary,
|
|
2203
3076
|
// The tier rides in the verdict's note as well as in the payload, so an
|
|
2204
3077
|
// `allow` says which checkout it authorized (APRV-124).
|
|
2205
|
-
notes: [fileTierNote(gated)],
|
|
3078
|
+
notes: gated.protectedPath ? [fileTierNote(gated)] : [],
|
|
2206
3079
|
};
|
|
2207
3080
|
}
|
|
2208
3081
|
/** The environment variable that turns the sandbox requirement on (APRV-193). */
|
|
@@ -2328,6 +3201,7 @@ function runBypass(streams, input, adapter, cwd, logPath, flags, actor, window,
|
|
|
2328
3201
|
* shape.
|
|
2329
3202
|
*/
|
|
2330
3203
|
decidedOn) {
|
|
3204
|
+
const codexCommand = adapter.kind === "codex" ? codexBinding(input, cwd).payload.command : undefined;
|
|
2331
3205
|
const scope = hookScope(flags, cwd);
|
|
2332
3206
|
const load = loadPolicy(scope.options.policy?.file === undefined
|
|
2333
3207
|
? { dir: scope.options.policy?.dir ?? cwd }
|
|
@@ -2336,20 +3210,16 @@ decidedOn) {
|
|
|
2336
3210
|
const policyNote = load.ok
|
|
2337
3211
|
? null
|
|
2338
3212
|
: `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`;
|
|
2339
|
-
const described = describeToolCall(input, adapter, protectedPaths, cwd
|
|
3213
|
+
const described = describeToolCall(input, adapter, protectedPaths, cwd,
|
|
3214
|
+
// APRV-347. The window suspends the POLICY, not the classification: a read
|
|
3215
|
+
// outside the scope is described as one here too, so the bypass RECORD says
|
|
3216
|
+
// what was actually authorized rather than calling it an ordinary read.
|
|
3217
|
+
resolveReadRoots(cwd, policyRootOf(load, scope.root), load.ok ? load.policy.read_scope?.roots : undefined));
|
|
2340
3218
|
if (described.kind === "deny") {
|
|
2341
3219
|
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);
|
|
2342
3220
|
}
|
|
2343
|
-
if (described.kind === "allow")
|
|
2344
|
-
return allow(streams, described.reason, adapter.kind);
|
|
2345
|
-
if (described.passthrough !== undefined) {
|
|
2346
|
-
// APRV-303. An ordinary workspace edit is allowed by the policy on its own
|
|
2347
|
-
// merits, so there is nothing here for the window to suspend and nothing
|
|
2348
|
-
// for a `gate.bypassed` record to say. The only thing that would have made
|
|
2349
|
-
// this call a question is a §10.2 floor, and a window bypasses the floor
|
|
2350
|
-
// outright. Answered here rather than below so the bypass log stays a
|
|
2351
|
-
// record of calls the window actually let through.
|
|
2352
|
-
return allow(streams, described.passthrough, adapter.kind);
|
|
3221
|
+
if (described.kind === "allow") {
|
|
3222
|
+
return allow(streams, described.reason, adapter.kind, codexCommand);
|
|
2353
3223
|
}
|
|
2354
3224
|
const classes = described.classes;
|
|
2355
3225
|
if (classes.length === 0) {
|
|
@@ -2357,13 +3227,22 @@ decidedOn) {
|
|
|
2357
3227
|
// record for the reason it is allowed outside a window: gating the gate
|
|
2358
3228
|
// with the gate recurses, and a window that recorded its own closing verb
|
|
2359
3229
|
// would be recording the act that ends it.
|
|
2360
|
-
return allow(streams, "the approval CLI is the gate itself and is not gated by it", adapter.kind);
|
|
3230
|
+
return allow(streams, "the approval CLI is the gate itself and is not gated by it", adapter.kind, codexCommand);
|
|
2361
3231
|
}
|
|
2362
3232
|
const mutation = classes.find((cls) => cls === "log.mutate");
|
|
2363
3233
|
if (mutation !== undefined) {
|
|
2364
3234
|
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);
|
|
2365
3235
|
}
|
|
2366
3236
|
if (load.ok) {
|
|
3237
|
+
// APRV-354, above the human-only check here for the same reason it sits
|
|
3238
|
+
// above it on the ordinary path. A window suspends what the policy DECIDES;
|
|
3239
|
+
// it cannot supply a decision the policy never made. A launch the policy
|
|
3240
|
+
// names no rule for is not a class the window is holding open — it is a
|
|
3241
|
+
// class nobody has opted into — so the window does not reach it either.
|
|
3242
|
+
const unruled = classes.find((cls) => harnessLaunchNeedsRule(cls, resolvePolicy(load, cls)));
|
|
3243
|
+
if (unruled !== undefined) {
|
|
3244
|
+
return deny(streams, "hook-harness-launch-unruled", `${harnessLaunchUnruledRefusal(unruled, "this command may not run under an agent")} An open window does not reach it: a window suspends what the policy decides, and this is a class the policy has not spoken about at all.`, adapter.kind);
|
|
3245
|
+
}
|
|
2367
3246
|
const reserved = classes.find((cls) => resolvePolicy(load, cls).autonomy === "human-only");
|
|
2368
3247
|
if (reserved !== undefined) {
|
|
2369
3248
|
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);
|
|
@@ -2400,9 +3279,10 @@ decidedOn) {
|
|
|
2400
3279
|
}
|
|
2401
3280
|
streams.err(bypassBanner(window, classes, recorded.record.seq));
|
|
2402
3281
|
const notes = [...described.notes, ...(policyNote === null ? [] : [policyNote])];
|
|
2403
|
-
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);
|
|
3282
|
+
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);
|
|
2404
3283
|
}
|
|
2405
3284
|
function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
3285
|
+
const configurationError = (message) => adapter.kind === "codex" ? deny(streams, "hook-io", message, adapter.kind) : usageError(streams, message);
|
|
2406
3286
|
const parsed = parseFlags(argv, {
|
|
2407
3287
|
...COMMON_FLAGS,
|
|
2408
3288
|
...POLICY_FLAGS,
|
|
@@ -2413,29 +3293,40 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2413
3293
|
"--retry-grace": "string",
|
|
2414
3294
|
});
|
|
2415
3295
|
if (!parsed.ok)
|
|
2416
|
-
return
|
|
3296
|
+
return configurationError(parsed.message);
|
|
2417
3297
|
if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
|
|
2418
|
-
|
|
3298
|
+
// APRV-243. Grok's help carries the `.grok/hooks/*.json` the human commits,
|
|
3299
|
+
// because that file's `timeout` is load-bearing: it must exceed `--timeout`
|
|
3300
|
+
// or Grok abandons the hook mid-wait and, failing open, runs the command.
|
|
3301
|
+
// Muse gets its own for the same reason and one more: its `.muse/hooks.json`
|
|
3302
|
+
// shape is not Claude's file under a different name, and an operator who
|
|
3303
|
+
// guessed would get "Hooks: 0 runnable" and a session that looks gated.
|
|
3304
|
+
const harnessHelp = adapter.kind === "grok"
|
|
3305
|
+
? HOOK_GROK_HELP
|
|
3306
|
+
: adapter.kind === "muse"
|
|
3307
|
+
? HOOK_MUSE_HELP
|
|
3308
|
+
: HOOK_HELP;
|
|
3309
|
+
streams.out(`${harnessHelp}\n`);
|
|
2419
3310
|
return EXIT_OK;
|
|
2420
3311
|
}
|
|
2421
3312
|
const extra = parsed.positionals[0];
|
|
2422
3313
|
if (extra !== undefined) {
|
|
2423
|
-
return
|
|
3314
|
+
return configurationError(`unexpected argument ${JSON.stringify(extra)}`);
|
|
2424
3315
|
}
|
|
2425
3316
|
const asFlag = stringFlag(parsed.flags, "--as");
|
|
2426
3317
|
const actor = asFlag ?? adapter.defaultActor;
|
|
2427
3318
|
if (!PRINCIPAL_ACTOR.test(actor)) {
|
|
2428
|
-
return
|
|
3319
|
+
return configurationError(`--as expects agent:<id> or human:<id>, got ${JSON.stringify(asFlag)}`);
|
|
2429
3320
|
}
|
|
2430
3321
|
const timeoutText = stringFlag(parsed.flags, "--timeout") ?? DEFAULT_TIMEOUT;
|
|
2431
3322
|
const timeoutMs = parseDuration(timeoutText);
|
|
2432
3323
|
if (timeoutMs === null) {
|
|
2433
|
-
return
|
|
3324
|
+
return configurationError(`--timeout expects a duration like 30s, 9m, got ${JSON.stringify(timeoutText)}`);
|
|
2434
3325
|
}
|
|
2435
3326
|
const intervalText = stringFlag(parsed.flags, "--interval");
|
|
2436
3327
|
const intervalMs = intervalText === null ? DEFAULT_INTERVAL_MS : parseDuration(intervalText);
|
|
2437
3328
|
if (intervalMs === null) {
|
|
2438
|
-
return
|
|
3329
|
+
return configurationError(`--interval expects a duration like 500ms, 2s, got ${JSON.stringify(intervalText)}`);
|
|
2439
3330
|
}
|
|
2440
3331
|
// APRV-287. How long the question outlives the wait, for the retry that
|
|
2441
3332
|
// adopts it. The duration grammar has no zero, so the shortest window is
|
|
@@ -2443,12 +3334,67 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2443
3334
|
const graceText = stringFlag(parsed.flags, "--retry-grace");
|
|
2444
3335
|
const graceMs = graceText === null ? HOOK_RETRY_GRACE_MS : parseDuration(graceText);
|
|
2445
3336
|
if (graceMs === null) {
|
|
2446
|
-
return
|
|
3337
|
+
return configurationError(`--retry-grace expects a duration like 5m, 30s, 1ms, got ${JSON.stringify(graceText)}`);
|
|
2447
3338
|
}
|
|
2448
|
-
const parsedInput = parseHookInput(readStdin());
|
|
3339
|
+
const parsedInput = parseHookInput(readStdin(), adapter.camelCaseEnvelope === true);
|
|
2449
3340
|
if (!parsedInput.ok)
|
|
2450
3341
|
return deny(streams, "hook-io", parsedInput.detail, adapter.kind);
|
|
2451
3342
|
const input = parsedInput.input;
|
|
3343
|
+
if (adapter.kind === "codex") {
|
|
3344
|
+
const checked = checkCodexHookInput(input, cwd);
|
|
3345
|
+
if (!checked.ok) {
|
|
3346
|
+
// APRV-311. WHICH PHASE the malformed event belongs to decides which
|
|
3347
|
+
// vocabulary refuses it, and until now both took the pre-execution one.
|
|
3348
|
+
// A `PostToolUse` event naming an unsupported tool, or carrying an id
|
|
3349
|
+
// this adapter will not accept, printed
|
|
3350
|
+
// `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"}}`
|
|
3351
|
+
// at exit 0: a permission decision about a call that has already run, in
|
|
3352
|
+
// the same words the pre-execution refusal uses, so nothing downstream
|
|
3353
|
+
// could tell the two apart. Nothing was ever going to be authorized here.
|
|
3354
|
+
//
|
|
3355
|
+
// The strict answer on this side is the one the rest of the post path
|
|
3356
|
+
// already gives: say so on stderr at the exit code that makes the line
|
|
3357
|
+
// visible, append nothing, and print no verdict. The event name is read
|
|
3358
|
+
// off the raw input rather than off the check, because the check is what
|
|
3359
|
+
// just failed.
|
|
3360
|
+
return input.hookEventName === CODEX_POST_TOOL_EVENT
|
|
3361
|
+
? report(streams, "post-tool-io", `${checked.detail}; nothing was appended, so the start this event would have closed is still open`)
|
|
3362
|
+
: deny(streams, "hook-io", checked.detail, adapter.kind);
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
const codexCommand = adapter.kind === "codex" ? codexBinding(input, cwd).payload.command : undefined;
|
|
3366
|
+
// APRV-350. The contributor guard runs BEFORE the event dispatch, the policy
|
|
3367
|
+
// and everything else, because it is not a question about the action: a
|
|
3368
|
+
// Contributor-tier session discloses every byte it reads, so there is nothing
|
|
3369
|
+
// for a policy to authorize. Refusing first also means no unparseable payload,
|
|
3370
|
+
// no missing log and no classifier quirk can route around it.
|
|
3371
|
+
//
|
|
3372
|
+
// On a POST event it refuses in the post vocabulary and prints no verdict:
|
|
3373
|
+
// Muse rejects a permission field on a post event, and a rejected hook is a
|
|
3374
|
+
// FAILED hook, which fails open. There is nothing to block after the fact
|
|
3375
|
+
// anyway; the line exists so the disclosure is on a stream somebody reads.
|
|
3376
|
+
if (adapter.contributorModelGuard === true) {
|
|
3377
|
+
const refusal = contributorModelRefusal(input.model);
|
|
3378
|
+
if (refusal !== null) {
|
|
3379
|
+
const postEvent = input.hookEventName !== null && POST_TOOL_EVENTS.includes(input.hookEventName);
|
|
3380
|
+
return postEvent
|
|
3381
|
+
? report(streams, MUSE_CONTRIBUTOR_REFUSAL, `${refusal} Nothing was appended.`)
|
|
3382
|
+
: deny(streams, MUSE_CONTRIBUTOR_REFUSAL, refusal, adapter.kind);
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
// APRV-350. The harness's own bookkeeping tool, answered and never gated.
|
|
3386
|
+
// Muse fires both events for `submit_reminder_decision` continuously — 100 of
|
|
3387
|
+
// the 139 events in the live capture — and it records a self-assessment and
|
|
3388
|
+
// touches nothing. It is answered with an explicit allow rather than left to
|
|
3389
|
+
// fall through, so the verdict is one dialect and nothing else, which is the
|
|
3390
|
+
// only shape this harness parses.
|
|
3391
|
+
if (adapter.passThroughTools?.includes(input.toolName) === true) {
|
|
3392
|
+
const passPostEvent = input.hookEventName !== null && POST_TOOL_EVENTS.includes(input.hookEventName);
|
|
3393
|
+
if (passPostEvent) {
|
|
3394
|
+
return report(streams, "post-tool-not-gated", `${input.toolName} is the harness's own bookkeeping tool, so no execution.started was ever written for it`);
|
|
3395
|
+
}
|
|
3396
|
+
return allow(streams, `${input.toolName} is ${adapter.kind}'s own bookkeeping tool: it records the session's self-assessment and touches nothing outside the session`, adapter.kind);
|
|
3397
|
+
}
|
|
2452
3398
|
// APRV-145: WHICH EVENT THIS IS, read first and read at all. One command is
|
|
2453
3399
|
// registered for two events, and they do opposite things — one answers before
|
|
2454
3400
|
// the tool runs, the other records how it went — so the dispatch is the first
|
|
@@ -2459,22 +3405,64 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2459
3405
|
// harness whose event this runtime does not recognize is a harness about to
|
|
2460
3406
|
// run a command, and treating an unknown name as a no-op would be an ungated
|
|
2461
3407
|
// one.
|
|
2462
|
-
|
|
3408
|
+
const postToolEvent = adapter.kind === "codex"
|
|
3409
|
+
? input.hookEventName === CODEX_POST_TOOL_EVENT
|
|
3410
|
+
: input.hookEventName !== null && POST_TOOL_EVENTS.includes(input.hookEventName);
|
|
3411
|
+
if (postToolEvent) {
|
|
2463
3412
|
// APRV-303. `commandHarnessHook`'s catch turns a throw into a DENY, which is
|
|
2464
3413
|
// the right answer for a call that has not run yet and exactly the wrong one
|
|
2465
3414
|
// here: it would print a verdict object about a tool call the harness has
|
|
2466
3415
|
// already finished, and the reason the counterpart did not land would be
|
|
2467
3416
|
// dressed as a permission decision. A throw on this path is `post-tool-io`,
|
|
2468
3417
|
// on stderr, at the exit code that makes the line visible.
|
|
3418
|
+
//
|
|
3419
|
+
// APRV-243. Grok Build reads exit 2 as DENY, full stop, so the visibility
|
|
3420
|
+
// exit this path uses everywhere else would be a permission decision about
|
|
3421
|
+
// a tool call that has already run. On this one harness the post-execution
|
|
3422
|
+
// path therefore always exits 0. The machine-readable line still goes to
|
|
3423
|
+
// stderr; whether Grok shows it is Grok's business, and losing a debug line
|
|
3424
|
+
// is a smaller harm than emitting a verdict the protocol will act on.
|
|
3425
|
+
const settle = (code) => (adapter.kind === "grok" ? EXIT_OK : code);
|
|
2469
3426
|
try {
|
|
2470
|
-
return runPostToolUse(parsed.flags, streams, cwd, input, actor, adapter);
|
|
3427
|
+
return settle(runPostToolUse(parsed.flags, streams, cwd, input, actor, adapter));
|
|
2471
3428
|
}
|
|
2472
3429
|
catch (cause) {
|
|
2473
|
-
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`);
|
|
3430
|
+
return settle(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`));
|
|
2474
3431
|
}
|
|
2475
3432
|
}
|
|
3433
|
+
// Codex 0.152.1 can execute Bash in a per-call working directory that is
|
|
3434
|
+
// absent from tool_input while both the event cwd and this hook process stay
|
|
3435
|
+
// at the session root (APRV-310 native v6). A decision over the visible
|
|
3436
|
+
// `{command, cwd}` would therefore bind different bytes from the action the
|
|
3437
|
+
// harness executes. Refuse before the open-window, gate-self, carry, or
|
|
3438
|
+
// registration paths; none of those can supply the missing directory fact.
|
|
3439
|
+
if (adapter.kind === "codex" && input.toolName === "Bash") {
|
|
3440
|
+
return deny(streams, "hook-unsupported-execution-context", "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);
|
|
3441
|
+
}
|
|
2476
3442
|
if (input.toolName !== adapter.shellTool && !adapter.fileTools.includes(input.toolName)) {
|
|
2477
|
-
|
|
3443
|
+
if (!adapter.readTools.includes(input.toolName)) {
|
|
3444
|
+
return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
|
|
3445
|
+
}
|
|
3446
|
+
// APRV-347, and the placement is the whole of its cost. A read tool call is
|
|
3447
|
+
// the most frequent event a session produces, and before this task it was
|
|
3448
|
+
// answered here with no policy load, no verified read of the log and no
|
|
3449
|
+
// window lookup. Everything below this line is expensive, so the reads that
|
|
3450
|
+
// do not need it must not pay for it.
|
|
3451
|
+
//
|
|
3452
|
+
// The short-circuit is sound because `read_scope` is ADDITIVE: it can only
|
|
3453
|
+
// widen the roots, so a target already inside the BUILT-IN roots is inside
|
|
3454
|
+
// the effective ones whatever the policy says, and the policy need not be
|
|
3455
|
+
// read to know it. A target outside them falls through, the policy is
|
|
3456
|
+
// loaded below, and `describeToolCall` asks again with the widened set —
|
|
3457
|
+
// which is where a policy-declared root actually takes effect.
|
|
3458
|
+
//
|
|
3459
|
+
// The cost of the fast path is a handful of `realpath` calls and no process
|
|
3460
|
+
// spawn, provided the hook is configured with `--dir` as the docs show
|
|
3461
|
+
// (otherwise `hookScope` runs `git rev-parse` to find the primary).
|
|
3462
|
+
const early = hookScope(parsed.flags, cwd);
|
|
3463
|
+
if (readToolGate(input.toolName, input.toolInput, resolveReadRoots(cwd, early.root), cwd) === null) {
|
|
3464
|
+
return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
|
|
3465
|
+
}
|
|
2478
3466
|
}
|
|
2479
3467
|
// APRV-188. From here on this process may resume a verified read behind the
|
|
2480
3468
|
// snapshot the daemon published, instead of walking the chain from genesis:
|
|
@@ -2511,6 +3499,40 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2511
3499
|
// verdict and the record are one read of the log.
|
|
2512
3500
|
looked.records === null ? null : { records: looked.records, head: looked.head });
|
|
2513
3501
|
}
|
|
3502
|
+
return renderVerdict(streams, adapter, codexCommand, decideHarnessCall({
|
|
3503
|
+
streams,
|
|
3504
|
+
input,
|
|
3505
|
+
adapter,
|
|
3506
|
+
cwd,
|
|
3507
|
+
logPath,
|
|
3508
|
+
root,
|
|
3509
|
+
options,
|
|
3510
|
+
actor,
|
|
3511
|
+
timeoutMs,
|
|
3512
|
+
intervalMs,
|
|
3513
|
+
graceMs,
|
|
3514
|
+
codexCommand,
|
|
3515
|
+
windowRecords: looked.records,
|
|
3516
|
+
}));
|
|
3517
|
+
}
|
|
3518
|
+
/**
|
|
3519
|
+
* Classify, resolve, gate and wait: one harness tool call, from the event to a
|
|
3520
|
+
* verdict (APRV-361).
|
|
3521
|
+
*
|
|
3522
|
+
* Extracted from the hook's own verb so a SECOND caller can reach a decision
|
|
3523
|
+
* through exactly this sequence. `cli/codex-bridge.ts` answers Codex's
|
|
3524
|
+
* app-server approval requests over JSON-RPC rather than on stdout, and the
|
|
3525
|
+
* thing it must not do is re-implement any of what is below: the human-only
|
|
3526
|
+
* refusal, the unruled `harness.launch.*` refusal, the sandbox requirement, the
|
|
3527
|
+
* loop floor, the unattended guard, the autonomous charge, and the register,
|
|
3528
|
+
* request and wait that follow. Two implementations of that sequence would be
|
|
3529
|
+
* two gates, and the second one would be the one nobody reviewed.
|
|
3530
|
+
*
|
|
3531
|
+
* It returns a verdict and prints none. `streams.err` still carries the
|
|
3532
|
+
* progress and withdrawal lines, which are a report rather than a decision.
|
|
3533
|
+
*/
|
|
3534
|
+
export function decideHarnessCall(decide) {
|
|
3535
|
+
const { streams, input, adapter, cwd, logPath, root, options, actor, timeoutMs, intervalMs, graceMs, codexCommand, windowRecords, } = decide;
|
|
2514
3536
|
// The policy is read BEFORE the command is classified (APRV-107): the
|
|
2515
3537
|
// protected-path set is built-ins plus `policy.protected_paths`, so what
|
|
2516
3538
|
// counts as a protected path is a policy question and the classifier cannot be
|
|
@@ -2523,24 +3545,36 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2523
3545
|
? { dir: options.policy?.dir ?? cwd }
|
|
2524
3546
|
: { file: options.policy.file });
|
|
2525
3547
|
if (!load.ok) {
|
|
2526
|
-
return
|
|
3548
|
+
return {
|
|
3549
|
+
permission: "deny",
|
|
3550
|
+
code: "hook-policy-unavailable",
|
|
3551
|
+
detail: `${load.code}: ${load.message}; every class resolves to manual and the hook cannot verify a decision`,
|
|
3552
|
+
};
|
|
2527
3553
|
}
|
|
2528
3554
|
const protectedPaths = load.policy.protected_paths ?? [];
|
|
3555
|
+
// APRV-347. Resolved from the LOADED policy's own directory, so the scope is
|
|
3556
|
+
// anchored to the gate a decision will be recorded in, and widened by
|
|
3557
|
+
// `read_scope.roots` if that policy declares any.
|
|
3558
|
+
const readRoots = resolveReadRoots(cwd, policyRootOf(load, root), load.policy.read_scope?.roots);
|
|
2529
3559
|
// What is being asked for, as one or more classes. One description site for
|
|
2530
3560
|
// both paths since APRV-214 (see `describeToolCall`): the open window
|
|
2531
3561
|
// classifies exactly as the closed one does, and a second copy of this would
|
|
2532
3562
|
// be a second answer to "what is this command".
|
|
2533
|
-
const described = describeToolCall(input, adapter, protectedPaths, cwd);
|
|
3563
|
+
const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots);
|
|
2534
3564
|
if (described.kind === "deny") {
|
|
2535
|
-
return deny
|
|
3565
|
+
return { permission: "deny", code: described.code, detail: described.detail };
|
|
3566
|
+
}
|
|
3567
|
+
if (described.kind === "allow") {
|
|
3568
|
+
return { permission: "allow", reason: described.reason };
|
|
2536
3569
|
}
|
|
2537
|
-
if (described.kind === "allow")
|
|
2538
|
-
return allow(streams, described.reason, adapter.kind);
|
|
2539
3570
|
const { classes, payload, headline } = described;
|
|
2540
3571
|
/** What the history-rewrite refinement did, for the decision reason. */
|
|
2541
3572
|
const notes = [...described.notes];
|
|
2542
3573
|
if (classes.length === 0) {
|
|
2543
|
-
return
|
|
3574
|
+
return {
|
|
3575
|
+
permission: "allow",
|
|
3576
|
+
reason: "the approval CLI is the gate itself and is not gated by it",
|
|
3577
|
+
};
|
|
2544
3578
|
}
|
|
2545
3579
|
// Every path from here needs the log, the fast paths included (APRV-139):
|
|
2546
3580
|
// attestation and loop-escalation are facts about the log, so the
|
|
@@ -2549,11 +3583,17 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2549
3583
|
// on-disk policy called autonomous; it now denies, which is the same answer
|
|
2550
3584
|
// it already gave every other class.
|
|
2551
3585
|
if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
|
|
2552
|
-
return
|
|
3586
|
+
return {
|
|
3587
|
+
permission: "deny",
|
|
3588
|
+
code: "hook-log-unreachable",
|
|
3589
|
+
detail: `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`,
|
|
3590
|
+
};
|
|
2553
3591
|
}
|
|
2554
3592
|
// Minted once, here, and carried into `gateAndWait`: the loop-escalation
|
|
2555
3593
|
// check below and any registration that follows must name the same task.
|
|
2556
|
-
const task =
|
|
3594
|
+
const task = adapter.kind === "codex"
|
|
3595
|
+
? codexBinding(input, cwd).task
|
|
3596
|
+
: `hook:${input.sessionId}:${input.toolUseId ?? randomBytes(8).toString("hex")}`;
|
|
2557
3597
|
const run = {
|
|
2558
3598
|
logPath,
|
|
2559
3599
|
options,
|
|
@@ -2564,6 +3604,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2564
3604
|
ttlMs: load.durations.approvalTtlMs,
|
|
2565
3605
|
harness: adapter.kind,
|
|
2566
3606
|
originApp: adapter.originApp,
|
|
3607
|
+
...(codexCommand === undefined ? {} : { codexCommand }),
|
|
2567
3608
|
eventVersion: input.harnessVersion,
|
|
2568
3609
|
// Off the policy this function already loaded and validated, so the names
|
|
2569
3610
|
// printed are the names a channel process would serve (APRV-281). Sorted
|
|
@@ -2572,7 +3613,26 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2572
3613
|
// state.
|
|
2573
3614
|
channels: Object.keys(load.policy.channels ?? {}).sort(),
|
|
2574
3615
|
};
|
|
2575
|
-
const
|
|
3616
|
+
const resolutions = classes.map((cls) => resolvePolicy(load, cls));
|
|
3617
|
+
const autonomies = resolutions.map((resolution) => resolution.autonomy);
|
|
3618
|
+
// APRV-354, and ABOVE the human-only deny because it is the narrower
|
|
3619
|
+
// statement: this class is not merely reserved, it is one the policy has not
|
|
3620
|
+
// spoken about at all, and the repair is a line rather than a person.
|
|
3621
|
+
//
|
|
3622
|
+
// A `harness.launch.*` class resolves only under a rule an operator wrote. A
|
|
3623
|
+
// policy naming neither the family nor the member refuses the launch instead
|
|
3624
|
+
// of falling to `defaults.autonomy`, which keeps arrival exactly as strict as
|
|
3625
|
+
// it was before the family existed (`hook-unclassified`, refused) until
|
|
3626
|
+
// somebody opts in. See `harnessLaunchNeedsRule` for why the family gets this
|
|
3627
|
+
// and no other class does.
|
|
3628
|
+
const unruled = classes.find((cls, index) => harnessLaunchNeedsRule(cls, resolutions[index]));
|
|
3629
|
+
if (unruled !== undefined) {
|
|
3630
|
+
return {
|
|
3631
|
+
permission: "deny",
|
|
3632
|
+
code: "hook-harness-launch-unruled",
|
|
3633
|
+
detail: harnessLaunchUnruledRefusal(unruled, "this command may not run under an agent"),
|
|
3634
|
+
};
|
|
3635
|
+
}
|
|
2576
3636
|
// APRV-185, amended SPEC.md §5.2, and the first verdict this function reaches
|
|
2577
3637
|
// once the classes have autonomies. A command touching a class the policy
|
|
2578
3638
|
// reserves to human hands is denied outright: no request is opened, no task is
|
|
@@ -2588,7 +3648,11 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2588
3648
|
// strictest thing in it.
|
|
2589
3649
|
const reserved = classes.find((_cls, index) => autonomies[index] === "human-only");
|
|
2590
3650
|
if (reserved !== undefined) {
|
|
2591
|
-
return
|
|
3651
|
+
return {
|
|
3652
|
+
permission: "deny",
|
|
3653
|
+
code: "hook-class-human-only",
|
|
3654
|
+
detail: `${humanOnlyRefusal(reserved, "this command may not run under an agent")} The gate's own code for this fact is \`class-human-only\`.`,
|
|
3655
|
+
};
|
|
2592
3656
|
}
|
|
2593
3657
|
// APRV-193, and BELOW the human-only deny for the same reason that one sits
|
|
2594
3658
|
// above the floor: a class no agent may run is answered before a question
|
|
@@ -2596,7 +3660,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2596
3660
|
// refused command leaves the log exactly as it found it.
|
|
2597
3661
|
const unsandboxed = sandboxRequirement(described.segments, autonomies);
|
|
2598
3662
|
if (unsandboxed !== null) {
|
|
2599
|
-
return deny
|
|
3663
|
+
return { permission: "deny", code: "hook-sandbox-required", detail: unsandboxed };
|
|
2600
3664
|
}
|
|
2601
3665
|
// APRV-145, amended SPEC.md §10.2: loop safety on a surface that mints a
|
|
2602
3666
|
// fresh task id per tool call. The floor is applied AFTER class resolution and
|
|
@@ -2611,9 +3675,9 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2611
3675
|
// have proceeded is routed to the human gate for this invocation (APRV-297
|
|
2612
3676
|
// narrowed it to those); a class that already resolves manual is untouched,
|
|
2613
3677
|
// because it was already going there.
|
|
2614
|
-
const floored = harnessFloor(logPath, task, actor,
|
|
3678
|
+
const floored = harnessFloor(logPath, task, actor, windowRecords);
|
|
2615
3679
|
if (!floored.ok)
|
|
2616
|
-
return deny
|
|
3680
|
+
return { permission: "deny", code: "hook-io", detail: floored.detail };
|
|
2617
3681
|
/**
|
|
2618
3682
|
* The streak the log shows, before the read carve-out (APRV-297).
|
|
2619
3683
|
*
|
|
@@ -2659,26 +3723,13 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2659
3723
|
* refinement's own words, and the loop floor's when one applied.
|
|
2660
3724
|
*/
|
|
2661
3725
|
const note = notes.length === 0 ? "" : ` (${notes.join("; ")})`;
|
|
2662
|
-
// APRV-303, and the last thing that can answer without touching the log: an
|
|
2663
|
-
// ordinary workspace edit, which the policy allows on its own merits and which
|
|
2664
|
-
// is a question only while a floor stands.
|
|
2665
|
-
//
|
|
2666
|
-
// The order is the whole fix. Until APRV-303 this allow was printed from
|
|
2667
|
-
// `describeToolCall`'s own branch, several hundred lines above the floor
|
|
2668
|
-
// lookup, so a session whose Bash calls were all being routed to a human went
|
|
2669
|
-
// on editing files unrouted and uncounted. Now the same allow is printed, in
|
|
2670
|
-
// the same words, from BELOW the floor: the fast path is as fast as it was,
|
|
2671
|
-
// and the floored path routes an Edit exactly as it routes an `echo >`,
|
|
2672
|
-
// because both are `files.write.workspace` and one predicate decides.
|
|
2673
|
-
if (described.passthrough !== undefined && floor === null) {
|
|
2674
|
-
return allow(streams, `${described.passthrough}${note}`, adapter.kind);
|
|
2675
|
-
}
|
|
2676
3726
|
/** No class here needs a human, so nothing downstream will ask for one. */
|
|
2677
3727
|
const unattended = floor === null && autonomies.every((autonomy) => autonomy !== "manual");
|
|
2678
3728
|
if (unattended) {
|
|
2679
|
-
const refused = unattendedGuard(logPath, load.source.path, task,
|
|
2680
|
-
if (refused !== null)
|
|
2681
|
-
return deny
|
|
3729
|
+
const refused = unattendedGuard(logPath, load.source.path, task, windowRecords);
|
|
3730
|
+
if (refused !== null) {
|
|
3731
|
+
return { permission: "deny", code: refused.code, detail: refused.detail };
|
|
3732
|
+
}
|
|
2682
3733
|
}
|
|
2683
3734
|
if (floor === null && autonomies.every((autonomy) => autonomy === "autonomous")) {
|
|
2684
3735
|
// No approval lifecycle: an autonomous action has none (amended SPEC.md
|
|
@@ -2688,9 +3739,13 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2688
3739
|
// charge is not a budget. See `recordUnattended`.
|
|
2689
3740
|
const charged = recordUnattended(run, task, classes, payloadHash(payload));
|
|
2690
3741
|
if (charged !== null) {
|
|
2691
|
-
return
|
|
3742
|
+
return {
|
|
3743
|
+
permission: "deny",
|
|
3744
|
+
code: `hook-gate-refused:${charged.code}`,
|
|
3745
|
+
detail: charged.message,
|
|
3746
|
+
};
|
|
2692
3747
|
}
|
|
2693
|
-
return allow
|
|
3748
|
+
return { permission: "allow", reason: `autonomous: ${classes.join(", ")}${note}` };
|
|
2694
3749
|
}
|
|
2695
3750
|
// Past here the hook appends. It writes to a log that already exists and
|
|
2696
3751
|
// creates none: a log the hook scaffolded where it happened to be standing
|
|
@@ -2698,7 +3753,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2698
3753
|
// do not survive a merge. An initialized-but-empty `.approval/log/` counts as
|
|
2699
3754
|
// reachable — an audit trail that has recorded nothing is an empty log, not a
|
|
2700
3755
|
// missing one (see `preflightLog`) — and `register` appends the first line.
|
|
2701
|
-
return
|
|
3756
|
+
return gateHarnessCall(streams, run, classes, payload, headline, task, note, floor);
|
|
2702
3757
|
}
|
|
2703
3758
|
function commandHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
2704
3759
|
try {
|
|
@@ -2729,15 +3784,14 @@ export function commandHook(argv, streams, cwd, readStdin = defaultStdin) {
|
|
|
2729
3784
|
streams.out(`${HOOK_HELP}\n`);
|
|
2730
3785
|
return EXIT_OK;
|
|
2731
3786
|
}
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
case "classify":
|
|
2738
|
-
return commandClassify(rest, streams, cwd);
|
|
2739
|
-
default:
|
|
2740
|
-
return usageError(streams, `unknown subcommand ${JSON.stringify(sub)} for \`approval hook\``);
|
|
3787
|
+
// APRV-358: one list. `isHarnessKind` is the same predicate the write
|
|
3788
|
+
// boundary and doctor use, so a kind that can be recorded is a kind that can
|
|
3789
|
+
// be invoked, and neither can be added without the other.
|
|
3790
|
+
if (isHarnessKind(sub)) {
|
|
3791
|
+
return commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub]);
|
|
2741
3792
|
}
|
|
3793
|
+
if (sub === "classify")
|
|
3794
|
+
return commandClassify(rest, streams, cwd);
|
|
3795
|
+
return usageError(streams, `unknown subcommand ${JSON.stringify(sub)} for \`approval hook\``);
|
|
2742
3796
|
}
|
|
2743
3797
|
//# sourceMappingURL=hook.js.map
|