approval-md 0.2.0 → 0.4.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 +85 -877
- package/SPEC.md +150 -19
- package/cli.js +66 -7
- package/dist/src/adapters/smtp.d.ts +18 -0
- package/dist/src/adapters/smtp.js +24 -3
- package/dist/src/adapters/smtp.js.map +1 -1
- package/dist/src/channels/contract.d.ts +53 -1
- package/dist/src/channels/contract.js +304 -7
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/relay-server.d.ts +90 -0
- package/dist/src/channels/relay-server.js +295 -0
- package/dist/src/channels/relay-server.js.map +1 -0
- package/dist/src/channels/relay.d.ts +273 -0
- package/dist/src/channels/relay.js +564 -0
- package/dist/src/channels/relay.js.map +1 -0
- package/dist/src/channels/tagging.js +6 -1
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-webhook.d.ts +273 -0
- package/dist/src/channels/telegram-webhook.js +536 -0
- package/dist/src/channels/telegram-webhook.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +394 -12
- package/dist/src/channels/telegram.js +621 -68
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/channels/web.d.ts +9 -0
- package/dist/src/channels/web.js +17 -0
- package/dist/src/channels/web.js.map +1 -1
- package/dist/src/cli/amend.js +564 -34
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.d.ts +9 -0
- package/dist/src/cli/attest.js +177 -13
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-relay.d.ts +108 -0
- package/dist/src/cli/channel-relay.js +334 -0
- package/dist/src/cli/channel-relay.js.map +1 -0
- package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
- package/dist/src/cli/channel-telegram-webhook.js +709 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +347 -28
- package/dist/src/cli/channel-telegram.js +763 -28
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +10 -1
- package/dist/src/cli/channel.js +14 -1
- package/dist/src/cli/channel.js.map +1 -1
- 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 +1 -1
- package/dist/src/cli/codex.js +304 -7
- package/dist/src/cli/codex.js.map +1 -1
- package/dist/src/cli/daemon.js +41 -6
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +1322 -122
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +17 -1
- package/dist/src/cli/execute.js +366 -22
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +12 -0
- package/dist/src/cli/exit-codes.js +13 -0
- package/dist/src/cli/exit-codes.js.map +1 -1
- package/dist/src/cli/gate.d.ts +2 -0
- package/dist/src/cli/gate.js +233 -2
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/help.d.ts +14 -2
- package/dist/src/cli/help.js +395 -82
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook-codex.d.ts +49 -1
- package/dist/src/cli/hook-codex.js +60 -1
- package/dist/src/cli/hook-codex.js.map +1 -1
- package/dist/src/cli/hook.d.ts +828 -4
- package/dist/src/cli/hook.js +2972 -142
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/import.js +1 -1
- package/dist/src/cli/import.js.map +1 -1
- package/dist/src/cli/init.js +16 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.js +24 -3
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +55 -0
- package/dist/src/cli/log-anchor.js +199 -60
- package/dist/src/cli/log-anchor.js.map +1 -1
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +56 -3
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/muse.d.ts +2 -0
- package/dist/src/cli/muse.js +62 -0
- package/dist/src/cli/muse.js.map +1 -0
- package/dist/src/cli/payload.js +79 -1
- package/dist/src/cli/payload.js.map +1 -1
- 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.js +14 -1
- package/dist/src/cli/policy.js.map +1 -1
- package/dist/src/cli/preflight.d.ts +151 -13
- package/dist/src/cli/preflight.js +398 -41
- package/dist/src/cli/preflight.js.map +1 -1
- package/dist/src/cli/sandbox.js +17 -1
- package/dist/src/cli/sandbox.js.map +1 -1
- package/dist/src/cli/scaffold.d.ts +1 -1
- package/dist/src/cli/scaffold.js +7 -1
- package/dist/src/cli/scaffold.js.map +1 -1
- package/dist/src/cli/serve.d.ts +53 -0
- package/dist/src/cli/serve.js +270 -0
- package/dist/src/cli/serve.js.map +1 -0
- package/dist/src/cli/setup-channel.d.ts +9 -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-common.d.ts +3 -1
- package/dist/src/cli/setup-common.js +3 -2
- package/dist/src/cli/setup-common.js.map +1 -1
- package/dist/src/cli/setup.d.ts +2 -0
- package/dist/src/cli/setup.js +94 -2
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/up.js +134 -51
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/values.js +3 -4
- package/dist/src/cli/values.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +510 -47
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- 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/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/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/core/advance-cycle.d.ts +51 -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 +20 -18
- package/dist/src/core/agents-md.js +33 -31
- package/dist/src/core/agents-md.js.map +1 -1
- package/dist/src/core/attest-bootstrap.d.ts +95 -0
- package/dist/src/core/attest-bootstrap.js +135 -0
- package/dist/src/core/attest-bootstrap.js.map +1 -0
- package/dist/src/core/attest.d.ts +234 -2
- package/dist/src/core/attest.js +342 -7
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +73 -3
- package/dist/src/core/audit.js +65 -5
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-lease.d.ts +322 -0
- package/dist/src/core/channel-lease.js +663 -0
- package/dist/src/core/channel-lease.js.map +1 -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/command-class.d.ts +205 -0
- package/dist/src/core/command-class.js +1433 -28
- 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/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/daemon-host.d.ts +178 -0
- package/dist/src/core/daemon-host.js +228 -0
- package/dist/src/core/daemon-host.js.map +1 -0
- package/dist/src/core/daemon-identity.d.ts +238 -0
- package/dist/src/core/daemon-identity.js +252 -0
- package/dist/src/core/daemon-identity.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +109 -8
- 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 +23 -2
- 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 +5 -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 +15 -2
- package/dist/src/core/execute.js +15 -2
- package/dist/src/core/execute.js.map +1 -1
- package/dist/src/core/gate.d.ts +392 -6
- package/dist/src/core/gate.js +852 -24
- 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/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +181 -12
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +82 -3
- package/dist/src/core/harness-wait.js +89 -3
- package/dist/src/core/harness-wait.js.map +1 -1
- package/dist/src/core/instance.d.ts +59 -2
- package/dist/src/core/instance.js +113 -0
- package/dist/src/core/instance.js.map +1 -1
- package/dist/src/core/log-subscribe.d.ts +21 -0
- package/dist/src/core/log-subscribe.js +16 -6
- package/dist/src/core/log-subscribe.js.map +1 -1
- package/dist/src/core/log-write-layer.d.ts +55 -0
- package/dist/src/core/log-write-layer.js +91 -0
- package/dist/src/core/log-write-layer.js.map +1 -0
- package/dist/src/core/log.d.ts +89 -22
- package/dist/src/core/log.js +197 -19
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/payload-census.d.ts +51 -0
- package/dist/src/core/payload-census.js +125 -2
- package/dist/src/core/payload-census.js.map +1 -1
- package/dist/src/core/payload-store.d.ts +31 -0
- package/dist/src/core/payload-store.js +113 -8
- package/dist/src/core/payload-store.js.map +1 -1
- package/dist/src/core/payload.d.ts +18 -3
- package/dist/src/core/payload.js +20 -4
- package/dist/src/core/payload.js.map +1 -1
- package/dist/src/core/policy-diff.d.ts +10 -0
- package/dist/src/core/policy-diff.js +10 -2
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +17 -1
- package/dist/src/core/policy-explain.js +48 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +65 -1
- package/dist/src/core/policy-load.js +21 -3
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +81 -0
- package/dist/src/core/policy-match.js +87 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +75 -0
- package/dist/src/core/policy-proposal.js +110 -4
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/protected-path-guard.d.ts +117 -4
- package/dist/src/core/protected-path-guard.js +362 -48
- 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 +182 -0
- package/dist/src/core/read-scope.js +256 -0
- package/dist/src/core/read-scope.js.map +1 -0
- package/dist/src/core/run-payload.d.ts +160 -0
- package/dist/src/core/run-payload.js +304 -0
- package/dist/src/core/run-payload.js.map +1 -0
- package/dist/src/core/sandbox.d.ts +81 -0
- package/dist/src/core/sandbox.js +190 -1
- package/dist/src/core/sandbox.js.map +1 -1
- package/dist/src/core/sender-identity.d.ts +501 -0
- package/dist/src/core/sender-identity.js +609 -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 +52 -1
- package/dist/src/core/state.js +52 -6
- package/dist/src/core/state.js.map +1 -1
- package/dist/src/core/telegram-config.d.ts +79 -0
- package/dist/src/core/telegram-config.js +108 -0
- package/dist/src/core/telegram-config.js.map +1 -1
- package/dist/src/core/values.d.ts +18 -8
- package/dist/src/core/values.js +36 -1
- package/dist/src/core/values.js.map +1 -1
- package/dist/src/core/verify.d.ts +23 -1
- package/dist/src/core/verify.js +20 -2
- package/dist/src/core/verify.js.map +1 -1
- package/dist/src/daemon/advance.d.ts +10 -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 +80 -5
- package/dist/src/daemon/audit.js +126 -6
- package/dist/src/daemon/audit.js.map +1 -1
- package/dist/src/daemon/daemon.d.ts +174 -2
- package/dist/src/daemon/daemon.js +307 -7
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +13 -2
- package/dist/src/daemon/projection.js +39 -6
- package/dist/src/daemon/projection.js.map +1 -1
- package/dist/src/mcp/server.d.ts +39 -3
- package/dist/src/mcp/server.js +30 -3
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/muse/server.d.ts +16 -0
- package/dist/src/muse/server.js +238 -0
- package/dist/src/muse/server.js.map +1 -0
- package/dist/src/serve/archive.d.ts +158 -0
- package/dist/src/serve/archive.js +354 -0
- package/dist/src/serve/archive.js.map +1 -0
- package/dist/src/serve/arguments.d.ts +93 -0
- package/dist/src/serve/arguments.js +228 -0
- package/dist/src/serve/arguments.js.map +1 -0
- package/dist/src/serve/credentials.d.ts +91 -0
- package/dist/src/serve/credentials.js +143 -0
- package/dist/src/serve/credentials.js.map +1 -0
- package/dist/src/serve/follow.d.ts +86 -0
- package/dist/src/serve/follow.js +101 -0
- package/dist/src/serve/follow.js.map +1 -0
- package/dist/src/serve/hook-thread.d.ts +134 -0
- package/dist/src/serve/hook-thread.js +333 -0
- package/dist/src/serve/hook-thread.js.map +1 -0
- package/dist/src/serve/hook-worker.d.ts +134 -0
- package/dist/src/serve/hook-worker.js +173 -0
- package/dist/src/serve/hook-worker.js.map +1 -0
- package/dist/src/serve/server.d.ts +412 -0
- package/dist/src/serve/server.js +1251 -0
- package/dist/src/serve/server.js.map +1 -0
- package/docs/README-extended.md +766 -0
- package/docs/cli-reference.md +2563 -73
- package/docs/codex-enforced-session.md +75 -2
- package/docs/codex-workspace-broker.md +118 -0
- package/package.json +6 -2
- package/schema/event.schema.json +595 -9
- 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/daemon-id-malformed.json +15 -0
- package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -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/invalid/harness-cap-at-margin.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
- package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
- package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -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/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +15 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -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-hermes.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -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/daemons-allowlist.json +19 -0
- package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
- package/schema/fixtures/policy-md/valid/agent-may-request.md +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 +100 -4
- package/schema/values.schema.json +7 -11
- package/schema/fixtures/values/invalid/version-string.json +0 -1
package/dist/src/cli/hook.js
CHANGED
|
@@ -72,32 +72,33 @@
|
|
|
72
72
|
* auditor holding the log alone. See `docs/claude-code-hook.md`.
|
|
73
73
|
*/
|
|
74
74
|
import { spawnSync } from "node:child_process";
|
|
75
|
-
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
75
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeSync } from "node:fs";
|
|
76
76
|
import { randomBytes } from "node:crypto";
|
|
77
77
|
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";
|
|
82
|
-
import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, register, request, startHarnessExecution, withdraw, } from "../core/gate.js";
|
|
81
|
+
import { classifyCommand, commandSegmentShapes, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, isCredentialPath, protectedPathClass, } from "../core/command-class.js";
|
|
82
|
+
import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, isProposalTask, 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";
|
|
85
|
-
import { abandonedAfterMs, HOOK_DEFAULT_WAIT, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
|
|
84
|
+
import { harnessProvenance, isHarnessKind, } from "../core/harness-version.js";
|
|
85
|
+
import { abandonedAfterMs, HARNESS_CAP_MARGIN_MS, harnessCapFitsMargin, harnessCappedTtlMs, 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
89
|
import { classifyApplyPatch, parseApplyPatch } from "../core/apply-patch.js";
|
|
90
90
|
import { loadPolicy, parseDuration } from "../core/policy-load.js";
|
|
91
|
-
import {
|
|
92
|
-
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";
|
|
93
|
+
import { payloadOf, readVerifiedRecords, requestExpiresAt, requestState, useVerifiedSnapshots, } from "../core/state.js";
|
|
93
94
|
import { boolFlag, parseFlags, stringFlag } from "./args.js";
|
|
94
95
|
import { EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
|
|
95
96
|
import { primaryRoot as resolvePrimaryRoot } from "./git-scope.js";
|
|
96
|
-
import { HOOK_HELP } from "./help.js";
|
|
97
|
+
import { HOOK_GROK_HELP, HOOK_HELP, HOOK_HERMES_HELP, HOOK_MUSE_HELP } from "./help.js";
|
|
97
98
|
import { DEFAULT_LOG_PATH } from "./paths.js";
|
|
98
99
|
import { refusal as renderRefusal, style, table } from "./style.js";
|
|
99
100
|
import { usageErrorText } from "./usage.js";
|
|
100
|
-
import { checkCodexHookInput, codexBinding, CODEX_POST_TOOL_EVENT, readCodexReportedOutcome, } from "./hook-codex.js";
|
|
101
|
+
import { checkCodexHookInput, codexArgvDisagrees, codexBinding, CODEX_POST_TOOL_EVENT, readCodexReportedOutcome, } from "./hook-codex.js";
|
|
101
102
|
/** Identity accepted for the proposing side: a person or an agent. */
|
|
102
103
|
const PRINCIPAL_ACTOR = /^(human|agent):.+/u;
|
|
103
104
|
/**
|
|
@@ -110,6 +111,22 @@ const PRINCIPAL_ACTOR = /^(human|agent):.+/u;
|
|
|
110
111
|
const DEFAULT_TIMEOUT = HOOK_DEFAULT_WAIT;
|
|
111
112
|
/** Poll interval for the decision wait. */
|
|
112
113
|
const DEFAULT_INTERVAL_MS = 1_000;
|
|
114
|
+
/**
|
|
115
|
+
* How many CONSECUTIVE torn-tail reads the decision wait reads past before it
|
|
116
|
+
* treats the log as unreadable (APRV-427 review).
|
|
117
|
+
*
|
|
118
|
+
* The poll reads the log while other writers append to it: the daemon beside
|
|
119
|
+
* a CLI hook, and every other hook call beside one run by `approval serve`. An
|
|
120
|
+
* append is one write on an O_APPEND handle, but a reader on a filesystem that
|
|
121
|
+
* grows a file a page at a time can see the front of a line before its end, and
|
|
122
|
+
* the verified read calls that a torn tail. It is a moment in someone else's
|
|
123
|
+
* write, and the next tick reads the whole line. Treating it as terminal
|
|
124
|
+
* withdrew a live question and denied `hook-io` because a DIFFERENT call was
|
|
125
|
+
* mid-append. So a torn read is read again next tick, like a view that lags;
|
|
126
|
+
* only a tail that stays torn for this many ticks in a row (a crashed write,
|
|
127
|
+
* which nobody is going to finish) takes the old withdraw-and-deny path.
|
|
128
|
+
*/
|
|
129
|
+
export const TORN_TAIL_RETRIES = 5;
|
|
113
130
|
/**
|
|
114
131
|
* How much of the command line goes in the (claimed) summary field.
|
|
115
132
|
*
|
|
@@ -151,6 +168,29 @@ export const HOOK_DENY_CODES = [
|
|
|
151
168
|
* rejection: nobody decided anything, so there is nothing to ask again.
|
|
152
169
|
*/
|
|
153
170
|
"hook-class-human-only",
|
|
171
|
+
/**
|
|
172
|
+
* A `harness.launch.*` class that no rule of this policy names (APRV-354).
|
|
173
|
+
*
|
|
174
|
+
* SPEC.md §7 says the family is never inferred autonomous; this is the
|
|
175
|
+
* stronger reading the family needs, which is that it is never inferred at
|
|
176
|
+
* all. A launch resolves only under a rule an operator wrote, and a policy
|
|
177
|
+
* that names neither `harness.launch.*` nor the specific member refuses.
|
|
178
|
+
*
|
|
179
|
+
* It exists because of the window the softer reading opens. Before the family
|
|
180
|
+
* existed, `codex …` and `muse …` were `hook-unclassified`: refused outright.
|
|
181
|
+
* Letting the new class fall to `defaults.autonomy` would have made every
|
|
182
|
+
* harness launch grantable by one approval in every project whose defaults
|
|
183
|
+
* are manual, the moment they upgraded — a capability arriving by upgrade
|
|
184
|
+
* rather than by decision. What that approval would cover is a whole second
|
|
185
|
+
* agent whose own actions this gate never sees.
|
|
186
|
+
*
|
|
187
|
+
* Distinct from `hook-unclassified`, which says the CLASSIFIER has nothing to
|
|
188
|
+
* say about the command; here the classifier was clear and the POLICY is
|
|
189
|
+
* silent. Distinct from `hook-class-human-only`, which is a policy that has
|
|
190
|
+
* spoken and reserved the class: the repair there is for a person to run the
|
|
191
|
+
* command, and the repair here is to write a line.
|
|
192
|
+
*/
|
|
193
|
+
"hook-harness-launch-unruled",
|
|
154
194
|
/** A construct whose effect cannot be read off the text (`bash -c`, `eval`). */
|
|
155
195
|
"hook-opaque",
|
|
156
196
|
/** The command line could not be tokenized at all. */
|
|
@@ -175,8 +215,36 @@ export const HOOK_DENY_CODES = [
|
|
|
175
215
|
* directory, once. Past the grace the hook withdraws it (reason `timeout`),
|
|
176
216
|
* because a question nothing will adopt is a message on a phone that decides
|
|
177
217
|
* nothing.
|
|
218
|
+
*
|
|
219
|
+
* **Who withdraws, stated because the deny text used to imply a scheduler
|
|
220
|
+
* that does not exist (APRV-410).** This process is over when it denies, so
|
|
221
|
+
* the withdrawal is made by the NEXT gated tool call this actor makes, from
|
|
222
|
+
* the sweep at the top of every gated invocation. It cannot be anyone else: a
|
|
223
|
+
* withdrawal is the requester's own (APRV-106 rule 1, `not-requester`), the
|
|
224
|
+
* gate refuses a `system:` withdrawal outright, and the event schema binds
|
|
225
|
+
* the one `system:` withdrawal that exists to `policy-drift` and to nothing
|
|
226
|
+
* else (APRV-235). So a session that denies here and then makes no further
|
|
227
|
+
* gated call leaves the question pending until the TTL, and a decision on it
|
|
228
|
+
* lands as an ordinary grant. What stops that grant authorizing anything is
|
|
229
|
+
* the other half of APRV-410: a retry past the grace does not carry it
|
|
230
|
+
* (`findHarnessCarry`), so it asks again.
|
|
178
231
|
*/
|
|
179
232
|
"hook-timeout",
|
|
233
|
+
/**
|
|
234
|
+
* The harness ceiling this hook was told it runs under leaves no window a
|
|
235
|
+
* human could answer in (APRV-423).
|
|
236
|
+
*
|
|
237
|
+
* `--harness-cap` (or the harness's own documented maximum) at or below
|
|
238
|
+
* `HARNESS_CAP_MARGIN_MS` means the question would lapse at the instant it
|
|
239
|
+
* was asked: the request must end a margin before the harness kills the
|
|
240
|
+
* process that opened it, and there is no room left for the asking. Nothing
|
|
241
|
+
* is registered, nothing is requested and nobody's phone lights up. The
|
|
242
|
+
* repair is a configuration change — raise the harness entry's timeout, or
|
|
243
|
+
* state the real one — and it is a refusal rather than a silent widening
|
|
244
|
+
* because a hook that quietly ignored its own ceiling would be back to
|
|
245
|
+
* recording grants on calls nobody holds.
|
|
246
|
+
*/
|
|
247
|
+
"hook-harness-cap-too-short",
|
|
180
248
|
/** The gate refused intake; the gate's own code follows a colon. */
|
|
181
249
|
"hook-gate-refused",
|
|
182
250
|
/**
|
|
@@ -219,6 +287,72 @@ export const HOOK_DENY_CODES = [
|
|
|
219
287
|
* merges do not reconcile hash chains (APRV-101).
|
|
220
288
|
*/
|
|
221
289
|
"hook-log-unreachable",
|
|
290
|
+
/**
|
|
291
|
+
* The harness does not tell this hook where the call will run, so no verdict
|
|
292
|
+
* over the visible bytes can bind the action (APRV-311, native evidence in
|
|
293
|
+
* APRV-310 v6/v7).
|
|
294
|
+
*
|
|
295
|
+
* Native Codex 0.152.1 honours a per-call Bash working directory that appears
|
|
296
|
+
* in no field of the event: `tool_input` carries `command` alone, and the
|
|
297
|
+
* event cwd and the hook process cwd both stay at the session root. A
|
|
298
|
+
* decision over `{command, session root}` would therefore authorize different
|
|
299
|
+
* bytes from the `{command, effective directory}` the harness executes, and a
|
|
300
|
+
* relative path in an approved command can name a protected organ in a
|
|
301
|
+
* directory the classifier never saw.
|
|
302
|
+
*
|
|
303
|
+
* Distinct from `hook-io`, which this used to borrow, and the distinction is
|
|
304
|
+
* the repair. `hook-io` says THIS event was malformed and a well-formed one
|
|
305
|
+
* would be answered; this says every event of this shape is refused on this
|
|
306
|
+
* harness version, and the fix is a harness contract that exposes the
|
|
307
|
+
* effective execution directory, not a retry, a policy edit, or an open
|
|
308
|
+
* window. Nothing appends on this path and no gate lifecycle opens.
|
|
309
|
+
*/
|
|
310
|
+
"hook-unsupported-execution-context",
|
|
311
|
+
/**
|
|
312
|
+
* The session names a Contributor-tier model, so every tool call is refused
|
|
313
|
+
* (APRV-350).
|
|
314
|
+
*
|
|
315
|
+
* Meta sells a Contributor variant of the Muse Spark family that "trades a
|
|
316
|
+
* lower price for permission to train on your prompts and completions". A
|
|
317
|
+
* session on one discloses every byte it reads, so the refusal is above the
|
|
318
|
+
* policy: no class resolution and no grant widens it, and an absent or
|
|
319
|
+
* unrecognised `model` is refused for the same reason an unparseable event is.
|
|
320
|
+
*
|
|
321
|
+
* Distinct from `hook-class-human-only`, which says a HUMAN must do this
|
|
322
|
+
* action; this says nothing may do it in this session, and the repair is to
|
|
323
|
+
* change the model in Muse's picker rather than to ask anybody. Distinct from
|
|
324
|
+
* `hook-io` because the event was perfectly well formed.
|
|
325
|
+
*
|
|
326
|
+
* What it cannot do is stated wherever it is documented: it stops tool calls,
|
|
327
|
+
* and it cannot recall a prompt the model has already been sent.
|
|
328
|
+
*/
|
|
329
|
+
"hook-muse-contributor-model",
|
|
330
|
+
/**
|
|
331
|
+
* A Hermes Agent `execute_code` call, refused before anything else looks at
|
|
332
|
+
* it (APRV-398).
|
|
333
|
+
*
|
|
334
|
+
* The tool takes a `code` string and runs it in-process. There is no path, no
|
|
335
|
+
* argv and no working directory: nothing the classifier reads exists in the
|
|
336
|
+
* call, so no class can be resolved and no payload can bind what will happen.
|
|
337
|
+
* A verdict over `{code}` would authorize a program this runtime never
|
|
338
|
+
* parsed, which is the same hole `hook-opaque` closes for `bash -c` and the
|
|
339
|
+
* same one `hook-unsupported-execution-context` closes for a command whose
|
|
340
|
+
* directory is invisible. SPEC.md §11.1 says ambiguity resolves to the
|
|
341
|
+
* stricter path, and the strictest honest answer to "may I run this arbitrary
|
|
342
|
+
* program" is no.
|
|
343
|
+
*
|
|
344
|
+
* Distinct from `hook-opaque`, and the distinction is the repair. That one
|
|
345
|
+
* says a command line carried a construct the classifier could not read, and
|
|
346
|
+
* the fix is to write the command differently. This one says the TOOL has no
|
|
347
|
+
* readable surface at all on this harness, so there is no spelling of an
|
|
348
|
+
* `execute_code` call that would be answered differently: the repair is to do
|
|
349
|
+
* the work through the shell tool, where the words are visible and the
|
|
350
|
+
* classifier can price them, or through `approval run` with a granted token.
|
|
351
|
+
*
|
|
352
|
+
* Distinct from `hook-unclassified`, which says the classifier had nothing to
|
|
353
|
+
* say about bytes it could see. Here there are no bytes to see.
|
|
354
|
+
*/
|
|
355
|
+
"hook-hermes-execute-code-unbound",
|
|
222
356
|
/** Malformed hook input, or a log/filesystem fact that stopped the check. */
|
|
223
357
|
"hook-io",
|
|
224
358
|
];
|
|
@@ -267,7 +401,7 @@ const primaryRoot = resolvePrimaryRoot;
|
|
|
267
401
|
* (`--policy` for the policy, `--log` for the log); otherwise both follow
|
|
268
402
|
* `--dir`, and with no flags at all both follow the primary checkout.
|
|
269
403
|
*/
|
|
270
|
-
function hookScope(flags, cwd) {
|
|
404
|
+
export function hookScope(flags, cwd) {
|
|
271
405
|
const policyFlag = stringFlag(flags, "--policy");
|
|
272
406
|
const logFlag = stringFlag(flags, "--log");
|
|
273
407
|
const dirFlag = stringFlag(flags, "--dir");
|
|
@@ -278,12 +412,46 @@ function hookScope(flags, cwd) {
|
|
|
278
412
|
const logPath = logFlag === null ? join(root, DEFAULT_LOG_PATH) : absolute(logFlag, cwd);
|
|
279
413
|
return { logPath, root, options };
|
|
280
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* The GATE ROOT: the directory holding the policy file that was actually read
|
|
417
|
+
* (APRV-347).
|
|
418
|
+
*
|
|
419
|
+
* This is the anchor of the read scope, and it is deliberately the loaded
|
|
420
|
+
* policy's own directory rather than the hook's cwd or the harness's `cwd`
|
|
421
|
+
* field. A muse session started in `~/dev/muse` with its own `APPROVAL.md` gets
|
|
422
|
+
* `~/dev/muse`, and every sibling under `~/dev` is outside the scope with no
|
|
423
|
+
* extra grammar anywhere. A policy that did not load falls back to the scope
|
|
424
|
+
* root, which is the same directory the loader was pointed at.
|
|
425
|
+
*/
|
|
426
|
+
function policyRootOf(load, fallback) {
|
|
427
|
+
return load.ok ? dirname(load.source.path) : fallback;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The ceiling a harness puts on its hook process, in milliseconds, or `null`
|
|
431
|
+
* (APRV-423): the operator's stated `--harness-cap` clamped to the adapter's
|
|
432
|
+
* contractual maximum, or, with nothing stated, the adapter's documented
|
|
433
|
+
* default, falling back to that maximum.
|
|
434
|
+
*
|
|
435
|
+
* Exported so `approval serve` computes the same number the hook will before
|
|
436
|
+
* it hands the call to a thread (APRV-427 review): the budget a queued call
|
|
437
|
+
* has left is this less the time it has waited.
|
|
438
|
+
*/
|
|
439
|
+
export function effectiveHarnessCapMs(adapter, statedCapMs) {
|
|
440
|
+
const ceilingMs = adapter.capCeilingMs ?? null;
|
|
441
|
+
if (statedCapMs === null)
|
|
442
|
+
return adapter.capDefaultMs ?? ceilingMs;
|
|
443
|
+
return ceilingMs === null ? statedCapMs : Math.min(statedCapMs, ceilingMs);
|
|
444
|
+
}
|
|
281
445
|
const CLAUDE_ADAPTER = {
|
|
282
446
|
kind: "claude-code",
|
|
283
447
|
originApp: "claude-code-hook",
|
|
284
448
|
defaultActor: "agent:claude-code",
|
|
285
449
|
shellTool: "Bash",
|
|
286
450
|
fileTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
|
|
451
|
+
// Claude Code's three path-carrying readers. `Read` names the file in
|
|
452
|
+
// `file_path` (or `notebook_path`), `Glob` and `Grep` name the directory they
|
|
453
|
+
// search in `path` and default to the workspace when it is absent.
|
|
454
|
+
readTools: ["Read", "Glob", "Grep"],
|
|
287
455
|
};
|
|
288
456
|
const CURSOR_ADAPTER = {
|
|
289
457
|
kind: "cursor",
|
|
@@ -291,6 +459,14 @@ const CURSOR_ADAPTER = {
|
|
|
291
459
|
defaultActor: "agent:cursor",
|
|
292
460
|
shellTool: "Shell",
|
|
293
461
|
fileTools: ["Write", "Delete"],
|
|
462
|
+
// Cursor's own hook documentation names `Shell`, `Write` and `Delete` as the
|
|
463
|
+
// matchable tools, and no read tool among them; `Read` is the name its agent
|
|
464
|
+
// surface uses. It is listed here because an entry that Cursor never sends is
|
|
465
|
+
// INERT — an unmatched tool takes the path it took before this task — while
|
|
466
|
+
// an entry missing for a tool Cursor does send would be an unscoped read. If
|
|
467
|
+
// Cursor names it otherwise, `cat` through `Shell` is still scoped by the
|
|
468
|
+
// classifier, which is the floor. See docs/cursor-hook.md.
|
|
469
|
+
readTools: ["Read"],
|
|
294
470
|
};
|
|
295
471
|
const CODEX_ADAPTER = {
|
|
296
472
|
kind: "codex",
|
|
@@ -298,14 +474,391 @@ const CODEX_ADAPTER = {
|
|
|
298
474
|
defaultActor: "agent:codex",
|
|
299
475
|
shellTool: "Bash",
|
|
300
476
|
fileTools: ["apply_patch"],
|
|
477
|
+
// Empty, and not an oversight. The Codex native hook contract exposes exactly
|
|
478
|
+
// two tools, `Bash` and `apply_patch` (docs/codex-hook.md); it has no
|
|
479
|
+
// separate read tool to gate. Codex `Bash` is refused outright today because
|
|
480
|
+
// the contract does not expose the per-call working directory (APRV-310), so
|
|
481
|
+
// a Codex read arrives as a shell command and is scoped by the classifier or
|
|
482
|
+
// it does not arrive at all.
|
|
483
|
+
readTools: [],
|
|
301
484
|
bindToolName: true,
|
|
302
485
|
};
|
|
486
|
+
/**
|
|
487
|
+
* Grok Build (APRV-243).
|
|
488
|
+
*
|
|
489
|
+
* Its PreToolUse hook is modelled on Claude Code's, with three differences
|
|
490
|
+
* that matter here: the envelope keys are camelCase, the verdict is
|
|
491
|
+
* `{decision, reason}` rather than the nested `hookSpecificOutput`, and a deny
|
|
492
|
+
* is EXIT 2 rather than exit 0 with a JSON body. The tool names are Claude
|
|
493
|
+
* Code's, which is what its documentation says and what the compatibility read
|
|
494
|
+
* of `.claude/settings.json` implies; `docs/grok-hook.md` records that this
|
|
495
|
+
* half is unverified until the live probe runs.
|
|
496
|
+
*
|
|
497
|
+
* The reason this adapter exists at all is a hazard rather than a feature.
|
|
498
|
+
* Grok Build states that it reads `.claude/settings.json` for compatibility.
|
|
499
|
+
* If that read fires this repository's committed claude-code entry under a
|
|
500
|
+
* Grok session, the claude-code adapter answers a deny by printing the nested
|
|
501
|
+
* Claude envelope and exiting 0, and Grok reads exit 0 as ALLOW. Every command
|
|
502
|
+
* would look gated and none would be.
|
|
503
|
+
*/
|
|
504
|
+
const GROK_ADAPTER = {
|
|
505
|
+
kind: "grok",
|
|
506
|
+
originApp: "grok-hook",
|
|
507
|
+
defaultActor: "agent:grok",
|
|
508
|
+
shellTool: "Bash",
|
|
509
|
+
fileTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
|
|
510
|
+
// Claude Code's three path-carrying readers, for the same reason the file
|
|
511
|
+
// tools are Claude Code's: Grok Build's tool vocabulary follows Claude
|
|
512
|
+
// Code's, and it READS `.claude/settings.json` for compatibility, so the
|
|
513
|
+
// names a Grok session sends are the names that file matches on. The
|
|
514
|
+
// asymmetry with Cursor is deliberate — Cursor documents its own smaller set,
|
|
515
|
+
// Grok documents Claude's.
|
|
516
|
+
//
|
|
517
|
+
// Both directions of the guess are safe in the way APRV-347 makes them safe.
|
|
518
|
+
// A read tool listed here that Grok never sends is INERT: an unmatched tool
|
|
519
|
+
// takes the path it took before. A read tool Grok sends that is NOT listed
|
|
520
|
+
// would be an unscoped read, which is the direction that matters, so the
|
|
521
|
+
// wider Claude set is the fail-closed guess. And a Grok read that arrives as
|
|
522
|
+
// a shell command through `Bash` is scoped by the classifier regardless,
|
|
523
|
+
// which is the floor under all of this.
|
|
524
|
+
//
|
|
525
|
+
// UNVERIFIED, like the file-tool list beside it, and `docs/grok-hook.md`
|
|
526
|
+
// says so: the live probe of APRV-243 AC1 records the tool names an actual
|
|
527
|
+
// session sends, and both lists are corrected to match before the register
|
|
528
|
+
// entry moves from parked.
|
|
529
|
+
readTools: ["Read", "Glob", "Grep"],
|
|
530
|
+
camelCaseEnvelope: true,
|
|
531
|
+
};
|
|
532
|
+
/**
|
|
533
|
+
* Meta Muse Code (APRV-350).
|
|
534
|
+
*
|
|
535
|
+
* Every field here is OBSERVED, from a live run on `muse-bin-1.3.0-R3233.1`
|
|
536
|
+
* whose 139 captured envelopes are the evidence. Nothing in this entry is a
|
|
537
|
+
* guess, which is the difference between it and the Grok entry above.
|
|
538
|
+
*
|
|
539
|
+
* The envelope is snake_case, so no `camelCaseEnvelope`. It carries
|
|
540
|
+
* `hook_event_name`, `tool_name`, `tool_input`, `tool_use_id`, `session_id`,
|
|
541
|
+
* `turn_id`, `cwd`, `transcript_path`, `model`, `permission_mode` and
|
|
542
|
+
* `model_provider`; `PostToolUse` adds `tool_response`. So Muse sends BOTH
|
|
543
|
+
* facts Codex lacks: a per-call working directory and a real outcome.
|
|
544
|
+
*
|
|
545
|
+
* ## What it cannot do, and why this adapter still ships
|
|
546
|
+
*
|
|
547
|
+
* Muse FAILS OPEN. A hook that crashes, hangs past its timeout, or prints
|
|
548
|
+
* anything Muse cannot parse is a hook Muse ignores, and the tool call
|
|
549
|
+
* proceeds. Worse, an output mixing verdict dialects is itself unparseable, so
|
|
550
|
+
* the belt-and-braces answer that satisfies every harness at once satisfies
|
|
551
|
+
* this one not at all: the probe's `deny` trial printed every dialect AND
|
|
552
|
+
* exited 2, and the write completed in under 80 ms.
|
|
553
|
+
*
|
|
554
|
+
* Therefore this adapter emits EXACTLY ONE dialect and nothing else: the nested
|
|
555
|
+
* `hookSpecificOutput` form at exit 0, which the default arm of
|
|
556
|
+
* {@link decision} already produces. Three forms were measured to block
|
|
557
|
+
* (nested at exit 0; `{decision:"block"}` at exit 0; empty stdout at exit 2)
|
|
558
|
+
* and the nested one is chosen because it is the only one that carries a REASON
|
|
559
|
+
* the model is shown, so a refused agent learns why instead of retrying blind.
|
|
560
|
+
* The probe watched an agent respond to an unexplained deny by shelling out to
|
|
561
|
+
* inspect the hook's own state file, which is the behaviour a silent refusal
|
|
562
|
+
* buys. `docs/muse-hook.md` records the choice and the two rejected forms.
|
|
563
|
+
*/
|
|
564
|
+
const MUSE_ADAPTER = {
|
|
565
|
+
kind: "muse",
|
|
566
|
+
originApp: "muse-hook",
|
|
567
|
+
defaultActor: "agent:muse",
|
|
568
|
+
shellTool: "bash",
|
|
569
|
+
fileTools: ["write_file"],
|
|
570
|
+
// `read_file` names one path (relative to the session cwd, or absolute);
|
|
571
|
+
// `search` names an ARRAY under `paths`, and the live capture caught it
|
|
572
|
+
// reaching outside the workspace entirely — Muse applies no workspace
|
|
573
|
+
// confinement in `permission_mode: "default"`, so this is the read jail's
|
|
574
|
+
// whole reason for existing here.
|
|
575
|
+
readTools: ["read_file", "search"],
|
|
576
|
+
passThroughTools: ["submit_reminder_decision"],
|
|
577
|
+
shellCwdKey: "workdir",
|
|
578
|
+
contributorModelGuard: true,
|
|
579
|
+
};
|
|
580
|
+
/**
|
|
581
|
+
* Hermes Agent's event names, which are its own and not Claude Code's
|
|
582
|
+
* (APRV-398).
|
|
583
|
+
*
|
|
584
|
+
* snake_case, and the VERBS differ too: `pre_tool_call` where every other
|
|
585
|
+
* harness in this table says `PreToolUse`. That is why they are constants
|
|
586
|
+
* rather than two more entries in {@link POST_TOOL_EVENTS} — a harness whose
|
|
587
|
+
* post-event name appeared in that shared list would also accept
|
|
588
|
+
* `PostToolUse` from an event nobody sent, and the dispatch would answer a
|
|
589
|
+
* name this harness never uses.
|
|
590
|
+
*/
|
|
591
|
+
export const HERMES_PRE_TOOL_EVENT = "pre_tool_call";
|
|
592
|
+
export const HERMES_POST_TOOL_EVENT = "post_tool_call";
|
|
593
|
+
/**
|
|
594
|
+
* Hermes Agent's read tools, read off its own tool registrations (APRV-398).
|
|
595
|
+
*
|
|
596
|
+
* `read_file` names one `path` (with `offset` and `limit`). `search_files` is
|
|
597
|
+
* BOTH of the readers another harness would ship as two: its `target` enum
|
|
598
|
+
* selects `content` (a grep) or `files` (a name search), and either way the
|
|
599
|
+
* directory it works in is one `path`. So there is no `glob`, no `grep` and no
|
|
600
|
+
* `list_files` here — not because they were left out, but because this harness
|
|
601
|
+
* does not have them.
|
|
602
|
+
*
|
|
603
|
+
* NO TOOL ON THIS HARNESS TAKES A LIST OF PATHS, which is the one thing worth
|
|
604
|
+
* saying out loud: Muse's `search` names an ARRAY under `paths` and needed
|
|
605
|
+
* `firstOutOfScope` to answer a list, and `readToolGate`'s single-path arm
|
|
606
|
+
* answers everything here. A `paths` array arriving from a future release would
|
|
607
|
+
* fall to that same list handling with no change, because the gate reads both.
|
|
608
|
+
*
|
|
609
|
+
* OBSERVED (APRV-415, the live probe of APRV-398): a running session sent
|
|
610
|
+
* `read_file` with `path` and `limit`, and the tools it sent in total were
|
|
611
|
+
* `terminal`, `write_file`, `patch`, `read_file` and `execute_code`.
|
|
612
|
+
* `search_files` was not exercised by the five prompts, so it stays on this list
|
|
613
|
+
* as the registration says, in the direction APRV-347 made safe: a listed tool
|
|
614
|
+
* Hermes never sends is INERT, an unlisted tool it does send would be an
|
|
615
|
+
* unscoped read. A read arriving through `terminal` as `cat` is scoped by the
|
|
616
|
+
* classifier regardless, which is the floor under all of it.
|
|
617
|
+
*/
|
|
618
|
+
const HERMES_READ_TOOLS = ["read_file", "search_files"];
|
|
619
|
+
/**
|
|
620
|
+
* The classes Hermes's own side-effecting tools are judged under (APRV-445,
|
|
621
|
+
* from the Agent Village DATA-234 survey of Hermes v2026.9.24).
|
|
622
|
+
*
|
|
623
|
+
* Before this table every one of these reached the not-a-gated-tool allow,
|
|
624
|
+
* because the adapter knew `terminal`, `write_file`, `patch` and the two read
|
|
625
|
+
* tools and nothing else, so a policy line for `cron.manage` or `message.send`
|
|
626
|
+
* was a line no Hermes call could ever reach. They are code, not policy: the
|
|
627
|
+
* classifier decides what a call IS and the policy decides what happens to it,
|
|
628
|
+
* so an operator prices these classes with ordinary `classes:` lines.
|
|
629
|
+
*
|
|
630
|
+
* - `cronjob_manage` (legacy alias `cronjob`): every action that changes the
|
|
631
|
+
* schedule or runs a job is `cron.manage`; `list` is a read. A scheduled job
|
|
632
|
+
* runs later with no `pre_tool_call` at all, so the schedule is the one place
|
|
633
|
+
* a human can see it.
|
|
634
|
+
* - `process_manage` (legacy alias `process`): `write`, `submit`, `kill`,
|
|
635
|
+
* `close` and `handoff` act on a running process and are `process.write`;
|
|
636
|
+
* `list`, `poll`, `log` and `wait` are reads.
|
|
637
|
+
* - `browser_exec`, `browser_cdp` and every other `browser_*`: `browser.exec`.
|
|
638
|
+
* - `skill_manage`: `skill.manage`. `delegate_task`: `agent.delegate`.
|
|
639
|
+
* `send_message`: `message.send` (not agent-callable at v2026.9.24; the rule
|
|
640
|
+
* stands so the day it is, it is already gated).
|
|
641
|
+
*
|
|
642
|
+
* An action this table does not recognise on a tool it does know is judged
|
|
643
|
+
* under the tool's side-effecting class rather than allowed: a new verb on a
|
|
644
|
+
* tool that schedules or drives processes is a new way to do that, and the
|
|
645
|
+
* strict reading is the safe one.
|
|
646
|
+
*/
|
|
647
|
+
const HERMES_CRON_READ_ACTIONS = new Set(["list"]);
|
|
648
|
+
const HERMES_PROCESS_READ_ACTIONS = new Set(["list", "poll", "log", "wait"]);
|
|
649
|
+
function hermesToolRule(toolName, toolInput) {
|
|
650
|
+
const action = readString(toolInput, "action");
|
|
651
|
+
const headline = (cls) => `${toolName}${action === null ? "" : ` ${action}`} (${cls})`;
|
|
652
|
+
if (toolName === "cronjob_manage" || toolName === "cronjob") {
|
|
653
|
+
if (action !== null && HERMES_CRON_READ_ACTIONS.has(action)) {
|
|
654
|
+
return { kind: "read", reason: `${toolName} ${action} reads the schedule and changes nothing` };
|
|
655
|
+
}
|
|
656
|
+
return { kind: "gated", cls: "cron.manage", headline: headline("cron.manage") };
|
|
657
|
+
}
|
|
658
|
+
if (toolName === "process_manage" || toolName === "process") {
|
|
659
|
+
if (action !== null && HERMES_PROCESS_READ_ACTIONS.has(action)) {
|
|
660
|
+
return { kind: "read", reason: `${toolName} ${action} reads a process and changes nothing` };
|
|
661
|
+
}
|
|
662
|
+
return { kind: "gated", cls: "process.write", headline: headline("process.write") };
|
|
663
|
+
}
|
|
664
|
+
if (toolName.startsWith("browser_")) {
|
|
665
|
+
return { kind: "gated", cls: "browser.exec", headline: headline("browser.exec") };
|
|
666
|
+
}
|
|
667
|
+
if (toolName === "skill_manage") {
|
|
668
|
+
return { kind: "gated", cls: "skill.manage", headline: headline("skill.manage") };
|
|
669
|
+
}
|
|
670
|
+
if (toolName === "delegate_task") {
|
|
671
|
+
return { kind: "gated", cls: "agent.delegate", headline: headline("agent.delegate") };
|
|
672
|
+
}
|
|
673
|
+
if (toolName === "send_message") {
|
|
674
|
+
return { kind: "gated", cls: "message.send", headline: headline("message.send") };
|
|
675
|
+
}
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Hermes Agent by Nous Research (APRV-398).
|
|
680
|
+
*
|
|
681
|
+
* The harness Agent Village v2 runs every resident agent on, one per tenant in
|
|
682
|
+
* a Railway sandbox, which is why this adapter exists at all. Its hook is a
|
|
683
|
+
* shell hook like every other entry in this table, configured under `hooks:` in
|
|
684
|
+
* `$HERMES_HOME/config.yaml` rather than in the repository: Hermes documents no
|
|
685
|
+
* project-local configuration directory, so the organ lives in the user home
|
|
686
|
+
* and `core/command-class.ts` matches it there.
|
|
687
|
+
*
|
|
688
|
+
* ## The dialect: exactly one form, and the form is Hermes's own
|
|
689
|
+
*
|
|
690
|
+
* Hermes's shell-hook parser accepts two block dialects, checked in this order:
|
|
691
|
+
* its own `{action:"block", message}` and a Claude-compatible
|
|
692
|
+
* `{decision:"block", reason}`. This adapter emits the NATIVE one and never
|
|
693
|
+
* both.
|
|
694
|
+
*
|
|
695
|
+
* The live probe (APRV-415) measured every dialect one at a time and then all of
|
|
696
|
+
* them at once: `{action:"block"}` at exit 2, `{action:"block"}` at exit 0,
|
|
697
|
+
* `{decision:"block"}`, a bare exit 2, and the mixed payload ALL BLOCKED. So
|
|
698
|
+
* HERMES TOLERATES A SUPERSET, which is exactly where it differs from Muse — a
|
|
699
|
+
* payload carrying several dialects there was unparseable, an unparseable hook
|
|
700
|
+
* was a failed hook, and a failed hook failed OPEN, so being more explicit made
|
|
701
|
+
* the refusal weaker (`docs/muse-hook.md`). One dialect is still what this
|
|
702
|
+
* adapter emits, and the reason is now the smaller one rather than the load-
|
|
703
|
+
* bearing one: the native form is the shape the parser tries first and the one
|
|
704
|
+
* least likely to be dropped by a release that tidies up a compatibility layer,
|
|
705
|
+
* and a verdict whose meaning does not depend on how much of it the harness
|
|
706
|
+
* understood is a verdict a reader can check. The tolerance is a fact about this
|
|
707
|
+
* release, not a licence to hedge in the next one.
|
|
708
|
+
*
|
|
709
|
+
* THE ALLOW IS `{}`, and that is not a placeholder. Hermes has no
|
|
710
|
+
* `{"action":"allow"}`: its parser returns "no directive" for an empty stdout,
|
|
711
|
+
* for `{}`, and for any JSON object naming no directive key, and no directive
|
|
712
|
+
* means the call proceeds. The probe confirmed it: the `allow-empty-object`
|
|
713
|
+
* trial CREATED its file under `fail_closed: true`. `{}` is chosen over an empty
|
|
714
|
+
* stdout because the two are distinguishable to a human reading a log and only
|
|
715
|
+
* one of them says a hook ran and decided; it is chosen over an invented
|
|
716
|
+
* `{"action":"allow"}` because that spelling is an allow only by falling through
|
|
717
|
+
* the parser's unrecognised-directive path — which the probe also measured, and
|
|
718
|
+
* which did fall through on the old build — and a verdict that works by not being
|
|
719
|
+
* understood is a verdict one release could turn into a block. The reason text
|
|
720
|
+
* rides on stderr, where Hermes reads it only as a block message at the blocking
|
|
721
|
+
* exit code, which an allow never uses.
|
|
722
|
+
*
|
|
723
|
+
* ## The first real gate since Claude Code, measured
|
|
724
|
+
*
|
|
725
|
+
* A per-entry `fail_closed: true` turns three hook failures into a BLOCK: a
|
|
726
|
+
* spawn error, a timeout, and stdout that is non-empty and not a JSON object.
|
|
727
|
+
* Grok Build and Muse Code both fail open on all three with no setting to change
|
|
728
|
+
* it, so both adapters are enforcement only while healthy.
|
|
729
|
+
*
|
|
730
|
+
* OBSERVED, 3 OF 3, on Hermes `main` at `118984d7` (APRV-415): the armed crash
|
|
731
|
+
* was refused, the armed garbage was refused, and the armed hang was refused at
|
|
732
|
+
* exactly 300s, the per-entry cap. So this adapter is a gate on a current Hermes
|
|
733
|
+
* rather than a backstop, which no adapter since Claude Code could say.
|
|
734
|
+
*
|
|
735
|
+
* THREE LIMITS RIDE WITH IT, and `docs/hermes-hook.md` states all three rather
|
|
736
|
+
* than selling the headline. The default is `false`, so an entry without the key
|
|
737
|
+
* fails open. The key does not cover a hook that exits non-zero having printed
|
|
738
|
+
* NOTHING, which is left to {@link HERMES_DENY_EXIT}'s unconditional block. And
|
|
739
|
+
* there is a VERSION FLOOR: `v0.21.3` (build `2026.9.14`) does not know the key
|
|
740
|
+
* at all and ignores it silently, with `hermes hooks list` rendering no
|
|
741
|
+
* `fail_closed` flag on either build, so the listing cannot be used to check it.
|
|
742
|
+
* {@link HERMES_FAIL_CLOSED_FLOOR} in `core/harness-version.ts` carries the floor
|
|
743
|
+
* and `approval doctor`'s harness-version row reports a build below it.
|
|
744
|
+
*
|
|
745
|
+
* ## `execute_code` has no readable surface, and may not even be hooked
|
|
746
|
+
*
|
|
747
|
+
* The tool takes a `code` string and no path, no argv and no directory, so
|
|
748
|
+
* nothing a verdict could bind exists in the call. It is refused before the
|
|
749
|
+
* event dispatch with {@link HERMES_EXECUTE_CODE_REFUSAL}.
|
|
750
|
+
*
|
|
751
|
+
* There is a second reason, and it is the stronger one. `execute_code` runs in
|
|
752
|
+
* a persistent kernel whose scripts can call Hermes's other tools IN-PROCESS.
|
|
753
|
+
* Whether those inner calls re-fire `pre_tool_call` is STILL not established and
|
|
754
|
+
* cannot be established from here: the probe refused every `execute_code` call
|
|
755
|
+
* before it ran, twice, which is the refusal working and is also the reason no
|
|
756
|
+
* inner call was ever observed. If they do not re-fire, one `execute_code` call
|
|
757
|
+
* is an unbounded bypass of this entire adapter. Refusing the tool is the only
|
|
758
|
+
* answer available to a hook that cannot see inside it, and it is the
|
|
759
|
+
* fail-closed one.
|
|
760
|
+
*/
|
|
761
|
+
const HERMES_ADAPTER = {
|
|
762
|
+
kind: "hermes",
|
|
763
|
+
originApp: "hermes-hook",
|
|
764
|
+
defaultActor: "agent:hermes",
|
|
765
|
+
// Two numbers, because Hermes bounds the hook twice and the shorter wins.
|
|
766
|
+
//
|
|
767
|
+
// The CEILING is 300 s, OBSERVED rather than read off a page: APRV-415's probe
|
|
768
|
+
// armed a hang and Hermes refused it at exactly 300 s, the per-entry cap. No
|
|
769
|
+
// entry can outlive it however it is written, so a stated `--harness-cap` is
|
|
770
|
+
// clamped to it.
|
|
771
|
+
//
|
|
772
|
+
// The DEFAULT is 30 s: `plugins.hook_callback_timeout`, which bounds the whole
|
|
773
|
+
// hook dispatch, fails closed on `pre_tool_call` by itself, and is what a
|
|
774
|
+
// Hermes install runs with until an operator raises it. The first pass of
|
|
775
|
+
// APRV-423 assumed the 300 s ceiling whenever the flag was absent, which on a
|
|
776
|
+
// default install asserted a T+240 s deadline for a hook Hermes kills at 30 s:
|
|
777
|
+
// APRV-410 again, with a fictional deadline. So with no flag the runtime
|
|
778
|
+
// assumes the stricter number, which does not clear the margin, and refuses
|
|
779
|
+
// `hook-harness-cap-too-short` with the repair below. An operator who has
|
|
780
|
+
// raised `hook_callback_timeout` states the smaller of it and the entry's
|
|
781
|
+
// `timeout` with `--harness-cap`, and that statement replaces the default.
|
|
782
|
+
capCeilingMs: 300_000,
|
|
783
|
+
capDefaultMs: 30_000,
|
|
784
|
+
capRepair: "On Hermes: raise `plugins.hook_callback_timeout` above the per-entry `timeout` in the harness's config, then pass `--harness-cap <the smaller of the two>` on the hook command, or start `approval serve` with `--hook-harness-cap <the smaller of the two>` for a hosted tenant (for the documented `hook_callback_timeout: 600` with `timeout: 300`, that is 300s). Without the flag this runtime assumes Hermes's 30s default callback timeout, which leaves no room for a human.",
|
|
785
|
+
// `terminal`, carrying `command` and a PER-CALL `workdir`. Hermes's contract
|
|
786
|
+
// has the field Codex's withholds (APRV-310), and the live probe found that
|
|
787
|
+
// having it is not the same as being sent it: the model passed no `workdir` at
|
|
788
|
+
// all, and the directory the command would have run in is reported nowhere
|
|
789
|
+
// else. So the field makes enforcement POSSIBLE and
|
|
790
|
+
// {@link hermesUnboundDirectory} is what makes it actual, by refusing the call
|
|
791
|
+
// that leaves the field out instead of guessing a directory for it.
|
|
792
|
+
shellTool: "terminal",
|
|
793
|
+
// `write_file` names `path` and `content`; `patch` names `path`,
|
|
794
|
+
// `old_string` and `new_string`. Both shapes are already what
|
|
795
|
+
// `fileToolGate` reads, so neither needed a new key. Both were observed on the
|
|
796
|
+
// live run, `patch` with `new_string`, `old_string` and `path` exactly.
|
|
797
|
+
fileTools: ["write_file", "patch"],
|
|
798
|
+
readTools: HERMES_READ_TOOLS,
|
|
799
|
+
shellCwdKey: "workdir",
|
|
800
|
+
toolRules: hermesToolRule,
|
|
801
|
+
};
|
|
802
|
+
/** The adapter's rule-table verdict for this call, or `null` (APRV-445). */
|
|
803
|
+
function toolRuleOf(adapter, input) {
|
|
804
|
+
if (adapter.toolRules === undefined)
|
|
805
|
+
return null;
|
|
806
|
+
if (input.toolName === adapter.shellTool)
|
|
807
|
+
return null;
|
|
808
|
+
if (adapter.fileTools.includes(input.toolName) || adapter.readTools.includes(input.toolName)) {
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
return adapter.toolRules(input.toolName, input.toolInput);
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Every harness this runtime speaks a hook protocol for, by kind (APRV-358).
|
|
815
|
+
*
|
|
816
|
+
* The table is `Record<HarnessKind, HarnessAdapter>` rather than a list of
|
|
817
|
+
* consts and a switch, and the type is the point: a kind added to
|
|
818
|
+
* `HARNESS_KINDS` with no adapter beside it fails to compile, so the two lists
|
|
819
|
+
* cannot drift by forgetting. The subcommand dispatch below reads this map, so
|
|
820
|
+
* `approval hook <kind>` is answerable for exactly the kinds named here.
|
|
821
|
+
*
|
|
822
|
+
* The kinds that are enumerated OUTSIDE this module — the schema's
|
|
823
|
+
* `payload.harness` enum, the verb registry's `hook` subcommands, the MCP
|
|
824
|
+
* exclusions, the help — are pinned set-equal to `HARNESS_KINDS` by
|
|
825
|
+
* `tests/harness-enum.test.ts`, which exists because `grok` shipped an adapter
|
|
826
|
+
* in APRV-243 and reached none of them. A Grok session's manual-class
|
|
827
|
+
* registration was refused at the write boundary for eleven days and nothing
|
|
828
|
+
* failed.
|
|
829
|
+
*/
|
|
830
|
+
export const HARNESS_ADAPTERS = {
|
|
831
|
+
"claude-code": CLAUDE_ADAPTER,
|
|
832
|
+
cursor: CURSOR_ADAPTER,
|
|
833
|
+
codex: CODEX_ADAPTER,
|
|
834
|
+
grok: GROK_ADAPTER,
|
|
835
|
+
muse: MUSE_ADAPTER,
|
|
836
|
+
hermes: HERMES_ADAPTER,
|
|
837
|
+
};
|
|
838
|
+
/**
|
|
839
|
+
* Is `name` this adapter's post-execution event?
|
|
840
|
+
*
|
|
841
|
+
* One reading of the question, so the three places that used to spell it
|
|
842
|
+
* themselves cannot come apart (APRV-398). Codex names one event, Hermes names
|
|
843
|
+
* its own snake_case pair, and everything else uses Claude Code's two; a
|
|
844
|
+
* harness whose names appeared in another harness's list would answer an event
|
|
845
|
+
* nobody sent it.
|
|
846
|
+
*/
|
|
847
|
+
function isPostToolEvent(adapter, name) {
|
|
848
|
+
if (name === null)
|
|
849
|
+
return false;
|
|
850
|
+
if (adapter.kind === "codex")
|
|
851
|
+
return name === CODEX_POST_TOOL_EVENT;
|
|
852
|
+
if (adapter.kind === "hermes")
|
|
853
|
+
return name === HERMES_POST_TOOL_EVENT;
|
|
854
|
+
return POST_TOOL_EVENTS.includes(name);
|
|
855
|
+
}
|
|
303
856
|
/**
|
|
304
857
|
* The decision object the harness reads from stdout.
|
|
305
858
|
*
|
|
306
859
|
* Claude Code wants the nested PreToolUse envelope. Cursor native hooks want
|
|
307
|
-
* `{permission, user_message, agent_message}`.
|
|
308
|
-
* harness, still never `ask`.
|
|
860
|
+
* `{permission, user_message, agent_message}`. Grok Build wants
|
|
861
|
+
* `{decision, reason}`. One construction site per harness, still never `ask`.
|
|
309
862
|
*/
|
|
310
863
|
function decision(permission, reason, harness, codexCommand) {
|
|
311
864
|
if (harness === "cursor") {
|
|
@@ -315,6 +868,36 @@ function decision(permission, reason, harness, codexCommand) {
|
|
|
315
868
|
agent_message: reason,
|
|
316
869
|
})}\n`;
|
|
317
870
|
}
|
|
871
|
+
if (harness === "grok") {
|
|
872
|
+
return `${JSON.stringify({ decision: permission, reason })}\n`;
|
|
873
|
+
}
|
|
874
|
+
if (harness === "hermes") {
|
|
875
|
+
// APRV-398. Hermes's OWN block form and exactly one form:
|
|
876
|
+
// `{action:"block", message}`. It emits neither the Claude-compatible
|
|
877
|
+
// `{decision:"block", reason}` beside it nor any other key, and the test
|
|
878
|
+
// asserts the top-level keys precisely so a later edit cannot quietly add
|
|
879
|
+
// one — on Muse a verdict carrying an unsupported key was a FAILED hook and
|
|
880
|
+
// a failed hook failed open, which made the more explicit refusal the weaker
|
|
881
|
+
// one. APRV-415 measured Hermes on the same question and it is NOT Muse: the
|
|
882
|
+
// mixed payload blocked, so this harness tolerates a superset. One dialect is
|
|
883
|
+
// kept anyway, because the tolerance is a property of one release and a
|
|
884
|
+
// verdict whose meaning depends on how much of it the harness parsed is not a
|
|
885
|
+
// verdict a reader can check.
|
|
886
|
+
//
|
|
887
|
+
// THE ALLOW IS `{}` AND CARRIES NO REASON, which is the harness's own
|
|
888
|
+
// vocabulary rather than a shrug: Hermes has no allow directive, and a
|
|
889
|
+
// parser that finds no directive lets the call proceed. `{}` over an empty
|
|
890
|
+
// stdout because a human reading a log can tell a hook that decided from a
|
|
891
|
+
// hook that printed nothing; `{}` over an invented `{"action":"allow"}`
|
|
892
|
+
// because that would be an allow only by falling through the parser's
|
|
893
|
+
// unrecognised path, and a verdict that works by not being understood is one
|
|
894
|
+
// release away from being a block. The reason reaches stderr instead (see
|
|
895
|
+
// {@link allow}), where it is read as a block message at the blocking exit
|
|
896
|
+
// code and nowhere else.
|
|
897
|
+
return permission === "deny"
|
|
898
|
+
? `${JSON.stringify({ action: "block", message: reason })}\n`
|
|
899
|
+
: "{}\n";
|
|
900
|
+
}
|
|
318
901
|
const hookSpecificOutput = {
|
|
319
902
|
hookEventName: "PreToolUse",
|
|
320
903
|
permissionDecision: permission,
|
|
@@ -330,17 +913,240 @@ function allow(streams, reason, harness, codexCommand) {
|
|
|
330
913
|
if (harness === "codex" && codexCommand === undefined) {
|
|
331
914
|
return deny(streams, "hook-io", "the Codex allow lost its exact bound tool_input.command", harness);
|
|
332
915
|
}
|
|
916
|
+
// APRV-398. Hermes's allow is `{}` and has nowhere to put a reason, so the
|
|
917
|
+
// reason goes to stderr rather than being dropped: it names the classes the
|
|
918
|
+
// verdict authorized and carries the loop floor's own words when one stood,
|
|
919
|
+
// which is the line an operator reads back off a session. Hermes reads stderr
|
|
920
|
+
// only as a block message at the blocking exit code, and an allow never uses
|
|
921
|
+
// that exit code, so this cannot become part of a verdict.
|
|
922
|
+
if (harness === "hermes")
|
|
923
|
+
streams.err(`approval hook hermes: allow — ${reason}\n`);
|
|
333
924
|
streams.out(decision("allow", reason, harness, codexCommand));
|
|
334
925
|
return EXIT_OK;
|
|
335
926
|
}
|
|
927
|
+
/**
|
|
928
|
+
* The exit code a DENY carries.
|
|
929
|
+
*
|
|
930
|
+
* Claude Code, Cursor and Codex read the verdict out of stdout and treat a
|
|
931
|
+
* non-zero exit as a broken hook, so a deny there exits 0 with a JSON body.
|
|
932
|
+
* Grok Build reads exit 2 as the deny (APRV-243), and reads exit 0 as ALLOW
|
|
933
|
+
* whatever stdout said. Printing the body AND exiting 2 satisfies both halves
|
|
934
|
+
* of its documented contract and leaves the reason where a person can read it.
|
|
935
|
+
*/
|
|
936
|
+
const GROK_DENY_EXIT = 2;
|
|
937
|
+
/**
|
|
938
|
+
* The exit code a HERMES deny carries, and why it IS 2 (APRV-398).
|
|
939
|
+
*
|
|
940
|
+
* This is the one place the Muse reasoning does not carry over, and the
|
|
941
|
+
* difference is in Hermes's own code rather than in a preference. Hermes defines
|
|
942
|
+
* a blocking exit code, applies it to `pre_tool_call` and to no other event, and
|
|
943
|
+
* treats it as UNCONDITIONAL: the call is blocked whatever stdout said, and the
|
|
944
|
+
* block message is taken from the stdout directive first, from stderr second,
|
|
945
|
+
* and from a default third. So the body and the exit code are not two accounts
|
|
946
|
+
* of one answer that the harness has to reconcile — the harness states the
|
|
947
|
+
* precedence itself, and the two agree by its own rule.
|
|
948
|
+
*
|
|
949
|
+
* That makes 2 strictly stronger than 0 here, and it closes the one gap
|
|
950
|
+
* `fail_closed` leaves open. `fail_closed: true` blocks on a spawn error, a
|
|
951
|
+
* timeout, and stdout that is non-empty and not a JSON object; it does NOT block
|
|
952
|
+
* a hook that exits non-zero having printed nothing, because its condition
|
|
953
|
+
* requires a non-empty stdout. Exiting 2 means every refusal this adapter
|
|
954
|
+
* reaches blocks on the exit code alone, including the ones where the body never
|
|
955
|
+
* made it out.
|
|
956
|
+
*
|
|
957
|
+
* Grok's 2 and this 2 are therefore the same number for opposite reasons, and it
|
|
958
|
+
* is worth not conflating them: there, exit 2 is the ONLY signal Grok reads and
|
|
959
|
+
* the body is decoration. Here the body is the message and the exit code is the
|
|
960
|
+
* floor under it.
|
|
961
|
+
*/
|
|
962
|
+
const HERMES_DENY_EXIT = 2;
|
|
336
963
|
function deny(streams, code, detail, harness) {
|
|
337
964
|
streams.out(decision("deny", `${code}: ${detail}`, harness));
|
|
965
|
+
if (harness === "grok")
|
|
966
|
+
return GROK_DENY_EXIT;
|
|
967
|
+
if (harness === "hermes")
|
|
968
|
+
return HERMES_DENY_EXIT;
|
|
338
969
|
return EXIT_OK;
|
|
339
970
|
}
|
|
971
|
+
/**
|
|
972
|
+
* The block directive a harness reads, as bytes, for a refusal raised OUTSIDE
|
|
973
|
+
* the hook (APRV-421).
|
|
974
|
+
*
|
|
975
|
+
* `approval serve` can refuse a hook request before `commandHook` is reached
|
|
976
|
+
* at all: an oversized body, a credential at the wrong door, a method it does
|
|
977
|
+
* not answer. Each of those is a refusal by the transport, and a transport
|
|
978
|
+
* refusal a harness reads as "no verdict" is a harness that runs the command.
|
|
979
|
+
* So the refusal has to be spoken in the harness's own dialect, and the only
|
|
980
|
+
* correct source of that dialect is the function that prints it here.
|
|
981
|
+
*
|
|
982
|
+
* It renders through {@link deny} for that reason: one construction site per
|
|
983
|
+
* harness, still never `ask`. The exit code returned is the one the verb would
|
|
984
|
+
* have carried; a caller may impose a stricter one, and the transport does.
|
|
985
|
+
*/
|
|
986
|
+
export function harnessBlockDirective(code, detail, harness) {
|
|
987
|
+
const out = [];
|
|
988
|
+
const exitCode = deny({ out: (text) => out.push(text), err: () => undefined }, code, detail, harness);
|
|
989
|
+
return { stdout: out.join(""), exitCode };
|
|
990
|
+
}
|
|
991
|
+
/** The machine-readable code a Hermes `execute_code` call is refused with. */
|
|
992
|
+
export const HERMES_EXECUTE_CODE_REFUSAL = "hook-hermes-execute-code-unbound";
|
|
993
|
+
/** The tool that carries a program and no path, no argv and no directory. */
|
|
994
|
+
const HERMES_EXECUTE_CODE_TOOL = "execute_code";
|
|
995
|
+
/**
|
|
996
|
+
* The retry a Hermes session makes after one of these refusals, in one place.
|
|
997
|
+
*
|
|
998
|
+
* It is the whole point of refusing rather than falling back: a session that is
|
|
999
|
+
* told what to send next stays usable, and the live probe watched the model
|
|
1000
|
+
* answer a block by trying the SAME effect through another tool and another
|
|
1001
|
+
* directory. A refusal that did not name the repair would get that behaviour
|
|
1002
|
+
* again, one tool along.
|
|
1003
|
+
*/
|
|
1004
|
+
const HERMES_ABSOLUTE_RETRY = "spell the directory in full from `/` and send the call again; the classifier then reads the same path the harness will write";
|
|
1005
|
+
/**
|
|
1006
|
+
* A Hermes call whose effective directory this hook cannot see, as the reason it
|
|
1007
|
+
* is refused, or `null` when the call binds its own directory (APRV-415).
|
|
1008
|
+
*
|
|
1009
|
+
* ## The fact, observed rather than reasoned
|
|
1010
|
+
*
|
|
1011
|
+
* The live probe of APRV-398 (Hermes `main` at `118984d7`, 60 envelopes) settled
|
|
1012
|
+
* three things that together make this refusal necessary:
|
|
1013
|
+
*
|
|
1014
|
+
* 1. the envelope's `cwd` is the Hermes PROCESS directory (`Path.cwd()` in its
|
|
1015
|
+
* payload builder), which is not where the work happens. A session launched in
|
|
1016
|
+
* the scratch project wrote into `$HERMES_HOME/cache/scratch`, and a Telegram
|
|
1017
|
+
* gateway session's `cwd` was the USER HOME;
|
|
1018
|
+
* 2. `terminal` keeps a PER-SESSION recorded working directory that a `cd` in an
|
|
1019
|
+
* earlier call moves, and no field of the event reports it. The model passed
|
|
1020
|
+
* no `workdir` at all on the probe's shell call;
|
|
1021
|
+
* 3. all four file tools resolve a RELATIVE path against that same unreported
|
|
1022
|
+
* directory (`tools/file_tools_paths.py`, `_resolve_path_for_task`).
|
|
1023
|
+
*
|
|
1024
|
+
* So a call that does not state an absolute directory is the APRV-310 shape
|
|
1025
|
+
* across every tool on this harness: the verdict would bind bytes in one
|
|
1026
|
+
* directory and the harness would act in another. That is not a narrower answer
|
|
1027
|
+
* this runtime may reach by guessing, and `describeToolCall`'s fallback to the
|
|
1028
|
+
* event `cwd` — correct on Muse, where `cwd` IS the session root — would bind a
|
|
1029
|
+
* directory that does not even contain the work here.
|
|
1030
|
+
*
|
|
1031
|
+
* ## Why a refusal rather than a fallback, and why it is usable
|
|
1032
|
+
*
|
|
1033
|
+
* SPEC.md §11.1: ambiguity resolves to the stricter path, and a self-reported
|
|
1034
|
+
* field never reduces scrutiny. A relative `workdir` cannot widen anything, and
|
|
1035
|
+
* on this harness it cannot be narrowed into either, because there is no
|
|
1036
|
+
* trustworthy directory to fall back TO. The refusal carries
|
|
1037
|
+
* `hook-unsupported-execution-context`, the same code the Codex `Bash` refusal
|
|
1038
|
+
* carries, because it is the same defect — the harness did not say WHERE — and
|
|
1039
|
+
* the repair differs only in being available: a Hermes session repairs it
|
|
1040
|
+
* itself, in the next call, by naming an absolute path. That is why the reason
|
|
1041
|
+
* names the retry rather than describing the contract.
|
|
1042
|
+
*
|
|
1043
|
+
* The probe also watched what a blocked model does next: it retries the same
|
|
1044
|
+
* effect through another tool or another path. Refusing the shell tool alone
|
|
1045
|
+
* would have moved the work into `write_file`, which is exactly why every gated
|
|
1046
|
+
* tool is covered here and why an ABSENT path is refused beside a relative one.
|
|
1047
|
+
* A missing `path` on this harness does not mean "the workspace" the way Claude
|
|
1048
|
+
* Code's `Glob` does; it means that same unreported recorded directory.
|
|
1049
|
+
*/
|
|
1050
|
+
function hermesUnboundDirectory(input, adapter) {
|
|
1051
|
+
const tool = input.toolName;
|
|
1052
|
+
if (tool === adapter.shellTool) {
|
|
1053
|
+
const key = adapter.shellCwdKey ?? "workdir";
|
|
1054
|
+
const declared = readString(input.toolInput, key);
|
|
1055
|
+
if (declared !== null && isAbsolute(declared))
|
|
1056
|
+
return null;
|
|
1057
|
+
return `Hermes \`${tool}\` is refused because the call ${declared === null
|
|
1058
|
+
? `states no \`${key}\``
|
|
1059
|
+
: `states a relative \`${key}\` (${JSON.stringify(declared)})`}, and this harness reports the effective directory nowhere else: its \`cwd\` is the Hermes process directory, while the command runs in a per-session directory that an earlier \`cd\` moves and no field of this event carries. A verdict over the command alone would bind bytes in a directory the hook cannot see. Set \`${key}\` to an absolute path — ${HERMES_ABSOLUTE_RETRY}. No policy or open window authorizes bytes whose directory the hook cannot bind`;
|
|
1060
|
+
}
|
|
1061
|
+
if (!adapter.fileTools.includes(tool) && !adapter.readTools.includes(tool))
|
|
1062
|
+
return null;
|
|
1063
|
+
// The same keys `fileToolGate` and `readToolGate` read, in their order, plus
|
|
1064
|
+
// the `paths` array neither Hermes tool sends today: a later release that
|
|
1065
|
+
// added one would otherwise arrive as an unbound call that reads as bound.
|
|
1066
|
+
const declared = readString(input.toolInput, "path") ??
|
|
1067
|
+
readString(input.toolInput, "file_path") ??
|
|
1068
|
+
readString(input.toolInput, "notebook_path");
|
|
1069
|
+
const list = input.toolInput["paths"];
|
|
1070
|
+
const entries = Array.isArray(list)
|
|
1071
|
+
? list.filter((entry) => typeof entry === "string" && entry.trim() !== "")
|
|
1072
|
+
: [];
|
|
1073
|
+
const candidates = declared === null ? entries : [declared, ...entries];
|
|
1074
|
+
if (candidates.length === 0) {
|
|
1075
|
+
return `Hermes \`${tool}\` is refused because the call names no path at all. On this harness an unnamed target is not the workspace: it is the per-session recorded directory that an earlier \`cd\` moves and that no field of this event carries, and a live session's was \`$HERMES_HOME/cache/scratch\` while a gateway session's was the user's home. Name the directory or file with an absolute \`path\` — ${HERMES_ABSOLUTE_RETRY}`;
|
|
1076
|
+
}
|
|
1077
|
+
const relative = candidates.find((candidate) => !isAbsolute(candidate));
|
|
1078
|
+
if (relative === undefined)
|
|
1079
|
+
return null;
|
|
1080
|
+
return `Hermes \`${tool}\` is refused because the path it names is relative (${JSON.stringify(relative)}), and Hermes resolves a relative path against the per-session recorded directory that an earlier \`cd\` moves, which this event does not carry: its \`cwd\` is the Hermes process directory. The verdict and the write would name different files. Send the same call with an absolute path — ${HERMES_ABSOLUTE_RETRY}. No policy or open window authorizes a path the hook cannot place`;
|
|
1081
|
+
}
|
|
1082
|
+
/** The machine-readable code a Contributor-tier session is refused with. */
|
|
1083
|
+
export const MUSE_CONTRIBUTOR_REFUSAL = "hook-muse-contributor-model";
|
|
1084
|
+
/**
|
|
1085
|
+
* Refuse every tool call on a Contributor-tier model, whatever the policy says.
|
|
1086
|
+
*
|
|
1087
|
+
* Meta sells two tiers of the Muse Spark family and marks the difference in the
|
|
1088
|
+
* model id: a Contributor variant "trades a lower price for permission to train
|
|
1089
|
+
* on your prompts and completions", against a Standard variant documented as
|
|
1090
|
+
* never trained on. So a Contributor session discloses every file it reads, and
|
|
1091
|
+
* a policy that would autonomously allow a read of the workspace was not
|
|
1092
|
+
* written with that in mind.
|
|
1093
|
+
*
|
|
1094
|
+
* ## Why this is not a policy line
|
|
1095
|
+
*
|
|
1096
|
+
* A policy grants and withholds authority over CLASSES. This is not about the
|
|
1097
|
+
* class of the action; the same `read.file` is fine on one model and a
|
|
1098
|
+
* disclosure on another. Encoding it as policy would mean every operator's
|
|
1099
|
+
* `APPROVAL.md` had to name Meta's tiering to be safe, and one that did not
|
|
1100
|
+
* would be silently unsafe. So the adapter refuses, above the policy, and no
|
|
1101
|
+
* policy can widen it. That is a strictness increase only, which is the one
|
|
1102
|
+
* direction a hook may move a verdict on its own (SPEC §11.1 invariant 4).
|
|
1103
|
+
*
|
|
1104
|
+
* ## The self-reported field
|
|
1105
|
+
*
|
|
1106
|
+
* `model` is the harness's claim about itself, and SPEC §11.1 says a
|
|
1107
|
+
* self-reported field never REDUCES scrutiny. This only ever raises it: the
|
|
1108
|
+
* string can refuse a call, never authorize one. An absent, empty or
|
|
1109
|
+
* unparseable `model` is refused too, because a session that will not say what
|
|
1110
|
+
* it is running is exactly the one not to trust with a read.
|
|
1111
|
+
*
|
|
1112
|
+
* ## What it cannot do
|
|
1113
|
+
*
|
|
1114
|
+
* It stops tool calls. It cannot recall the prompt that was already sent — a
|
|
1115
|
+
* hook fires after the model has seen it — and it cannot know what the harness
|
|
1116
|
+
* attached as context before the first tool call. `docs/muse-hook.md` opens
|
|
1117
|
+
* with this, because a guard people over-trust is worse than no guard.
|
|
1118
|
+
*/
|
|
1119
|
+
function contributorModelRefusal(model) {
|
|
1120
|
+
if (model === null || model.trim() === "") {
|
|
1121
|
+
return "the envelope names no model, and a session that will not say what it is running cannot be recognised as non-contributor";
|
|
1122
|
+
}
|
|
1123
|
+
if (model.trim().toLowerCase().includes(CONTRIBUTOR_SUFFIX.replace(/^-/u, ""))) {
|
|
1124
|
+
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.`;
|
|
1125
|
+
}
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
340
1128
|
function readString(source, key) {
|
|
341
1129
|
const value = source[key];
|
|
342
1130
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
343
1131
|
}
|
|
1132
|
+
/**
|
|
1133
|
+
* The snake_case key, or the camelCase one when the adapter speaks that
|
|
1134
|
+
* dialect (APRV-243).
|
|
1135
|
+
*
|
|
1136
|
+
* snake_case is read FIRST in both dialects, so an envelope carrying both
|
|
1137
|
+
* spellings resolves the same way for every harness and cannot be used to show
|
|
1138
|
+
* one command to the classifier and another to the harness.
|
|
1139
|
+
*/
|
|
1140
|
+
function readDialect(source, snake, camel, camelCase) {
|
|
1141
|
+
const value = source[snake];
|
|
1142
|
+
if (value !== undefined)
|
|
1143
|
+
return value;
|
|
1144
|
+
return camelCase ? source[camel] : undefined;
|
|
1145
|
+
}
|
|
1146
|
+
function readDialectString(source, snake, camel, camelCase) {
|
|
1147
|
+
const value = readDialect(source, snake, camel, camelCase);
|
|
1148
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
1149
|
+
}
|
|
344
1150
|
/**
|
|
345
1151
|
* Parse the PreToolUse JSON.
|
|
346
1152
|
*
|
|
@@ -350,7 +1156,7 @@ function readString(source, key) {
|
|
|
350
1156
|
* and a gate that read the subject's own account of its intent would be letting
|
|
351
1157
|
* a self-reported field reduce scrutiny (SPEC.md §11.1).
|
|
352
1158
|
*/
|
|
353
|
-
function parseHookInput(raw) {
|
|
1159
|
+
function parseHookInput(raw, camelCase = false) {
|
|
354
1160
|
if (raw.trim().length === 0)
|
|
355
1161
|
return { ok: false, detail: "hook stdin was empty" };
|
|
356
1162
|
let parsed;
|
|
@@ -367,15 +1173,19 @@ function parseHookInput(raw) {
|
|
|
367
1173
|
return { ok: false, detail: "hook stdin is not a JSON object" };
|
|
368
1174
|
}
|
|
369
1175
|
const fields = parsed;
|
|
370
|
-
const toolName =
|
|
371
|
-
if (toolName === null)
|
|
372
|
-
return {
|
|
373
|
-
|
|
1176
|
+
const toolName = readDialectString(fields, "tool_name", "toolName", camelCase);
|
|
1177
|
+
if (toolName === null) {
|
|
1178
|
+
return {
|
|
1179
|
+
ok: false,
|
|
1180
|
+
detail: camelCase ? "hook input has no tool_name or toolName" : "hook input has no tool_name",
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
const toolInputValue = readDialect(fields, "tool_input", "toolInput", camelCase);
|
|
374
1184
|
const toolInput = typeof toolInputValue === "object" && toolInputValue !== null && !Array.isArray(toolInputValue)
|
|
375
1185
|
? toolInputValue
|
|
376
1186
|
: {};
|
|
377
|
-
const responseValue = fields
|
|
378
|
-
const sessionId =
|
|
1187
|
+
const responseValue = readDialect(fields, "tool_response", "toolResponse", camelCase);
|
|
1188
|
+
const sessionId = readDialectString(fields, "session_id", "sessionId", camelCase);
|
|
379
1189
|
return {
|
|
380
1190
|
ok: true,
|
|
381
1191
|
input: {
|
|
@@ -384,13 +1194,17 @@ function parseHookInput(raw) {
|
|
|
384
1194
|
// slower, which is the fail-closed direction.
|
|
385
1195
|
sessionId: sessionId ?? UNKNOWN_SESSION,
|
|
386
1196
|
sessionIdPresent: sessionId !== null,
|
|
1197
|
+
// Grok Build sends `workspaceRoot` beside `cwd`. Only `cwd` is read:
|
|
1198
|
+
// the classifier resolves paths against the directory the command will
|
|
1199
|
+
// actually run in, and a workspace root is a different fact.
|
|
387
1200
|
cwd: readString(fields, "cwd") ?? "",
|
|
388
1201
|
toolName,
|
|
389
1202
|
toolInput,
|
|
390
|
-
toolUseId:
|
|
391
|
-
hookEventName:
|
|
1203
|
+
toolUseId: readDialectString(fields, "tool_use_id", "toolUseId", camelCase),
|
|
1204
|
+
hookEventName: readDialectString(fields, "hook_event_name", "hookEventName", camelCase),
|
|
1205
|
+
model: readDialectString(fields, "model", "model", camelCase),
|
|
392
1206
|
harnessVersion: readString(fields, "version"),
|
|
393
|
-
interrupted: fields["is_interrupt"] === true,
|
|
1207
|
+
interrupted: fields["is_interrupt"] === true || (camelCase && fields["isInterrupt"] === true),
|
|
394
1208
|
toolResponse: typeof responseValue === "object" && responseValue !== null && !Array.isArray(responseValue)
|
|
395
1209
|
? responseValue
|
|
396
1210
|
: null,
|
|
@@ -842,22 +1656,387 @@ export function refineScratchDelete(result, roots) {
|
|
|
842
1656
|
}
|
|
843
1657
|
return { result: { ok: true, segments, classes }, notes };
|
|
844
1658
|
}
|
|
1659
|
+
// ===========================================================================
|
|
1660
|
+
// Read scope, the disk half (APRV-347)
|
|
1661
|
+
// ===========================================================================
|
|
1662
|
+
/**
|
|
1663
|
+
* ## Why reads need a second pass too
|
|
1664
|
+
*
|
|
1665
|
+
* The pure classifier can settle an ABSOLUTE read target against the roots and
|
|
1666
|
+
* nothing else. `cat ../other/secrets`, `grep -r needle ./link-to-elsewhere`
|
|
1667
|
+
* and a bare `ls` all mean something only once a working directory and the
|
|
1668
|
+
* filesystem's own links are in hand, and those are exactly the spellings an
|
|
1669
|
+
* agent reaches for. So the read rule has the same two-part shape the delete
|
|
1670
|
+
* rule has: the text decides what text can decide, and this pass — which
|
|
1671
|
+
* resolves against the hook's OWN directory, follows links, and answers `null`
|
|
1672
|
+
* for anything it cannot resolve — tightens `read.shell` back to
|
|
1673
|
+
* `read.file.out_of_scope`.
|
|
1674
|
+
*
|
|
1675
|
+
* It only ever moves a segment toward the stricter class. A caller that skipped
|
|
1676
|
+
* it would be no more permissive than one that runs it, which is what lets
|
|
1677
|
+
* `hook classify` and `hook <harness>` share it without either becoming the
|
|
1678
|
+
* authority.
|
|
1679
|
+
*/
|
|
1680
|
+
/** The rule a read tightened by this pass reports. */
|
|
1681
|
+
const READ_SCOPE_REJECTED_RULE = "read-out-of-scope-resolved";
|
|
1682
|
+
/**
|
|
1683
|
+
* The read roots this process may vouch for, resolved.
|
|
1684
|
+
*
|
|
1685
|
+
* The gate root is the directory the hook resolved its POLICY from, never the
|
|
1686
|
+
* harness-supplied `cwd`: a scope the subject of the gate could choose is not a
|
|
1687
|
+
* scope (SPEC.md §11.1, self-reported fields never reduce scrutiny). The
|
|
1688
|
+
* scratchpad and temp roots are the ones `resolveScratchRoots` already computes
|
|
1689
|
+
* and already guards, so the two rules cannot disagree about where the agent's
|
|
1690
|
+
* own scratch is. `declared` is `read_scope.roots` out of the loaded policy,
|
|
1691
|
+
* which may only widen this set.
|
|
1692
|
+
*/
|
|
1693
|
+
export function resolveReadRoots(cwd, gateRoot, declared) {
|
|
1694
|
+
const roots = effectiveReadRoots({
|
|
1695
|
+
gateRoot: resolvedPath(gateRoot) ?? gateRoot,
|
|
1696
|
+
systemRoots: resolveScratchRoots(cwd),
|
|
1697
|
+
...(declared === undefined ? {} : { declared }),
|
|
1698
|
+
});
|
|
1699
|
+
const out = [];
|
|
1700
|
+
for (const root of roots) {
|
|
1701
|
+
const resolved = resolvedPath(root) ?? root;
|
|
1702
|
+
if (!out.includes(resolved))
|
|
1703
|
+
out.push(resolved);
|
|
1704
|
+
}
|
|
1705
|
+
return out;
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Where this target really is, or `null` when nothing can say.
|
|
1709
|
+
*
|
|
1710
|
+
* The same walk `targetStaysInScratch` does, and for the same reason: the file
|
|
1711
|
+
* may not exist yet (or at all), so the nearest EXISTING ancestor is resolved
|
|
1712
|
+
* and the unresolved tail re-appended. A symlink anywhere in that chain
|
|
1713
|
+
* therefore cannot smuggle a read out of the root, which is the escape the pure
|
|
1714
|
+
* half cannot see.
|
|
1715
|
+
*/
|
|
1716
|
+
function resolvedReadTarget(target, cwd) {
|
|
1717
|
+
let existing = isAbsolute(target) ? target : resolvePathSegments(cwd, target);
|
|
1718
|
+
const tail = [];
|
|
1719
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
1720
|
+
if (existsSync(existing))
|
|
1721
|
+
break;
|
|
1722
|
+
const up = dirname(existing);
|
|
1723
|
+
if (up === existing)
|
|
1724
|
+
return null;
|
|
1725
|
+
tail.unshift(basename(existing));
|
|
1726
|
+
existing = up;
|
|
1727
|
+
}
|
|
1728
|
+
const resolved = resolvedPath(existing);
|
|
1729
|
+
if (resolved === null)
|
|
1730
|
+
return null;
|
|
1731
|
+
return tail.length === 0 ? resolved : join(resolved, ...tail);
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Tighten a `read.shell` segment to `read.file.out_of_scope` wherever the disk
|
|
1735
|
+
* disagrees with the text.
|
|
1736
|
+
*
|
|
1737
|
+
* IMPURE by design and by contract. `roots` empty means the caller asked for no
|
|
1738
|
+
* read scoping at all, and every segment is returned untouched — the same
|
|
1739
|
+
* "absent yields today's answer" the classifier context promises.
|
|
1740
|
+
*/
|
|
1741
|
+
export function refineReadScope(result, roots, cwd) {
|
|
1742
|
+
if (!result.ok)
|
|
1743
|
+
return { result, notes: [] };
|
|
1744
|
+
if (roots.length === 0)
|
|
1745
|
+
return { result, notes: [] };
|
|
1746
|
+
if (!result.segments.some((segment) => segment.class === "read.shell")) {
|
|
1747
|
+
return { result, notes: [] };
|
|
1748
|
+
}
|
|
1749
|
+
const notes = [];
|
|
1750
|
+
const segments = result.segments.map((segment) => {
|
|
1751
|
+
if (segment.class !== "read.shell")
|
|
1752
|
+
return segment;
|
|
1753
|
+
const words = commandSegmentWords(segment.text);
|
|
1754
|
+
const parsed = words === null ? undefined : words[0];
|
|
1755
|
+
if (parsed === undefined)
|
|
1756
|
+
return segment;
|
|
1757
|
+
const positionals = parsed.args.filter((arg) => !arg.startsWith("-") || arg === "-");
|
|
1758
|
+
const declaredTargets = readTargetsOf(parsed.bin, positionals, parsed.args);
|
|
1759
|
+
if (declaredTargets === null)
|
|
1760
|
+
return segment;
|
|
1761
|
+
// No operand is not "no read": `ls`, `find` and a piped `grep needle` all
|
|
1762
|
+
// read the working directory, so the working directory is what is checked.
|
|
1763
|
+
const targets = declaredTargets.length === 0 ? ["."] : declaredTargets;
|
|
1764
|
+
const reject = (path, detail) => {
|
|
1765
|
+
notes.push(`${READ_SCOPE_REJECTED_RULE}: ${detail}`);
|
|
1766
|
+
return {
|
|
1767
|
+
...segment,
|
|
1768
|
+
class: READ_OUT_OF_SCOPE_CLASS,
|
|
1769
|
+
rule: READ_SCOPE_REJECTED_RULE,
|
|
1770
|
+
path,
|
|
1771
|
+
};
|
|
1772
|
+
};
|
|
1773
|
+
for (const target of targets) {
|
|
1774
|
+
const resolved = resolvedReadTarget(target, cwd);
|
|
1775
|
+
if (resolved === null) {
|
|
1776
|
+
return reject(target, `${target} does not resolve to any path this hook can see, so \`${segment.text}\` is ${READ_OUT_OF_SCOPE_CLASS}`);
|
|
1777
|
+
}
|
|
1778
|
+
if (!isInReadScope(resolved, roots)) {
|
|
1779
|
+
return reject(resolved, `${target} resolves to ${resolved}, which is outside the read scope (${renderReadRoots(roots)}), so \`${segment.text}\` is ${READ_OUT_OF_SCOPE_CLASS}`);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
return segment;
|
|
1783
|
+
});
|
|
1784
|
+
if (notes.length === 0)
|
|
1785
|
+
return { result, notes };
|
|
1786
|
+
const classes = [];
|
|
1787
|
+
for (const segment of segments) {
|
|
1788
|
+
if (!classes.includes(segment.class))
|
|
1789
|
+
classes.push(segment.class);
|
|
1790
|
+
}
|
|
1791
|
+
return { result: { ok: true, segments, classes }, notes };
|
|
1792
|
+
}
|
|
1793
|
+
// ===========================================================================
|
|
1794
|
+
// Write scope, the disk half (APRV-402)
|
|
1795
|
+
// ===========================================================================
|
|
845
1796
|
/**
|
|
846
|
-
*
|
|
1797
|
+
* ## Why writes need a second pass too
|
|
1798
|
+
*
|
|
1799
|
+
* Deletes and reads each had one and writes did not, which left the oldest of
|
|
1800
|
+
* the three rules the loosest. `files.write.workspace` was decided on the text
|
|
1801
|
+
* alone: a relative destination is the workspace, said the classifier, and a
|
|
1802
|
+
* relative destination is the workspace only while nothing on the way to it is
|
|
1803
|
+
* a symlink out. `cp x build/y`, `tee build/y`, `mkdir build/y`, and since
|
|
1804
|
+
* APRV-397 `tar -x -C build` and `npm pack --pack-destination build`, were all
|
|
1805
|
+
* autonomous with `build` pointing anywhere at all.
|
|
1806
|
+
*
|
|
1807
|
+
* Not a regression and not new with the packaging rows (APRV-402): the hole is
|
|
1808
|
+
* as old as the workspace-write row, and before APRV-397 every packaging
|
|
1809
|
+
* command was denied as unclassified, so nothing that was refused became
|
|
1810
|
+
* allowed by a symlink. What changed is that the rows now say `workspace` often
|
|
1811
|
+
* enough for the gap to be worth the disk.
|
|
1812
|
+
*
|
|
1813
|
+
* ## The shape is the other two passes' shape
|
|
1814
|
+
*
|
|
1815
|
+
* Resolve the nearest EXISTING ancestor of each destination, re-append the
|
|
1816
|
+
* unresolved tail, and TIGHTEN when the result is outside every root. It can
|
|
1817
|
+
* only ever move a segment toward the stricter class, so a caller that skipped
|
|
1818
|
+
* it is no more permissive than one that runs it, which is what lets
|
|
1819
|
+
* `hook classify` and `hook <harness>` share it without either becoming the
|
|
1820
|
+
* authority.
|
|
1821
|
+
*
|
|
1822
|
+
* ## What it costs, measured before it was written (APRV-402 AC1)
|
|
1823
|
+
*
|
|
1824
|
+
* The walk is 0.10 ms to 0.13 ms per destination on a warm cache (0.07 ms for
|
|
1825
|
+
* an absolute one), over 20k iterations of each shape. A write segment names
|
|
1826
|
+
* one or two destinations, so a write command pays about a fifth of a
|
|
1827
|
+
* millisecond. APRV-209 measured a cold gated invocation at about 371 ms, of
|
|
1828
|
+
* which 116 ms is the module graph and 51 ms is `hook.js` alone; this is under
|
|
1829
|
+
* half a percent of that last term and it is paid only by segments the
|
|
1830
|
+
* classifier already called a workspace write. The delete and read passes pay
|
|
1831
|
+
* the same price per target and have since APRV-267 and APRV-347.
|
|
1832
|
+
*/
|
|
1833
|
+
/** The class an out-of-scope write tightens to, as `core/command-class.ts` spells it. */
|
|
1834
|
+
const OUT_OF_SCOPE_WRITE_CLASS = "files.delete.out_of_scope";
|
|
1835
|
+
/** The rule a write tightened by this pass reports. */
|
|
1836
|
+
const WRITE_SCOPE_REJECTED_RULE = "write-out-of-scope-resolved";
|
|
1837
|
+
/** The class this pass re-reads, and the only one it will touch. */
|
|
1838
|
+
const WORKSPACE_WRITE = "files.write.workspace";
|
|
1839
|
+
/**
|
|
1840
|
+
* The classifier rules whose destinations this pass re-reads.
|
|
1841
|
+
*
|
|
1842
|
+
* Exactly the rows APRV-402 names: the workspace-write row (`mkdir`, `cp`,
|
|
1843
|
+
* `mv`, `touch`, `tee`, `ln`, `chmod`, `truncate`, `rmdir`) and the six
|
|
1844
|
+
* packaging answers APRV-397 added. `rm-workspace` and `redirect-write` reach
|
|
1845
|
+
* the same class by the same text-only reasoning and are deliberately NOT here:
|
|
1846
|
+
* they are the same hole in two more rules and they are their own decision, not
|
|
1847
|
+
* one to take inside this task's diff.
|
|
1848
|
+
*/
|
|
1849
|
+
const WRITE_SCOPE_RULES = [
|
|
1850
|
+
"workspace-write",
|
|
1851
|
+
"tar-extract",
|
|
1852
|
+
"tar-create",
|
|
1853
|
+
"gunzip-write",
|
|
1854
|
+
"base64-write",
|
|
1855
|
+
"openssl-digest-out",
|
|
1856
|
+
"npm-pack",
|
|
1857
|
+
];
|
|
1858
|
+
/**
|
|
1859
|
+
* The roots a write may land in, resolved.
|
|
1860
|
+
*
|
|
1861
|
+
* The scratch roots are the ones `resolveScratchRoots` already computes and
|
|
1862
|
+
* already guards, so this rule and the delete rule cannot disagree about where
|
|
1863
|
+
* the agent's own scratch is. The working directory is the other one, and it is
|
|
1864
|
+
* the root the classifier's own text rule already implies: "a relative
|
|
1865
|
+
* destination is the workspace" means the workspace is wherever the command
|
|
1866
|
+
* runs.
|
|
1867
|
+
*
|
|
1868
|
+
* `cwd` is harness-supplied, and reading a root out of it does not offend
|
|
1869
|
+
* SPEC.md §11.1 invariant 4, because this pass only ever narrows. The baseline
|
|
1870
|
+
* it narrows from is no check at all, so a poisoned `cwd` buys back exactly
|
|
1871
|
+
* today's answer and never a looser one. Compare the read pass, which checks
|
|
1872
|
+
* containment against the GATE root: that pass decides whether a read is in
|
|
1873
|
+
* scope at all, so the scope has to be one the subject cannot choose. This one
|
|
1874
|
+
* only takes an allowance away.
|
|
1875
|
+
*/
|
|
1876
|
+
export function resolveWriteRoots(cwd) {
|
|
1877
|
+
const roots = resolveScratchRoots(cwd);
|
|
1878
|
+
const here = resolvedPath(cwd);
|
|
1879
|
+
if (here !== null && !roots.includes(here))
|
|
1880
|
+
roots.unshift(here);
|
|
1881
|
+
return roots;
|
|
1882
|
+
}
|
|
1883
|
+
/**
|
|
1884
|
+
* Where this destination really is, or `null` when nothing can say.
|
|
1885
|
+
*
|
|
1886
|
+
* The same walk `targetStaysInScratch` and `resolvedReadTarget` do, for the
|
|
1887
|
+
* same reason: the destination usually does NOT exist yet, so the nearest
|
|
1888
|
+
* existing ancestor is resolved and the unresolved tail re-appended. A symlink
|
|
1889
|
+
* anywhere in that chain therefore cannot smuggle a write out of the roots,
|
|
1890
|
+
* which is the escape the pure half cannot see.
|
|
1891
|
+
*/
|
|
1892
|
+
function resolvedWriteTarget(target, cwd) {
|
|
1893
|
+
let existing = isAbsolute(target) ? target : resolvePathSegments(cwd, target);
|
|
1894
|
+
const tail = [];
|
|
1895
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
1896
|
+
if (existsSync(existing))
|
|
1897
|
+
break;
|
|
1898
|
+
const up = dirname(existing);
|
|
1899
|
+
if (up === existing)
|
|
1900
|
+
return null;
|
|
1901
|
+
tail.unshift(basename(existing));
|
|
1902
|
+
existing = up;
|
|
1903
|
+
}
|
|
1904
|
+
const resolved = resolvedPath(existing);
|
|
1905
|
+
if (resolved === null)
|
|
1906
|
+
return null;
|
|
1907
|
+
return tail.length === 0 ? resolved : join(resolved, ...tail);
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* The words of a write segment that could name a path on disk.
|
|
1911
|
+
*
|
|
1912
|
+
* Every argument that is not spelled as a flag, plus the value half of
|
|
1913
|
+
* `--opt=value`. This is the delete pass's own filter, and it is deliberately
|
|
1914
|
+
* coarser than the classifier's flag table: it reaches `-C dir`, `-o file` and
|
|
1915
|
+
* `--pack-destination dir` without this file holding a second copy of that
|
|
1916
|
+
* table to drift against. It over-includes (a `tar` archive being READ is a
|
|
1917
|
+
* candidate too, and so is `chmod`'s mode), and over-including can only tighten
|
|
1918
|
+
* a segment that would otherwise have been waved through.
|
|
1919
|
+
*
|
|
1920
|
+
* A bare `-` is not a path: it is stdin or stdout, and every binary in these
|
|
1921
|
+
* rows that accepts it means the stream.
|
|
1922
|
+
*/
|
|
1923
|
+
function writeCandidates(args) {
|
|
1924
|
+
const candidates = [];
|
|
1925
|
+
for (const arg of args) {
|
|
1926
|
+
if (arg === "-")
|
|
1927
|
+
continue;
|
|
1928
|
+
if (!arg.startsWith("-")) {
|
|
1929
|
+
candidates.push(arg);
|
|
1930
|
+
continue;
|
|
1931
|
+
}
|
|
1932
|
+
const equals = arg.indexOf("=");
|
|
1933
|
+
if (equals === -1)
|
|
1934
|
+
continue;
|
|
1935
|
+
const value = arg.slice(equals + 1);
|
|
1936
|
+
if (value.length > 0)
|
|
1937
|
+
candidates.push(value);
|
|
1938
|
+
}
|
|
1939
|
+
return candidates;
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* Tighten a `files.write.workspace` segment to the out-of-scope write class
|
|
1943
|
+
* wherever the disk disagrees with the text.
|
|
1944
|
+
*
|
|
1945
|
+
* IMPURE by design and by contract, exactly as the other two passes are. The
|
|
1946
|
+
* class it tightens to is the one `core/command-class.ts` already answers for
|
|
1947
|
+
* an out-of-scope packaging destination rather than a new name: a class no
|
|
1948
|
+
* policy mentions resolves by `defaults.autonomy` (SPEC.md §7), so minting
|
|
1949
|
+
* `files.write.out_of_scope` here would arrive AUTONOMOUS in every deployment
|
|
1950
|
+
* with permissive defaults, which is the one way a tightening pass could
|
|
1951
|
+
* loosen something.
|
|
1952
|
+
*
|
|
1953
|
+
* `roots` empty means the caller asked for no write scoping, and every segment
|
|
1954
|
+
* comes back untouched: the same "absent yields today's answer" the read pass
|
|
1955
|
+
* promises.
|
|
1956
|
+
*/
|
|
1957
|
+
export function refineWriteScope(result, roots, cwd) {
|
|
1958
|
+
if (!result.ok)
|
|
1959
|
+
return { result, notes: [] };
|
|
1960
|
+
if (roots.length === 0)
|
|
1961
|
+
return { result, notes: [] };
|
|
1962
|
+
if (!result.segments.some((segment) => segment.class === WORKSPACE_WRITE && WRITE_SCOPE_RULES.includes(segment.rule))) {
|
|
1963
|
+
return { result, notes: [] };
|
|
1964
|
+
}
|
|
1965
|
+
const notes = [];
|
|
1966
|
+
const segments = result.segments.map((segment) => {
|
|
1967
|
+
if (segment.class !== WORKSPACE_WRITE)
|
|
1968
|
+
return segment;
|
|
1969
|
+
if (!WRITE_SCOPE_RULES.includes(segment.rule))
|
|
1970
|
+
return segment;
|
|
1971
|
+
const words = commandSegmentWords(segment.text);
|
|
1972
|
+
const parsed = words === null ? undefined : words[0];
|
|
1973
|
+
// The classifier read this segment a moment ago, so a parse that disagrees
|
|
1974
|
+
// here is two reads of the same bytes disagreeing. Fail closed.
|
|
1975
|
+
if (parsed === undefined) {
|
|
1976
|
+
notes.push(`${WRITE_SCOPE_REJECTED_RULE}: \`${segment.text}\` could not be re-read, so it is ${OUT_OF_SCOPE_WRITE_CLASS}`);
|
|
1977
|
+
return { ...segment, class: OUT_OF_SCOPE_WRITE_CLASS, rule: WRITE_SCOPE_REJECTED_RULE };
|
|
1978
|
+
}
|
|
1979
|
+
const reject = (path, detail) => {
|
|
1980
|
+
notes.push(`${WRITE_SCOPE_REJECTED_RULE}: ${detail}`);
|
|
1981
|
+
return {
|
|
1982
|
+
...segment,
|
|
1983
|
+
class: OUT_OF_SCOPE_WRITE_CLASS,
|
|
1984
|
+
rule: WRITE_SCOPE_REJECTED_RULE,
|
|
1985
|
+
path,
|
|
1986
|
+
};
|
|
1987
|
+
};
|
|
1988
|
+
for (const candidate of writeCandidates(parsed.args)) {
|
|
1989
|
+
const resolved = resolvedWriteTarget(candidate, cwd);
|
|
1990
|
+
if (resolved === null) {
|
|
1991
|
+
return reject(candidate, `${candidate} does not resolve to any path this hook can see, so \`${segment.text}\` is ${OUT_OF_SCOPE_WRITE_CLASS}`);
|
|
1992
|
+
}
|
|
1993
|
+
if (!roots.some((root) => resolved === root || isBelow(resolved, root))) {
|
|
1994
|
+
return reject(resolved, `${candidate} resolves to ${resolved}, which is outside the workspace and every scratch root (${roots.join(", ")}), so \`${segment.text}\` is ${OUT_OF_SCOPE_WRITE_CLASS}`);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
return segment;
|
|
1998
|
+
});
|
|
1999
|
+
if (notes.length === 0)
|
|
2000
|
+
return { result, notes };
|
|
2001
|
+
const classes = [];
|
|
2002
|
+
for (const segment of segments) {
|
|
2003
|
+
if (!classes.includes(segment.class))
|
|
2004
|
+
classes.push(segment.class);
|
|
2005
|
+
}
|
|
2006
|
+
return { result: { ok: true, segments, classes }, notes };
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* The classifier, its context, and all four impure refinements, in the one
|
|
847
2010
|
* order every caller must use.
|
|
848
2011
|
*
|
|
849
2012
|
* `hook classify` printing a different class from the one `hook claude-code`
|
|
850
2013
|
* decides would make the explainer a different program (APRV-108's note), and
|
|
851
|
-
* that stays true now there are
|
|
2014
|
+
* that stays true now there are four refinements in the chain.
|
|
2015
|
+
*
|
|
2016
|
+
* `readRoots` is the one argument whose ABSENCE is the loose answer rather than
|
|
2017
|
+
* the strict one (APRV-347), so it is passed explicitly at every call site: an
|
|
2018
|
+
* empty list means "do not scope reads", which is what every caller outside a
|
|
2019
|
+
* resolved gate scope wants and what this classifier did before the field
|
|
2020
|
+
* existed.
|
|
852
2021
|
*/
|
|
853
|
-
export function classifyForHook(command, protectedPaths, cwd) {
|
|
2022
|
+
export function classifyForHook(command, protectedPaths, cwd, readRoots = []) {
|
|
854
2023
|
const roots = resolveScratchRoots(cwd);
|
|
855
|
-
const classified = classifyCommand(command, protectedPaths, {
|
|
2024
|
+
const classified = classifyCommand(command, protectedPaths, {
|
|
2025
|
+
scratchRoots: roots,
|
|
2026
|
+
...(readRoots.length === 0 ? {} : { readRoots }),
|
|
2027
|
+
});
|
|
856
2028
|
const rewritten = refineRewrite(classified, cwd);
|
|
857
2029
|
const scratched = refineScratchDelete(rewritten.result, roots);
|
|
2030
|
+
const scoped = refineReadScope(scratched.result, readRoots, cwd);
|
|
2031
|
+
// APRV-402. Its roots are not `readRoots`: a read scope is a read notion and
|
|
2032
|
+
// must not become a write authorization, so this pass asks its own question
|
|
2033
|
+
// of the working directory and the scratch roots. Last in the chain because
|
|
2034
|
+
// it is the narrowest, and because nothing after it would re-read a segment
|
|
2035
|
+
// it has already tightened.
|
|
2036
|
+
const written = refineWriteScope(scoped.result, resolveWriteRoots(cwd), cwd);
|
|
858
2037
|
return {
|
|
859
|
-
result:
|
|
860
|
-
notes: [...rewritten.notes, ...scratched.notes],
|
|
2038
|
+
result: written.result,
|
|
2039
|
+
notes: [...rewritten.notes, ...scratched.notes, ...scoped.notes, ...written.notes],
|
|
861
2040
|
};
|
|
862
2041
|
}
|
|
863
2042
|
// ===========================================================================
|
|
@@ -913,7 +2092,7 @@ function commandClassify(argv, streams, cwd) {
|
|
|
913
2092
|
if (command.length === 0) {
|
|
914
2093
|
return usageError(streams, "missing <command> argument for `approval hook classify`");
|
|
915
2094
|
}
|
|
916
|
-
const { options } = hookScope(parsed.flags, cwd);
|
|
2095
|
+
const { root, options } = hookScope(parsed.flags, cwd);
|
|
917
2096
|
const load = loadPolicy(options.policy?.file === undefined
|
|
918
2097
|
? { dir: options.policy?.dir ?? cwd }
|
|
919
2098
|
: { file: options.policy.file });
|
|
@@ -922,10 +2101,13 @@ function commandClassify(argv, streams, cwd) {
|
|
|
922
2101
|
}
|
|
923
2102
|
const protectedPaths = load.ok ? (load.policy.protected_paths ?? []) : [];
|
|
924
2103
|
// The same impure refinements `hook claude-code` applies (APRV-108,
|
|
925
|
-
// APRV-267), run against the same directory
|
|
926
|
-
// pure class where the hook decides a refined one
|
|
927
|
-
// different program.
|
|
928
|
-
|
|
2104
|
+
// APRV-267, APRV-347), run against the same directory and the same roots: an
|
|
2105
|
+
// explainer that printed the pure class where the hook decides a refined one
|
|
2106
|
+
// would be explaining a different program. A policy that did not load
|
|
2107
|
+
// contributes no `read_scope`, so the scope is the built-in roots alone,
|
|
2108
|
+
// which is the narrower answer and is what the hook itself would refuse on.
|
|
2109
|
+
const readRoots = resolveReadRoots(cwd, policyRootOf(load, root), load.ok ? load.policy.read_scope?.roots : undefined);
|
|
2110
|
+
streams.out(renderClassification(classifyForHook(command, protectedPaths, cwd, readRoots).result, boolFlag(parsed.flags, "--json")));
|
|
929
2111
|
return EXIT_OK;
|
|
930
2112
|
}
|
|
931
2113
|
function sleepSync(ms) {
|
|
@@ -1053,6 +2235,132 @@ function tierOf(target, cwd) {
|
|
|
1053
2235
|
* non-protected target, which is why the loop floor could not see an Edit.
|
|
1054
2236
|
*/
|
|
1055
2237
|
const WORKSPACE_WRITE_CLASS = "files.write.workspace";
|
|
2238
|
+
/**
|
|
2239
|
+
* The Codex app-server's change set, when a call carries one (APRV-363,
|
|
2240
|
+
* APRV-379).
|
|
2241
|
+
*
|
|
2242
|
+
* TWO SHAPES, because the protocol has two. The LEGACY `applyPatchApproval`
|
|
2243
|
+
* carries `fileChanges`, a map of path to change, inline on the request. The
|
|
2244
|
+
* ITEM-BASED API puts the same material on an earlier `item/started` frame as
|
|
2245
|
+
* an ARRAY of `{path, kind, diff}`, and the bridge correlates that frame to the
|
|
2246
|
+
* approval request by item id before handing it here (APRV-379). Both arrive as
|
|
2247
|
+
* the server sent them and neither is re-rendered into the other.
|
|
2248
|
+
*
|
|
2249
|
+
* `null` for every other `apply_patch` call, which keeps the envelope path
|
|
2250
|
+
* exactly as it was: the native hook's `apply_patch` tool sends a command
|
|
2251
|
+
* string and reaches this function's `null` on the first test.
|
|
2252
|
+
*/
|
|
2253
|
+
function codexFileChanges(toolInput) {
|
|
2254
|
+
const value = toolInput["file_changes"] ?? toolInput["fileChanges"];
|
|
2255
|
+
if (value === null || typeof value !== "object")
|
|
2256
|
+
return null;
|
|
2257
|
+
if (Array.isArray(value))
|
|
2258
|
+
return value.length === 0 ? null : value;
|
|
2259
|
+
const map = value;
|
|
2260
|
+
return Object.keys(map).length === 0 ? null : map;
|
|
2261
|
+
}
|
|
2262
|
+
/**
|
|
2263
|
+
* The paths a change set names, in the order this runtime will classify them,
|
|
2264
|
+
* or `null` when an entry names none (APRV-379).
|
|
2265
|
+
*
|
|
2266
|
+
* The map's keys ARE its paths, sorted so one change set is one description
|
|
2267
|
+
* whatever key order the server used. The array's entries each carry a `path`
|
|
2268
|
+
* string, and the order is the server's own: an array is a sequence and
|
|
2269
|
+
* reordering it would be this runtime describing a change set nobody sent. An
|
|
2270
|
+
* entry that is not an object, or whose `path` is not a string, yields `null`
|
|
2271
|
+
* and the caller refuses the whole set, because a change set with one
|
|
2272
|
+
* unreadable member is a change set this runtime cannot say the extent of.
|
|
2273
|
+
*/
|
|
2274
|
+
function codexChangePaths(changes) {
|
|
2275
|
+
if (!Array.isArray(changes))
|
|
2276
|
+
return Object.keys(changes).sort();
|
|
2277
|
+
const paths = [];
|
|
2278
|
+
for (const entry of changes) {
|
|
2279
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
|
|
2280
|
+
return null;
|
|
2281
|
+
const declared = entry["path"];
|
|
2282
|
+
if (typeof declared !== "string")
|
|
2283
|
+
return null;
|
|
2284
|
+
paths.push(declared);
|
|
2285
|
+
}
|
|
2286
|
+
return paths;
|
|
2287
|
+
}
|
|
2288
|
+
/**
|
|
2289
|
+
* What a change SET asks for: one class per path it names, and the change bound
|
|
2290
|
+
* whole (APRV-363, APRV-379).
|
|
2291
|
+
*
|
|
2292
|
+
* The class rule is the file tools' rule and not a second one: a protected
|
|
2293
|
+
* target takes its derived protected class, every other target is
|
|
2294
|
+
* `files.write.workspace`, and the policy decides the autonomy of either. The
|
|
2295
|
+
* payload is the change rather than the touch, for the reason
|
|
2296
|
+
* {@link fileToolGate} states at length, plus `content_sha256` over the set as
|
|
2297
|
+
* it ARRIVED, so a human's grant binds the bytes the server sent and a later
|
|
2298
|
+
* reader can check that it did. The set goes into the payload in the shape it
|
|
2299
|
+
* arrived in, map or array; nothing here reads `diff`, `kind` or any other
|
|
2300
|
+
* member, because one observed `add` is not a licence to parse Codex patch
|
|
2301
|
+
* semantics in this repository.
|
|
2302
|
+
*
|
|
2303
|
+
* Fails closed on a path this runtime cannot place: one that lands outside the
|
|
2304
|
+
* directory the server named, or one carrying a NUL. A change whose target
|
|
2305
|
+
* cannot be resolved cannot be classified, and a classification against the
|
|
2306
|
+
* wrong tree is the failure this whole file exists to avoid.
|
|
2307
|
+
*
|
|
2308
|
+
* An ABSOLUTE path is accepted when it resolves INSIDE that directory, which
|
|
2309
|
+
* APRV-379 changed: the observed item frame names absolute paths
|
|
2310
|
+
* (`docs/codex-app-server-bridge.md`, question 1), and an absolute path inside
|
|
2311
|
+
* the directory is exactly as placeable as a relative one. Containment is what
|
|
2312
|
+
* was ever doing the work here, and it still decides: a path outside is refused
|
|
2313
|
+
* whichever way it was spelled.
|
|
2314
|
+
*/
|
|
2315
|
+
function describeCodexFileChanges(changes, protectedPaths, cwd) {
|
|
2316
|
+
const classes = [];
|
|
2317
|
+
const notes = [];
|
|
2318
|
+
const paths = [];
|
|
2319
|
+
const declaredPaths = codexChangePaths(changes);
|
|
2320
|
+
if (declaredPaths === null) {
|
|
2321
|
+
return {
|
|
2322
|
+
kind: "deny",
|
|
2323
|
+
code: "hook-io",
|
|
2324
|
+
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",
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
for (const declared of declaredPaths) {
|
|
2328
|
+
if (declared.length === 0 || declared.includes("\0")) {
|
|
2329
|
+
return {
|
|
2330
|
+
kind: "deny",
|
|
2331
|
+
code: "hook-io",
|
|
2332
|
+
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`,
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
const resolved = resolvePathSegments(cwd, declared);
|
|
2336
|
+
if (resolved !== cwd && !resolved.startsWith(`${cwd}${sep}`)) {
|
|
2337
|
+
return {
|
|
2338
|
+
kind: "deny",
|
|
2339
|
+
code: "hook-io",
|
|
2340
|
+
detail: `the file change names ${JSON.stringify(declared)}, which resolves outside ${cwd}`,
|
|
2341
|
+
};
|
|
2342
|
+
}
|
|
2343
|
+
paths.push(declared);
|
|
2344
|
+
const cls = protectedPathClass(resolved, protectedPaths) ?? WORKSPACE_WRITE_CLASS;
|
|
2345
|
+
if (!classes.includes(cls))
|
|
2346
|
+
classes.push(cls);
|
|
2347
|
+
notes.push(`change ${declared} (${cls})`);
|
|
2348
|
+
}
|
|
2349
|
+
return {
|
|
2350
|
+
kind: "gated",
|
|
2351
|
+
classes,
|
|
2352
|
+
payload: {
|
|
2353
|
+
tool: "apply_patch",
|
|
2354
|
+
rule: "codex app-server file change",
|
|
2355
|
+
cwd,
|
|
2356
|
+
paths,
|
|
2357
|
+
content_sha256: payloadHash(changes),
|
|
2358
|
+
changes,
|
|
2359
|
+
},
|
|
2360
|
+
headline: `apply_patch ${String(paths.length)} file change(s)`,
|
|
2361
|
+
notes,
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
1056
2364
|
/**
|
|
1057
2365
|
* What a non-Bash tool call asks for, or `null` when it names no file at all.
|
|
1058
2366
|
*
|
|
@@ -1149,6 +2457,95 @@ function fileToolGate(toolName, toolInput, protectedPaths, cwd) {
|
|
|
1149
2457
|
summary: summaryFor(tier, toolName, file),
|
|
1150
2458
|
};
|
|
1151
2459
|
}
|
|
2460
|
+
/**
|
|
2461
|
+
* The first entry of a `paths` array that falls outside the read scope, or
|
|
2462
|
+
* `null` when every entry is inside it (APRV-350).
|
|
2463
|
+
*
|
|
2464
|
+
* Separate from the single-path arm because the ANSWER is different: one path
|
|
2465
|
+
* either is or is not in scope, while a list is out of scope if ANY member is.
|
|
2466
|
+
* Returning the offending member rather than a boolean keeps the gated question
|
|
2467
|
+
* specific — an approver is asked about the directory that actually left the
|
|
2468
|
+
* scope, not about the whole list.
|
|
2469
|
+
*/
|
|
2470
|
+
function firstOutOfScope(toolInput, roots, cwd) {
|
|
2471
|
+
const paths = toolInput["paths"];
|
|
2472
|
+
if (!Array.isArray(paths))
|
|
2473
|
+
return null;
|
|
2474
|
+
for (const entry of paths) {
|
|
2475
|
+
if (typeof entry !== "string" || entry.trim() === "")
|
|
2476
|
+
continue;
|
|
2477
|
+
const resolved = resolvedReadTarget(entry, cwd);
|
|
2478
|
+
if (resolved === null || !isInReadScope(resolved, roots))
|
|
2479
|
+
return entry;
|
|
2480
|
+
}
|
|
2481
|
+
return null;
|
|
2482
|
+
}
|
|
2483
|
+
function readToolGate(toolName, toolInput, roots, cwd) {
|
|
2484
|
+
if (roots.length === 0)
|
|
2485
|
+
return null;
|
|
2486
|
+
const declared = readString(toolInput, "file_path") ??
|
|
2487
|
+
readString(toolInput, "notebook_path") ??
|
|
2488
|
+
readString(toolInput, "path") ??
|
|
2489
|
+
// APRV-350: Muse's `search` names an ARRAY under `paths`, and the live
|
|
2490
|
+
// capture caught one pointing clean out of the workspace. The FIRST entry
|
|
2491
|
+
// that resolves outside the scope is the one gated: a search over five
|
|
2492
|
+
// directories where one is out of scope is an out-of-scope read, and
|
|
2493
|
+
// gating the first in-scope entry instead would have let it through. An
|
|
2494
|
+
// unreadable entry counts as out of scope for the same fail-closed reason
|
|
2495
|
+
// the single-path arm below resolves that way.
|
|
2496
|
+
firstOutOfScope(toolInput, roots, cwd);
|
|
2497
|
+
if (declared === null)
|
|
2498
|
+
return null;
|
|
2499
|
+
// FAIL CLOSED on an unresolvable path (the task's AC1, and SPEC.md §11.1):
|
|
2500
|
+
// a target nothing on this disk can place is a target nothing can say is
|
|
2501
|
+
// inside the scope, so it is out of it.
|
|
2502
|
+
const resolved = resolvedReadTarget(declared, cwd);
|
|
2503
|
+
if (resolved !== null && isInReadScope(resolved, roots))
|
|
2504
|
+
return null;
|
|
2505
|
+
const file = resolved ?? absolute(declared, cwd);
|
|
2506
|
+
const input = { ...toolInput };
|
|
2507
|
+
delete input["description"];
|
|
2508
|
+
return {
|
|
2509
|
+
file,
|
|
2510
|
+
declared,
|
|
2511
|
+
// The binding bytes name the RESOLVED path as well as the tool's own input,
|
|
2512
|
+
// so an approver reads where the data actually comes from and a grant over
|
|
2513
|
+
// one spelling cannot be spent on another.
|
|
2514
|
+
payload: { tool: toolName, rule: "read-scope", file, input },
|
|
2515
|
+
summary: `${toolName} ${file} (outside the read scope)`,
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
/**
|
|
2519
|
+
* A read tool call naming a credential file (APRV-445 refutation, B2): the
|
|
2520
|
+
* Hermes home's `.env` and `auth.json`, and the approval home's vault, keys and
|
|
2521
|
+
* environment map. `null` for anything else.
|
|
2522
|
+
*/
|
|
2523
|
+
function credentialReadGate(toolName, toolInput, cwd) {
|
|
2524
|
+
const declared = readString(toolInput, "file_path") ??
|
|
2525
|
+
readString(toolInput, "notebook_path") ??
|
|
2526
|
+
readString(toolInput, "path");
|
|
2527
|
+
if (declared === null)
|
|
2528
|
+
return null;
|
|
2529
|
+
// APRV-445 recheck SF2(e, f): judged through realpath, so a symlinked
|
|
2530
|
+
// parent cannot launder the path, and a directory of credentials handed to a
|
|
2531
|
+
// read tool (a search, a glob, a grep) is gated as the credentials are.
|
|
2532
|
+
const file = canonicalPath(absolute(declared, cwd));
|
|
2533
|
+
// Recheck 3 (SF1a): only a directory that holds credentials (a home root, an
|
|
2534
|
+
// approval home, a home's `approval/`, or a directory under a home holding a
|
|
2535
|
+
// credential file); `skills/`, `workspace/` and the like are ordinary.
|
|
2536
|
+
if (!isCredentialPath(file) && !holdsCredentials(file))
|
|
2537
|
+
return null;
|
|
2538
|
+
const input = { ...toolInput };
|
|
2539
|
+
delete input["description"];
|
|
2540
|
+
return {
|
|
2541
|
+
payload: { tool: toolName, rule: "credential", file, input },
|
|
2542
|
+
summary: `${toolName} ${file} (credential)`,
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2545
|
+
/** The verdict note for a gated read: what was asked for, and against what. */
|
|
2546
|
+
function readScopeNote(gated, roots) {
|
|
2547
|
+
return `read-scope: ${gated.declared} resolves to ${gated.file}, which is outside the read scope (${renderReadRoots(roots)})`;
|
|
2548
|
+
}
|
|
1152
2549
|
/** The headline for a tier: the qualifier first, the touch after it. */
|
|
1153
2550
|
function summaryFor(tier, toolName, file) {
|
|
1154
2551
|
if (tier.worktree !== null) {
|
|
@@ -1276,6 +2673,12 @@ function abandonedRequests(run, records, now, keepHash) {
|
|
|
1276
2673
|
continue;
|
|
1277
2674
|
if (record.actor !== run.actor)
|
|
1278
2675
|
continue;
|
|
2676
|
+
// APRV-445. Under `approval serve` the hook and `propose` act as ONE actor,
|
|
2677
|
+
// and a proposal's request is harness-executed too, so without this every
|
|
2678
|
+
// proposal pending past the hook's wait plus grace (minutes) would be swept
|
|
2679
|
+
// as abandoned by the next gated tool call, days before its TTL.
|
|
2680
|
+
if (isProposalTask(record.task))
|
|
2681
|
+
continue;
|
|
1279
2682
|
const key = record.action_key;
|
|
1280
2683
|
if (typeof key !== "string" || key.length === 0)
|
|
1281
2684
|
continue;
|
|
@@ -1550,7 +2953,7 @@ function recordUnattended(run, task, classes, hash) {
|
|
|
1550
2953
|
* both serves the channels and consumes the taps. That is worth saying: on
|
|
1551
2954
|
* 2026-09-05 taps piled up unconsumed while hooks waited out their windows.
|
|
1552
2955
|
*/
|
|
1553
|
-
function announceWait(streams, run, waiting) {
|
|
2956
|
+
function announceWait(streams, run, waiting, expiryOf) {
|
|
1554
2957
|
const where = run.channels.length === 0
|
|
1555
2958
|
? "no channel (this policy configures none, so nothing is delivering the question)"
|
|
1556
2959
|
: `channel ${run.channels.join(", ")}`;
|
|
@@ -1558,7 +2961,21 @@ function announceWait(streams, run, waiting) {
|
|
|
1558
2961
|
const adopted = action.origin === "adopted"
|
|
1559
2962
|
? " The question was already open for these exact bytes, so this tool call adopts it rather than asking a second time."
|
|
1560
2963
|
: "";
|
|
1561
|
-
|
|
2964
|
+
const at = expiryOf.get(action.actionKey);
|
|
2965
|
+
// Second review pass (F7): which of the two ends first decides the sentence.
|
|
2966
|
+
// "Open for a 5m grace" beside "expires at T+240s" was two claims about one
|
|
2967
|
+
// question that could not both hold; the shorter one is the one stated.
|
|
2968
|
+
const holdsOpen = at !== undefined && windowEndsBeforeGrace(run)
|
|
2969
|
+
? `. The request expires at ${at}, which comes before the ${minutesText(run.graceMs)} retry grace would run out, so that is how long it stays open for a retry; a decision after it is refused rather than granted.`
|
|
2970
|
+
: ` and leaving the request open for a ${minutesText(run.graceMs)} retry grace.${expiryClause(at)}`;
|
|
2971
|
+
streams.err(`approval: ${action.actionKey} (${action.cls}) is waiting for a human on ${where}; a decision on the phone releases it, and this hook blocks for up to ${waitText(run)} before denying with hook-timeout${holdsOpen}${adopted}\n`);
|
|
2972
|
+
}
|
|
2973
|
+
// APRV-423, third review pass. The wait is CLAMPED to what the ceiling
|
|
2974
|
+
// leaves (see `HookRun.waitMs`), so the runtime acts rather than warns: a
|
|
2975
|
+
// wait the harness would have cut short is shortened here, and the operator
|
|
2976
|
+
// is told once so the configured --timeout is not mistaken for the wait.
|
|
2977
|
+
if (run.harnessCapMs !== null && run.waitMs < run.timeoutMs) {
|
|
2978
|
+
streams.err(`approval: this hook's ${String(run.timeoutMs)}ms --timeout is longer than the ${String(run.harnessCapMs)}ms harness ceiling leaves after the ${String(HARNESS_CAP_MARGIN_MS)}ms margin, so it waits ${String(run.waitMs)}ms instead and answers before the harness stops listening. Lower --timeout, or state the real ceiling with --harness-cap.\n`);
|
|
1562
2979
|
}
|
|
1563
2980
|
const socket = drawSocketPathFor(run.logPath);
|
|
1564
2981
|
const listener = drawSocketUsable(socket);
|
|
@@ -1567,44 +2984,68 @@ function announceWait(streams, run, waiting) {
|
|
|
1567
2984
|
streams.err(`approval: no listener is running for this log (${listener.reason}: ${socket}), so the request above may sit undelivered and a decision may go unconsumed. Start the gate's ambient runtime in the checkout that owns this log: \`eval "$(approval env)" && approval up\`, which runs the daemon loop and every configured channel in one process.\n`);
|
|
1568
2985
|
}
|
|
1569
2986
|
/**
|
|
1570
|
-
* The
|
|
1571
|
-
*
|
|
1572
|
-
* whatever verdict it printed.
|
|
1573
|
-
*
|
|
1574
|
-
* ## Requests are keyed by bytes, not by invocation (APRV-117)
|
|
1575
|
-
*
|
|
1576
|
-
* The action key is still `hook:<session>:<tool-use id>:<class>` and is still
|
|
1577
|
-
* unique per invocation — what changed is that intake LOOKS for an earlier
|
|
1578
|
-
* request about the same `{command, cwd}` before opening a new one, matching on
|
|
1579
|
-
* the `payload_hash` recorded on `approval.requested`. Three outcomes per class,
|
|
1580
|
-
* decided by `core/gate.ts`'s `findHarnessCarry`:
|
|
1581
|
-
*
|
|
1582
|
-
* - nothing to carry: register and request, exactly as before;
|
|
1583
|
-
* - a pending request: **adopt** it — wait out the remainder of this
|
|
1584
|
-
* invocation's window on somebody else's key, opening nothing. The approver's
|
|
1585
|
-
* phone never shows two prompts for one command, because there is only ever
|
|
1586
|
-
* one question;
|
|
1587
|
-
* - an unspent grant inside the TTL: **carry** it — no wait, no prompt, and
|
|
1588
|
-
* the grant is spent (once) before the allow is printed.
|
|
1589
|
-
*
|
|
1590
|
-
* ## Why the wait no longer ends in a withdrawal (APRV-106, revised)
|
|
2987
|
+
* ` The request expires at <instant> ...`, or `""` when nothing bounds it
|
|
2988
|
+
* (APRV-423).
|
|
1591
2989
|
*
|
|
1592
|
-
*
|
|
1593
|
-
*
|
|
1594
|
-
*
|
|
1595
|
-
|
|
1596
|
-
|
|
2990
|
+
* One phrasing for the three places that state the deadline — the announce
|
|
2991
|
+
* line, the timeout deny and the expiry deny — so an agent reading two of them
|
|
2992
|
+
* in one session is reading the same sentence about the same moment.
|
|
2993
|
+
*/
|
|
2994
|
+
function expiryClause(at) {
|
|
2995
|
+
if (at === undefined)
|
|
2996
|
+
return "";
|
|
2997
|
+
return ` The request expires at ${at}, after which a decision on it is refused rather than granted.`;
|
|
2998
|
+
}
|
|
2999
|
+
/**
|
|
3000
|
+
* Does the window a capped request is judged by close before the retry grace
|
|
3001
|
+
* this invocation would hold the question open for? (APRV-423, second review
|
|
3002
|
+
* pass.)
|
|
3003
|
+
*
|
|
3004
|
+
* Both are durations measured from the `approval.requested` record's own `ts`
|
|
3005
|
+
* (`harnessCappedTtlMs` for the window, `abandonedAfterMs` for the grace), so
|
|
3006
|
+
* the comparison needs no clock. `false` for an uncapped run whatever the
|
|
3007
|
+
* policy's TTL: the uncapped sentences are left byte-for-byte as APRV-287 wrote
|
|
3008
|
+
* them, which `tests/cli-hook.test.ts`'s snapshot case depends on. Computed from
|
|
3009
|
+
* this invocation's own flags, so for an ADOPTED question opened under other
|
|
3010
|
+
* flags it is the current invocation's reading of the two windows.
|
|
3011
|
+
*/
|
|
3012
|
+
/**
|
|
3013
|
+
* `55000ms wait`, or `0ms wait (a 240000ms --timeout clamped to what the 30000ms
|
|
3014
|
+
* harness ceiling leaves)` when the ceiling shortened it (APRV-423, third
|
|
3015
|
+
* review pass). One spelling for the announce line, the lagging note and the
|
|
3016
|
+
* `hook-timeout` denies, so a reader is never told a wait this process did not
|
|
3017
|
+
* make.
|
|
3018
|
+
*/
|
|
3019
|
+
function waitText(run) {
|
|
3020
|
+
if (run.waitMs === run.timeoutMs || run.harnessCapMs === null) {
|
|
3021
|
+
return `${String(run.waitMs)}ms wait`;
|
|
3022
|
+
}
|
|
3023
|
+
return `${String(run.waitMs)}ms wait (a ${String(run.timeoutMs)}ms --timeout clamped to what the ${String(run.harnessCapMs)}ms harness ceiling leaves)`;
|
|
3024
|
+
}
|
|
3025
|
+
function windowEndsBeforeGrace(run) {
|
|
3026
|
+
if (run.harnessCapMs === null)
|
|
3027
|
+
return false;
|
|
3028
|
+
const window = harnessCappedTtlMs(run.ttlMs, run.harnessCapMs);
|
|
3029
|
+
return window !== null && window <= abandonedAfterMs(run.timeoutMs, run.graceMs);
|
|
3030
|
+
}
|
|
3031
|
+
/** ` (expired at <instant>)`, or `""`. The parenthetical form of the above. */
|
|
3032
|
+
function expiryAt(at) {
|
|
3033
|
+
return at === undefined ? "" : ` (expired at ${at})`;
|
|
3034
|
+
}
|
|
3035
|
+
/**
|
|
3036
|
+
* The hook's own rendering of a verdict: one decision object on stdout, in the
|
|
3037
|
+
* dialect of the harness that asked.
|
|
1597
3038
|
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
1601
|
-
* any retry, so the classes already opened are noise in a human's queue). The
|
|
1602
|
-
* signal handlers are installed for the duration of the wait ONLY, and removed
|
|
1603
|
-
* in `finally`: a hook process is short-lived and borrowing the harness's
|
|
1604
|
-
* signal disposition for longer than the loop would be a side effect nobody
|
|
1605
|
-
* asked for.
|
|
3039
|
+
* The one place a verdict becomes bytes, so the Codex guard inside
|
|
3040
|
+
* {@link allow} — an allow that lost its exact bound `tool_input.command` is an
|
|
3041
|
+
* I/O failure rather than a permission — still stands over every path.
|
|
1606
3042
|
*/
|
|
1607
|
-
function
|
|
3043
|
+
function renderVerdict(streams, adapter, codexCommand, verdict) {
|
|
3044
|
+
return verdict.permission === "allow"
|
|
3045
|
+
? allow(streams, verdict.reason, adapter.kind, codexCommand)
|
|
3046
|
+
: deny(streams, verdict.code, verdict.detail, adapter.kind);
|
|
3047
|
+
}
|
|
3048
|
+
export function gateHarnessCall(streams, run, classes,
|
|
1608
3049
|
/**
|
|
1609
3050
|
* The bytes the grant binds to: `{command, cwd}` for a Bash call, the change
|
|
1610
3051
|
* itself for a file tool (APRV-124). Whatever this is, it is what reaches the
|
|
@@ -1655,18 +3096,22 @@ floor = null) {
|
|
|
1655
3096
|
const floorApplies = (cls) => floor !== null && isSideEffectingClass(cls);
|
|
1656
3097
|
const hash = payloadHash(payload);
|
|
1657
3098
|
const summary = truncate(headline, SUMMARY_LIMIT);
|
|
1658
|
-
const sayAllow = (reason) => allow
|
|
3099
|
+
const sayAllow = (reason) => ({ permission: "allow", reason });
|
|
1659
3100
|
/**
|
|
1660
|
-
* Every deny this function can
|
|
3101
|
+
* Every deny this function can reach, with the floor's own sentence appended
|
|
1661
3102
|
* when a floor is what routed the command here (APRV-280). One wrapper rather
|
|
1662
3103
|
* than a sentence bolted onto the timeout alone: a floored invocation that
|
|
1663
3104
|
* ends in a rejection, a lapse or an I/O fault leaves the agent in exactly the
|
|
1664
3105
|
* same place, and the operator reading the harness's error stream needs the
|
|
1665
3106
|
* scope key either way.
|
|
1666
3107
|
*/
|
|
1667
|
-
const sayDeny = (code, detail) =>
|
|
1668
|
-
|
|
1669
|
-
|
|
3108
|
+
const sayDeny = (code, detail) => ({
|
|
3109
|
+
permission: "deny",
|
|
3110
|
+
code,
|
|
3111
|
+
detail: floor === null
|
|
3112
|
+
? detail
|
|
3113
|
+
: `${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)}`,
|
|
3114
|
+
});
|
|
1670
3115
|
// Intake reads the VERIFIED log, once, before anything is written: an
|
|
1671
3116
|
// enforcement path reads nothing else (SPEC.md §11.1), and a carry decided
|
|
1672
3117
|
// from unverified bytes would be a grant invented by whoever could write the
|
|
@@ -1685,7 +3130,12 @@ floor = null) {
|
|
|
1685
3130
|
streams.err(`approval: withdrew ${String(swept.length)} abandoned harness request(s) nothing retried (${swept.join(", ")}); a tap on one of them now authorizes nothing and the channel says so\n`);
|
|
1686
3131
|
}
|
|
1687
3132
|
const actions = classes.map((cls) => {
|
|
1688
|
-
|
|
3133
|
+
// APRV-410: the window this invocation is configured for bounds which
|
|
3134
|
+
// GRANT it may carry, so an answer that landed after the asking process
|
|
3135
|
+
// was gone is not adopted by a later one. The sweep above takes back the
|
|
3136
|
+
// pending questions; this refuses the grants that were written on questions
|
|
3137
|
+
// nobody was holding. The two halves are the same fact from both sides.
|
|
3138
|
+
const carry = findHarnessCarry(intake.records, hash, cls, intakeTs, run.ttlMs, abandonedAfterMs(run.timeoutMs, run.graceMs));
|
|
1689
3139
|
if (carry === null)
|
|
1690
3140
|
return { cls, actionKey: `${task}:${cls}`, origin: "new" };
|
|
1691
3141
|
return {
|
|
@@ -1697,6 +3147,62 @@ floor = null) {
|
|
|
1697
3147
|
const fresh = actions.filter((action) => action.origin === "new");
|
|
1698
3148
|
const adopted = actions.filter((action) => action.origin === "adopted");
|
|
1699
3149
|
const carried = actions.filter((action) => action.origin === "carried");
|
|
3150
|
+
/**
|
|
3151
|
+
* The window every request this invocation opens will be judged by
|
|
3152
|
+
* (APRV-423): the policy's TTL, narrowed by the harness ceiling this process
|
|
3153
|
+
* runs under. `null` means nothing bounds it, which is a policy with no
|
|
3154
|
+
* `approval_ttl` under a harness that documents no maximum and an operator
|
|
3155
|
+
* who stated none.
|
|
3156
|
+
*/
|
|
3157
|
+
const ownTtlMs = harnessCappedTtlMs(run.ttlMs, run.harnessCapMs);
|
|
3158
|
+
/**
|
|
3159
|
+
* When each waited-on key lapses, ISO-8601, for the lines this invocation
|
|
3160
|
+
* prints. Filled from the runtime's OWN write-boundary timestamps — the `ts`
|
|
3161
|
+
* on the record `request` returned, and the request record intake already
|
|
3162
|
+
* read for an adopted key — so a deny that names a deadline names the one the
|
|
3163
|
+
* gate will judge by, and no second read of the log pays for the sentence.
|
|
3164
|
+
*
|
|
3165
|
+
* CAPPED REQUESTS ONLY (APRV-423). The sentence exists to state a deadline
|
|
3166
|
+
* its reader cannot work out from the policy: under a harness ceiling the
|
|
3167
|
+
* window is neither `approval_ttl` nor the harness's own timeout, and an
|
|
3168
|
+
* agent told "it lapsed" with no instant has no way to tell a four-minute
|
|
3169
|
+
* window from the hour its policy declares. A request the policy alone bounds
|
|
3170
|
+
* already has its deadline stated where its reader can see it (the approver's
|
|
3171
|
+
* own card, `approval status`, the queue), so the hook's lines are left
|
|
3172
|
+
* exactly as APRV-281 and APRV-287 wrote them — which also keeps every
|
|
3173
|
+
* uncapped deny byte-for-byte reproducible across two invocations, the
|
|
3174
|
+
* property `tests/cli-hook.test.ts` pins when it compares a cold read's
|
|
3175
|
+
* verdict with a snapshot-resumed one.
|
|
3176
|
+
*/
|
|
3177
|
+
const expiryOf = new Map();
|
|
3178
|
+
// APRV-423, and the last thing checked before this invocation opens a
|
|
3179
|
+
// question. A ceiling at or below the margin leaves no window: the request
|
|
3180
|
+
// would have to lapse before the harness kills this process, and there is no
|
|
3181
|
+
// "before" left. Refused rather than widened, because a hook that ignored its
|
|
3182
|
+
// own ceiling is a hook whose question outlives its asker, which is the whole
|
|
3183
|
+
// failure this cap exists to end (APRV-410). Placed here rather than at the
|
|
3184
|
+
// top of the function so it refuses exactly the act it is about: an ADOPTED
|
|
3185
|
+
// question was opened by a process that cleared this check, and a CARRIED
|
|
3186
|
+
// grant is a human's answer that already exists.
|
|
3187
|
+
if (fresh.length > 0 && run.harnessCapMs !== null && !harnessCapFitsMargin(run.harnessCapMs)) {
|
|
3188
|
+
// Second review pass: the deny names the actions it refused for (so a reader
|
|
3189
|
+
// can still see which class routed this call to a human), says whether the
|
|
3190
|
+
// ceiling was stated or assumed, and ends with the harness's own repair
|
|
3191
|
+
// where the adapter has one.
|
|
3192
|
+
const needing = fresh.map((action) => `${action.actionKey} (${action.cls})`).join(", ");
|
|
3193
|
+
const origin = run.harnessCapStated
|
|
3194
|
+
? "stated with --harness-cap"
|
|
3195
|
+
: "assumed from the harness's documented defaults because no --harness-cap was passed";
|
|
3196
|
+
// APRV-427 review: where time spent before this invocation was charged,
|
|
3197
|
+
// the operator's number is named as theirs and the remainder as what is
|
|
3198
|
+
// left of it, rather than reporting the remainder as what they stated.
|
|
3199
|
+
const source = run.harnessCapCeilingMs === null
|
|
3200
|
+
? origin
|
|
3201
|
+
: `~${String(run.harnessCapMs)}ms left of the ${String(run.harnessCapCeilingMs)}ms ceiling ${origin}; the rest was spent before this hook began, waiting in approval serve's queue or for its store lock`;
|
|
3202
|
+
const repair = run.harnessCapRepair ??
|
|
3203
|
+
`Raise the harness entry's timeout past ${String(HARNESS_CAP_MARGIN_MS)}ms and state the real one with --harness-cap, or route this class somewhere a human is not on the critical path.`;
|
|
3204
|
+
return sayDeny("hook-harness-cap-too-short", `${needing} would need a human, but this hook runs under a ${String(run.harnessCapMs)}ms harness ceiling (${source}), and a request must lapse at least ${String(HARNESS_CAP_MARGIN_MS)}ms before that ceiling so the runtime records the expiry while the harness is still listening. That leaves no window in which a human could answer, so nothing was registered, nothing was requested and no question reached a phone. ${repair}`);
|
|
3205
|
+
}
|
|
1700
3206
|
// Only the classes that need a new question are registered. A retry whose
|
|
1701
3207
|
// every class carries or adopts registers no task at all — the envelope it
|
|
1702
3208
|
// would declare already exists, under the key it is about to wait on.
|
|
@@ -1745,6 +3251,12 @@ floor = null) {
|
|
|
1745
3251
|
payload_hash: hash,
|
|
1746
3252
|
payload: { value: payload },
|
|
1747
3253
|
execution: "harness",
|
|
3254
|
+
// APRV-423. The ceiling this process runs under, recorded so the gate,
|
|
3255
|
+
// the daemon's sweep and every channel judge the question by a window
|
|
3256
|
+
// that ends before the harness kills the asker. Omitted entirely when
|
|
3257
|
+
// there is no ceiling to state: an absent field is the pre-APRV-423
|
|
3258
|
+
// shape, and the policy's TTL governs alone.
|
|
3259
|
+
...(run.harnessCapMs === null ? {} : { harnessCapMs: run.harnessCapMs }),
|
|
1748
3260
|
...(floorApplies(action.cls) ? { loopFloor: true } : {}),
|
|
1749
3261
|
}, run.actor, run.options);
|
|
1750
3262
|
if (!result.ok) {
|
|
@@ -1754,8 +3266,31 @@ floor = null) {
|
|
|
1754
3266
|
withdrawPending(run, streams, ownKeys, `intake refused ${action.actionKey}; this command cannot proceed, so the classes already opened for it are questions nobody needs to answer`);
|
|
1755
3267
|
return sayDeny(`hook-gate-refused:${result.code}`, result.message);
|
|
1756
3268
|
}
|
|
1757
|
-
if (result.record !== null)
|
|
3269
|
+
if (result.record !== null) {
|
|
1758
3270
|
ownKeys.push(action.actionKey);
|
|
3271
|
+
// The deadline in words, from the `ts` the runtime stamped on the record
|
|
3272
|
+
// it just wrote. Never from this process's own clock: a gate-typed event
|
|
3273
|
+
// carries the write boundary's instant (SPEC.md §11.1 invariant 2), and a
|
|
3274
|
+
// deny that named a deadline measured from anything else would be naming
|
|
3275
|
+
// a moment the gate does not judge by.
|
|
3276
|
+
if (ownTtlMs !== null && run.harnessCapMs !== null) {
|
|
3277
|
+
const lapses = Date.parse(result.record.ts);
|
|
3278
|
+
if (!Number.isNaN(lapses)) {
|
|
3279
|
+
expiryOf.set(action.actionKey, new Date(lapses + ownTtlMs).toISOString());
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
// An adopted question was opened by an earlier invocation, under whatever
|
|
3285
|
+
// ceiling THAT one ran under, so its deadline is read off the request record
|
|
3286
|
+
// intake already verified rather than recomputed from this process's flags.
|
|
3287
|
+
for (const action of adopted) {
|
|
3288
|
+
const derivation = requestState(intake.records, action.actionKey, intakeTs, run.ttlMs);
|
|
3289
|
+
if (derivation.declared.harness_cap_ms === null)
|
|
3290
|
+
continue;
|
|
3291
|
+
const at = requestExpiresAt(derivation);
|
|
3292
|
+
if (at !== null)
|
|
3293
|
+
expiryOf.set(action.actionKey, at);
|
|
1759
3294
|
}
|
|
1760
3295
|
/** Every key that must be granted before this hook says yes. */
|
|
1761
3296
|
const waitKeys = [...adopted.map((action) => action.actionKey), ...ownKeys];
|
|
@@ -1795,21 +3330,50 @@ floor = null) {
|
|
|
1795
3330
|
// an earlier tool call, which wait in the same silence and were the case the
|
|
1796
3331
|
// announce would most easily have missed. A carried grant is not here because
|
|
1797
3332
|
// nothing is waiting on it.
|
|
1798
|
-
announceWait(streams, run, actions.filter((action) => waitKeys.includes(action.actionKey)));
|
|
1799
|
-
const deadline = Date.now() + run.
|
|
3333
|
+
announceWait(streams, run, actions.filter((action) => waitKeys.includes(action.actionKey)), expiryOf);
|
|
3334
|
+
const deadline = Date.now() + run.waitMs;
|
|
3335
|
+
// APRV-427. From the first poll to the last this invocation only READS the
|
|
3336
|
+
// verified log and sleeps, so an embedding caller (`approval serve`) may let
|
|
3337
|
+
// other work on the same store run meanwhile. `leaveWait` hands the stretch
|
|
3338
|
+
// back before anything below appends, and once more whichever way the loop
|
|
3339
|
+
// is left; it is a no-op from the CLI, where there is no seam.
|
|
3340
|
+
let waiting = false;
|
|
3341
|
+
const leaveWait = () => {
|
|
3342
|
+
if (!waiting)
|
|
3343
|
+
return;
|
|
3344
|
+
waiting = false;
|
|
3345
|
+
run.waitSeam?.leaveWait();
|
|
3346
|
+
};
|
|
1800
3347
|
// A signal arriving mid-wait means the session is going away: nothing will
|
|
1801
3348
|
// retry this command, so the question this invocation opened is retracted.
|
|
1802
3349
|
// `process.exit` is deliberate and immediate: the default disposition for
|
|
1803
3350
|
// these signals is to die, and a handler that only withdrew would leave the
|
|
1804
3351
|
// hook wedged in its poll loop with the harness waiting on it.
|
|
1805
3352
|
const onSignal = (signal) => {
|
|
3353
|
+
leaveWait();
|
|
1806
3354
|
withdrawPending(run, streams, ownKeys, `the requesting hook process received ${signal} while waiting; the session is ending, so no retry will adopt this request`);
|
|
3355
|
+
// APRV-445. On Hermes an exit with nothing on stdout is an ALLOW unless the
|
|
3356
|
+
// exit is 2, and a harness tearing down may still read the verdict. So the
|
|
3357
|
+
// block directive goes out first, written synchronously because
|
|
3358
|
+
// `process.exit` below does not wait for a stream to drain.
|
|
3359
|
+
if (run.harness === "hermes") {
|
|
3360
|
+
const directive = harnessBlockDirective("hook-interrupted", `the hook received ${signal} while waiting for a decision; nothing authorizes this call`, "hermes");
|
|
3361
|
+
try {
|
|
3362
|
+
writeSync(1, directive.stdout);
|
|
3363
|
+
}
|
|
3364
|
+
catch {
|
|
3365
|
+
// stdout is gone; the exit code below is the whole verdict.
|
|
3366
|
+
}
|
|
3367
|
+
process.exit(HERMES_DENY_EXIT);
|
|
3368
|
+
}
|
|
1807
3369
|
process.exit(EXIT_USAGE);
|
|
1808
3370
|
};
|
|
1809
3371
|
const onTerm = () => onSignal("SIGTERM");
|
|
1810
3372
|
const onInt = () => onSignal("SIGINT");
|
|
1811
|
-
|
|
1812
|
-
|
|
3373
|
+
// Prepended: under Hermes an earlier guard (`hermesFailClosed`) listens for
|
|
3374
|
+
// the same signals, and this handler, which also withdraws, must answer first.
|
|
3375
|
+
process.prependListener("SIGTERM", onTerm);
|
|
3376
|
+
process.prependListener("SIGINT", onInt);
|
|
1813
3377
|
/**
|
|
1814
3378
|
* Has this invocation already said, on stderr, that the verified view lags
|
|
1815
3379
|
* the requests it is waiting on (APRV-294)? Said once per invocation: the
|
|
@@ -1817,22 +3381,96 @@ floor = null) {
|
|
|
1817
3381
|
* matters under sixty copies of itself.
|
|
1818
3382
|
*/
|
|
1819
3383
|
let saidLagging = false;
|
|
3384
|
+
/** Consecutive torn-tail reads, for {@link TORN_TAIL_RETRIES}. */
|
|
3385
|
+
let tornReads = 0;
|
|
3386
|
+
/** The last whole verified view this wait read, for a torn read at the deadline. */
|
|
3387
|
+
let lastClean = null;
|
|
3388
|
+
const callerGone = () => run.waitSeam?.cancelled?.() === true;
|
|
3389
|
+
/** The verdict for a caller that went away: nothing spent, nothing withdrawn. */
|
|
3390
|
+
const sayGone = () => sayDeny("hook-timeout", `the caller that asked went away before ${waitKeys.join(", ")} could be answered to it. NOTHING WAS SPENT AND NOTHING WAS WITHDRAWN: the question, or a grant already recorded on it, is left for a retry of this exact command in this exact directory to adopt within the ${minutesText(run.graceMs)} retry grace.`);
|
|
3391
|
+
const pollRead = run.waitSeam?.pollRead ?? ((path) => readVerifiedRecords(path));
|
|
3392
|
+
/**
|
|
3393
|
+
* The deny for a wait that ran out with the question left OPEN, after
|
|
3394
|
+
* `lead` (APRV-427 review: one builder, so every route to it says the same
|
|
3395
|
+
* thing). Two forms, as the second review pass (F7) fixed them: where the
|
|
3396
|
+
* capped window closes before the retry grace would, the sentence names the
|
|
3397
|
+
* expiry as what bounds the question; otherwise it names the grace and adds
|
|
3398
|
+
* the expiry where there is one.
|
|
3399
|
+
*/
|
|
3400
|
+
const keptOpen = (lead, stillLagging) => {
|
|
3401
|
+
// APRV-423. The deadline the still-open question is under, named in the
|
|
3402
|
+
// same breath as the wait that ran out: the two are different numbers and
|
|
3403
|
+
// the second one is the one that decides whether a retry can still adopt
|
|
3404
|
+
// this question. Under a harness ceiling it is also the instant the runtime
|
|
3405
|
+
// will append `approval.expired` at, which is what keeps this deny and that
|
|
3406
|
+
// record from disagreeing about one request.
|
|
3407
|
+
const deadlines = waitKeys
|
|
3408
|
+
.map((key) => expiryOf.get(key))
|
|
3409
|
+
.filter((at) => at !== undefined);
|
|
3410
|
+
const expiresAt = [...new Set(deadlines)].sort().join(", ");
|
|
3411
|
+
if (deadlines.length > 0 && windowEndsBeforeGrace(run)) {
|
|
3412
|
+
return sayDeny("hook-timeout", `${lead} This tool call is denied and NOTHING WAS WITHDRAWN: the harness ceiling this hook runs under bounds the question, and the request(s) expire at ${expiresAt}, which comes before the ${minutesText(run.graceMs)} retry grace would run out, so that is how long they stay open. A decision inside that window authorizes a retry of this exact command in this exact directory, once; retry it after the approver answers, and the retry adopts the same question rather than asking a second one. A decision after ${expiresAt} is refused rather than granted, the runtime records the lapse as approval.expired, and a retry then asks the question fresh.${stillLagging}`);
|
|
3413
|
+
}
|
|
3414
|
+
const expiresText = deadlines.length === 0
|
|
3415
|
+
? ""
|
|
3416
|
+
: ` The harness ceiling this hook runs under bounds the question too: the request(s) expire at ${expiresAt}, and a decision after that is refused rather than granted — the runtime records the lapse as approval.expired.`;
|
|
3417
|
+
return sayDeny("hook-timeout", `${lead} This tool call is denied and NOTHING WAS WITHDRAWN: the request(s) stay open for the ${minutesText(run.graceMs)} retry grace, and a decision inside that window authorizes a retry of this exact command in this exact directory, once.${expiresText} Retry it after the approver answers; the retry adopts the same question rather than asking a second one. Past the grace the question is taken back (approval.withdrawn, reason timeout) BY THE NEXT GATED TOOL CALL THIS ACTOR (${run.actor}) MAKES, which is the only process that may: a withdrawal is the requester's own (APRV-106), so no daemon and no channel can do it for you. Until that next call the request stays pending, and a decision that lands past the grace is recorded as a grant — it authorizes nothing, because a retry past the grace asks again rather than carrying a grant nobody was holding (APRV-410).${stillLagging}`);
|
|
3418
|
+
};
|
|
3419
|
+
run.waitSeam?.enterWait();
|
|
3420
|
+
waiting = true;
|
|
1820
3421
|
try {
|
|
1821
3422
|
for (;;) {
|
|
1822
|
-
|
|
3423
|
+
if (callerGone())
|
|
3424
|
+
return sayGone();
|
|
3425
|
+
let read = pollRead(run.logPath);
|
|
3426
|
+
if (!read.ok && read.code === "log-torn-tail" && tornReads < TORN_TAIL_RETRIES) {
|
|
3427
|
+
if (Date.now() < deadline) {
|
|
3428
|
+
// Another writer's line, caught half-landed. Read again next tick;
|
|
3429
|
+
// see TORN_TAIL_RETRIES. Said once, like the lagging view below.
|
|
3430
|
+
if (tornReads === 0) {
|
|
3431
|
+
streams.err(`approval: the log's last line was incomplete when this hook read it (another writer mid-append); reading again rather than giving up on ${task}\n`);
|
|
3432
|
+
}
|
|
3433
|
+
tornReads += 1;
|
|
3434
|
+
sleepSync(Math.min(run.intervalMs, Math.max(0, deadline - Date.now())));
|
|
3435
|
+
continue;
|
|
3436
|
+
}
|
|
3437
|
+
// AT the deadline (APRV-427 review). A clean read at this tick would
|
|
3438
|
+
// take the timeout path and leave the question open for the retry
|
|
3439
|
+
// grace; someone else's half-written line must not turn that into a
|
|
3440
|
+
// withdrawal. So the tick is judged by the last whole view this wait
|
|
3441
|
+
// read, and the timeout path below runs on it. Only a tail that stays
|
|
3442
|
+
// torn past the retries, a write nobody is going to finish, still
|
|
3443
|
+
// withdraws and denies.
|
|
3444
|
+
if (lastClean === null) {
|
|
3445
|
+
return keptOpen(`no decision on ${waitKeys.join(", ")} within the hook's ${waitText(run)}, and the log's last line was incomplete when the wait ended (another writer mid-append).`, "");
|
|
3446
|
+
}
|
|
3447
|
+
read = lastClean;
|
|
3448
|
+
}
|
|
1823
3449
|
if (!read.ok) {
|
|
3450
|
+
leaveWait();
|
|
1824
3451
|
withdrawPending(run, streams, ownKeys, `the hook could not read the log while waiting on ${task}`);
|
|
1825
3452
|
return sayDeny("hook-io", read.message);
|
|
1826
3453
|
}
|
|
3454
|
+
tornReads = 0;
|
|
3455
|
+
lastClean = read;
|
|
1827
3456
|
const ts = new Date().toISOString();
|
|
1828
3457
|
// Only the keys this invocation is waiting on count. Deriving the set
|
|
1829
3458
|
// from the log again would let an empty or foreign result read as
|
|
1830
3459
|
// "nothing pending" and fall through to allow; the verified log must show
|
|
1831
3460
|
// every one of these keys granted before the hook says yes.
|
|
1832
|
-
const derived = waitKeys.map((key) =>
|
|
1833
|
-
key,
|
|
1834
|
-
|
|
1835
|
-
|
|
3461
|
+
const derived = waitKeys.map((key) => {
|
|
3462
|
+
const derivation = requestState(read.records, key, ts, run.ttlMs);
|
|
3463
|
+
// APRV-423: the deadline this read judged by, kept beside the state so
|
|
3464
|
+
// the verdict and the sentence that explains it come from one
|
|
3465
|
+
// derivation. A key whose record this view does not carry yet answers
|
|
3466
|
+
// `null`, and the lagging branch below is what handles that.
|
|
3467
|
+
if (derivation.declared.harness_cap_ms !== null) {
|
|
3468
|
+
const at = requestExpiresAt(derivation);
|
|
3469
|
+
if (at !== null)
|
|
3470
|
+
expiryOf.set(key, at);
|
|
3471
|
+
}
|
|
3472
|
+
return { key, state: derivation.state };
|
|
3473
|
+
});
|
|
1836
3474
|
const states = derived.map((entry) => entry.state);
|
|
1837
3475
|
/**
|
|
1838
3476
|
* Keys this process ESTABLISHED exist, that this read does not carry
|
|
@@ -1866,7 +3504,7 @@ floor = null) {
|
|
|
1866
3504
|
.map((entry) => entry.key);
|
|
1867
3505
|
if (lagging.length > 0 && !saidLagging) {
|
|
1868
3506
|
saidLagging = true;
|
|
1869
|
-
streams.err(`approval: the verified log does not yet carry ${lagging.join(", ")} (verified head: ${read.head === null ? "empty" : `seq ${String(read.head.seq)}`}). The request(s) were appended by this hook, so this is a view that lags rather than a decision; the hook keeps waiting for the verification to catch up, up to its ${
|
|
3507
|
+
streams.err(`approval: the verified log does not yet carry ${lagging.join(", ")} (verified head: ${read.head === null ? "empty" : `seq ${String(read.head.seq)}`}). The request(s) were appended by this hook, so this is a view that lags rather than a decision; the hook keeps waiting for the verification to catch up, up to its ${waitText(run)}. A sync or a daemon restart in the last minute is the usual cause (docs/claude-code-hook.md).\n`);
|
|
1870
3508
|
}
|
|
1871
3509
|
if (!states.includes("requested") && lagging.length === 0) {
|
|
1872
3510
|
// Precedence, as `approval wait` fixes it: a human's "no" outranks a
|
|
@@ -1883,11 +3521,27 @@ floor = null) {
|
|
|
1883
3521
|
return sayDeny("hook-withdrawn", `the request for ${task} was withdrawn before a decision; nothing is pending and nothing was authorized`);
|
|
1884
3522
|
}
|
|
1885
3523
|
if (states.includes("expired")) {
|
|
1886
|
-
|
|
3524
|
+
// APRV-423. WHICH deadline it lapsed at, because under a harness
|
|
3525
|
+
// ceiling that is not the policy's TTL and an agent told only "it
|
|
3526
|
+
// lapsed" would read a four-minute window as an hour-long one that
|
|
3527
|
+
// somehow failed. The runtime, not this hook, appends the
|
|
3528
|
+
// `approval.expired` record: `decide` materialises it the moment a
|
|
3529
|
+
// late tap is refused, and the daemon's sweep materialises it on its
|
|
3530
|
+
// own cadence. A hook never writes one.
|
|
3531
|
+
const lapsed = derived
|
|
3532
|
+
.filter((entry) => entry.state === "expired")
|
|
3533
|
+
.map((entry) => `${entry.key}${expiryAt(expiryOf.get(entry.key))}`);
|
|
3534
|
+
return sayDeny("hook-expired", `the request for ${task} lapsed before a decision (${lapsed.join(", ")}). A decision arriving now is refused rather than granted, and the runtime records the lapse as approval.expired; run the command again to ask the question fresh.`);
|
|
1887
3535
|
}
|
|
1888
3536
|
if (states.every((state) => state === "granted")) {
|
|
1889
3537
|
// The grants are spent before the allow is printed, so this exact
|
|
1890
3538
|
// command cannot ride the same authorization twice.
|
|
3539
|
+
leaveWait();
|
|
3540
|
+
// Looked at again now the lock is held: re-taking it may have waited,
|
|
3541
|
+
// and a caller that left meanwhile must not have its grant spent on a
|
|
3542
|
+
// verdict nobody will receive. The retry is who spends it.
|
|
3543
|
+
if (callerGone())
|
|
3544
|
+
return sayGone();
|
|
1891
3545
|
const failed = consumeGrants(run, spendKeys, hash, task);
|
|
1892
3546
|
if (failed !== null) {
|
|
1893
3547
|
return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
|
|
@@ -1917,6 +3571,7 @@ floor = null) {
|
|
|
1917
3571
|
// Past the grace nobody is coming back for it, and a question nothing
|
|
1918
3572
|
// will adopt is taken back rather than left for a restarted listener to
|
|
1919
3573
|
// re-deliver.
|
|
3574
|
+
leaveWait();
|
|
1920
3575
|
const withdrawn = withdrawAbandoned(run, streams, read.records, ts, null, ownKeys);
|
|
1921
3576
|
// APRV-294: a wait that ends with the view still short of its own
|
|
1922
3577
|
// requests says so. The deny is the same deny — the wait ran out — and
|
|
@@ -1927,9 +3582,9 @@ floor = null) {
|
|
|
1927
3582
|
? ""
|
|
1928
3583
|
: ` The verified view still does not carry ${lagging.join(", ")}, which this hook appended: the request(s) exist and the view is behind, so check the log that owns them (\`approval log verify\`, \`approval status\`) rather than reading this as an unanswered question.`;
|
|
1929
3584
|
if (withdrawn.length > 0) {
|
|
1930
|
-
return sayDeny("hook-timeout", `no decision on ${waitKeys.join(", ")} within the hook's ${
|
|
3585
|
+
return sayDeny("hook-timeout", `no decision on ${waitKeys.join(", ")} within the hook's ${waitText(run)}, and the ${minutesText(run.graceMs)} retry grace has run out: ${withdrawn.join(", ")} WAS WITHDRAWN (reason timeout). A tap on it now authorizes nothing and the channel says so. Run the command again to ask the question fresh.${stillLagging}`);
|
|
1931
3586
|
}
|
|
1932
|
-
return
|
|
3587
|
+
return keptOpen(`no decision on ${waitKeys.join(", ")} within the hook's ${waitText(run)}.`, stillLagging);
|
|
1933
3588
|
}
|
|
1934
3589
|
sleepSync(Math.min(run.intervalMs, Math.max(0, deadline - Date.now())));
|
|
1935
3590
|
}
|
|
@@ -1939,10 +3594,12 @@ floor = null) {
|
|
|
1939
3594
|
// deny. Unlike the timeout, this process cannot say what state it left
|
|
1940
3595
|
// behind, so the question it opened is retracted rather than left standing
|
|
1941
3596
|
// on a failure nobody diagnosed.
|
|
3597
|
+
leaveWait();
|
|
1942
3598
|
withdrawPending(run, streams, ownKeys, `the requesting hook process failed while waiting (${cause instanceof Error ? cause.message : String(cause)})`);
|
|
1943
3599
|
throw cause;
|
|
1944
3600
|
}
|
|
1945
3601
|
finally {
|
|
3602
|
+
leaveWait();
|
|
1946
3603
|
process.off("SIGTERM", onTerm);
|
|
1947
3604
|
process.off("SIGINT", onInt);
|
|
1948
3605
|
}
|
|
@@ -2093,9 +3750,143 @@ function report(streams, code, detail, extra = {}) {
|
|
|
2093
3750
|
* whether two enumerated fields are present and what kind of value they hold.
|
|
2094
3751
|
* No text from any of them reaches the log or this function's return.
|
|
2095
3752
|
*/
|
|
3753
|
+
/**
|
|
3754
|
+
* Muse's outcome, which it actually sends (APRV-350).
|
|
3755
|
+
*
|
|
3756
|
+
* The shell tool's `tool_response` is a JSON STRING, not an object, carrying
|
|
3757
|
+
* `exit_code`, `terminal_status`, `output` and `truncated`. The generic reader
|
|
3758
|
+
* above would see a string, find neither `interrupted` nor `error` on it, and
|
|
3759
|
+
* call every command completed — including the ones that failed. So Muse gets
|
|
3760
|
+
* its own reading, and it is the richer one: this harness sends both facts
|
|
3761
|
+
* Codex lacks.
|
|
3762
|
+
*
|
|
3763
|
+
* `terminal_status` leads because it distinguishes a command that ran from one
|
|
3764
|
+
* that was killed or timed out; `exit_code` decides the rest. Anything this
|
|
3765
|
+
* cannot read is UNREADABLE rather than assumed complete, which appends nothing
|
|
3766
|
+
* and leaves the path as vacuous as it was — the same choice the generic reader
|
|
3767
|
+
* makes, for the same reason. Nothing of `output` is read.
|
|
3768
|
+
*/
|
|
3769
|
+
function readMuseReportedOutcome(input) {
|
|
3770
|
+
const raw = input.toolResponseRaw;
|
|
3771
|
+
// The file tools answer with an object (`filePath`, `structuredPatch`) and
|
|
3772
|
+
// the read tools with a plain string of file content. Neither carries an
|
|
3773
|
+
// outcome, and the event name is the only fact available for them.
|
|
3774
|
+
if (typeof raw !== "string") {
|
|
3775
|
+
return { ok: true, outcome: "completed" };
|
|
3776
|
+
}
|
|
3777
|
+
let body;
|
|
3778
|
+
try {
|
|
3779
|
+
body = JSON.parse(raw);
|
|
3780
|
+
}
|
|
3781
|
+
catch {
|
|
3782
|
+
// A non-JSON string is a read tool's content. It says nothing about
|
|
3783
|
+
// success, and the event fired at all, so the event name stands.
|
|
3784
|
+
return { ok: true, outcome: "completed" };
|
|
3785
|
+
}
|
|
3786
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
3787
|
+
return { ok: true, outcome: "completed" };
|
|
3788
|
+
}
|
|
3789
|
+
const fields = body;
|
|
3790
|
+
const status = fields["terminal_status"];
|
|
3791
|
+
if (typeof status === "string" && status !== "completed") {
|
|
3792
|
+
// `aborted`, `timed_out`, and whatever else Meta adds: a command that did
|
|
3793
|
+
// not finish on its own terms neither completed nor failed, exactly as an
|
|
3794
|
+
// interrupt does not.
|
|
3795
|
+
return {
|
|
3796
|
+
ok: false,
|
|
3797
|
+
detail: `terminal_status is ${JSON.stringify(status)}, so the command neither completed nor failed on its own terms`,
|
|
3798
|
+
};
|
|
3799
|
+
}
|
|
3800
|
+
const exitCode = fields["exit_code"];
|
|
3801
|
+
if (typeof exitCode === "number") {
|
|
3802
|
+
return { ok: true, outcome: exitCode === 0 ? "completed" : "failed" };
|
|
3803
|
+
}
|
|
3804
|
+
return { ok: true, outcome: "completed" };
|
|
3805
|
+
}
|
|
3806
|
+
/**
|
|
3807
|
+
* Hermes's outcome, read off `post_tool_call` where the event carries one
|
|
3808
|
+
* (APRV-398).
|
|
3809
|
+
*
|
|
3810
|
+
* The envelope's result field is read for its SHAPE only, exactly as the generic
|
|
3811
|
+
* reader reads Claude Code's: an `exit_code` decides a shell call, an `error`
|
|
3812
|
+
* string or an `error` flag decides anything, and a result this cannot read is
|
|
3813
|
+
* UNREADABLE rather than assumed complete. Nothing of the tool's output text is
|
|
3814
|
+
* read, and nothing from it reaches the log.
|
|
3815
|
+
*
|
|
3816
|
+
* Unreadable is the safe answer in both directions at once and the reasoning is
|
|
3817
|
+
* the generic reader's own: a failure nobody observed trips an escalation on
|
|
3818
|
+
* noise, and a completion nobody observed clears a streak on nothing. Appending
|
|
3819
|
+
* neither leaves the path as vacuous as it was before this adapter existed, for
|
|
3820
|
+
* that call.
|
|
3821
|
+
*
|
|
3822
|
+
* A POST EVENT ON THIS HARNESS IS NOT EVIDENCE THE TOOL RAN, which APRV-415's
|
|
3823
|
+
* probe established and which changes what the missing-result case may conclude.
|
|
3824
|
+
* `post_tool_call` fired 29ms after an exit-2 deny, and it fired again after the
|
|
3825
|
+
* armed hang was refused at the 300s cap: Hermes reports the end of its own
|
|
3826
|
+
* dispatch, not the end of an execution. So an envelope this reader cannot read
|
|
3827
|
+
* is UNREADABLE rather than complete. The event name is Hermes saying its
|
|
3828
|
+
* `post_tool_call` code path ran and nothing more, and crediting a completion on
|
|
3829
|
+
* that alone would clear a failure streak (amended SPEC.md §10.2) on a call that
|
|
3830
|
+
* may have been blocked. A blocked call usually has no `execution.started` to
|
|
3831
|
+
* close, because the pre half appended nothing; the case this protects is the one
|
|
3832
|
+
* where the pre half ALLOWED and something later in Hermes's own executor —
|
|
3833
|
+
* `approvals.mode`, a guardrail, a tool-scope check — stopped the call anyway.
|
|
3834
|
+
*/
|
|
3835
|
+
function readHermesReportedOutcome(input) {
|
|
3836
|
+
// A shell result arrives as an object on this harness (`tool_input` is an
|
|
3837
|
+
// object throughout its contract), so the generic object reader is the right
|
|
3838
|
+
// shape; a string result says nothing about success and says so below.
|
|
3839
|
+
const response = input.toolResponse;
|
|
3840
|
+
if (response === null) {
|
|
3841
|
+
return {
|
|
3842
|
+
ok: false,
|
|
3843
|
+
detail: "the post_tool_call event carries no readable result, and on this harness a post event is not evidence the tool ran: it fires for a blocked call and after a refused timeout, so nothing here says the execution completed or failed",
|
|
3844
|
+
};
|
|
3845
|
+
}
|
|
3846
|
+
if (response["interrupted"] === true || input.interrupted) {
|
|
3847
|
+
return {
|
|
3848
|
+
ok: false,
|
|
3849
|
+
detail: "the tool call was interrupted, so it neither completed nor failed on its own terms; an interruption is somebody stopping the session rather than a loop to escalate or a recovery to credit",
|
|
3850
|
+
};
|
|
3851
|
+
}
|
|
3852
|
+
const exitCode = response["exit_code"] ?? response["exitCode"] ?? response["returncode"];
|
|
3853
|
+
if (typeof exitCode === "number") {
|
|
3854
|
+
return { ok: true, outcome: exitCode === 0 ? "completed" : "failed" };
|
|
3855
|
+
}
|
|
3856
|
+
const errorText = response["error"];
|
|
3857
|
+
if (response["error"] === true ||
|
|
3858
|
+
response["status"] === "error" ||
|
|
3859
|
+
(typeof errorText === "string" && errorText.length > 0)) {
|
|
3860
|
+
return { ok: true, outcome: "failed" };
|
|
3861
|
+
}
|
|
3862
|
+
// A result object the harness DID attach, naming neither an exit code nor an
|
|
3863
|
+
// error: read as a completion, which is the generic reader's own precedent. The
|
|
3864
|
+
// residual is stated rather than hidden — if a blocked call turns out to carry a
|
|
3865
|
+
// result object of this shape, this line credits a completion that did not
|
|
3866
|
+
// happen, and the repair is one more key here once a capture names it.
|
|
3867
|
+
return { ok: true, outcome: "completed" };
|
|
3868
|
+
}
|
|
2096
3869
|
function readReportedOutcome(input, adapter) {
|
|
2097
3870
|
if (adapter.kind === "codex")
|
|
2098
3871
|
return readCodexReportedOutcome(input);
|
|
3872
|
+
if (adapter.kind === "hermes") {
|
|
3873
|
+
if (input.hookEventName !== HERMES_POST_TOOL_EVENT) {
|
|
3874
|
+
return {
|
|
3875
|
+
ok: false,
|
|
3876
|
+
detail: `hook_event_name is ${input.hookEventName === null ? "absent" : JSON.stringify(input.hookEventName)}, which is not the event this adapter reports an outcome for (${HERMES_POST_TOOL_EVENT})`,
|
|
3877
|
+
};
|
|
3878
|
+
}
|
|
3879
|
+
return readHermesReportedOutcome(input);
|
|
3880
|
+
}
|
|
3881
|
+
if (adapter.kind === "muse") {
|
|
3882
|
+
if (input.hookEventName !== "PostToolUse") {
|
|
3883
|
+
return {
|
|
3884
|
+
ok: false,
|
|
3885
|
+
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)`,
|
|
3886
|
+
};
|
|
3887
|
+
}
|
|
3888
|
+
return readMuseReportedOutcome(input);
|
|
3889
|
+
}
|
|
2099
3890
|
const event = input.hookEventName;
|
|
2100
3891
|
if (event !== "PostToolUse" && event !== "PostToolUseFailure") {
|
|
2101
3892
|
return {
|
|
@@ -2132,7 +3923,14 @@ function readReportedOutcome(input, adapter) {
|
|
|
2132
3923
|
* is the reading of the event and nothing else.
|
|
2133
3924
|
*/
|
|
2134
3925
|
function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
|
|
2135
|
-
if (input.toolName !== adapter.shellTool &&
|
|
3926
|
+
if (input.toolName !== adapter.shellTool &&
|
|
3927
|
+
!adapter.fileTools.includes(input.toolName) &&
|
|
3928
|
+
// APRV-347: a read tool MAY have had a start written for it (one outside
|
|
3929
|
+
// the scope), so it belongs in this set. A read inside the scope wrote
|
|
3930
|
+
// nothing, and the close below finds no start and says so in its own words.
|
|
3931
|
+
!adapter.readTools.includes(input.toolName) &&
|
|
3932
|
+
// APRV-445: a tool the rule table gated wrote a start like any other.
|
|
3933
|
+
toolRuleOf(adapter, input)?.kind !== "gated") {
|
|
2136
3934
|
return report(streams, "post-tool-not-gated", `${input.toolName} is not a gated tool, so no execution.started was ever written for it`);
|
|
2137
3935
|
}
|
|
2138
3936
|
if (input.toolUseId === null) {
|
|
@@ -2142,9 +3940,19 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
|
|
|
2142
3940
|
// `approval status` rather than closed against a guess.
|
|
2143
3941
|
return report(streams, "post-tool-unidentified", "the event carries no tool_use_id, so the task id the pre-execution hook minted cannot be reconstructed; nothing was appended");
|
|
2144
3942
|
}
|
|
3943
|
+
// APRV-311. The id the pre-execution half minted for this same call, derived
|
|
3944
|
+
// here from the same two native fields it derived it from. It rides the
|
|
3945
|
+
// unreadable arm so that the line naming a start nobody closed also names
|
|
3946
|
+
// WHICH start: on Codex that arm is the only arm, and a diagnostic that
|
|
3947
|
+
// cannot be joined to an `execution.started` leaves an operator grepping a
|
|
3948
|
+
// log for a record they cannot identify. It is the correlation and nothing
|
|
3949
|
+
// more — no outcome is read, inferred, or appended on this path.
|
|
3950
|
+
const task = adapter.kind === "codex"
|
|
3951
|
+
? codexBinding(input, cwd).task
|
|
3952
|
+
: `hook:${input.sessionId}:${input.toolUseId}`;
|
|
2145
3953
|
const reading = readReportedOutcome(input, adapter);
|
|
2146
3954
|
if (!reading.ok) {
|
|
2147
|
-
return report(streams, "post-tool-unreadable-outcome", `${reading.detail}; nothing was appended
|
|
3955
|
+
return report(streams, "post-tool-unreadable-outcome", `${reading.detail}; nothing was appended`, { task });
|
|
2148
3956
|
}
|
|
2149
3957
|
const { logPath, root } = hookScope(flags, cwd);
|
|
2150
3958
|
if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
|
|
@@ -2159,12 +3967,35 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
|
|
|
2159
3967
|
reportedBy: "post-tool-use",
|
|
2160
3968
|
}, actor);
|
|
2161
3969
|
if (!finished.ok) {
|
|
2162
|
-
return report(streams, `post-tool-gate-refused:${finished.code}`, finished.message);
|
|
3970
|
+
return report(streams, `post-tool-gate-refused:${finished.code}`, finished.message, { task });
|
|
2163
3971
|
}
|
|
2164
3972
|
return report(streams, POST_TOOL_REPORTED, `recorded ${reading.outcome} for ${String(finished.records.length)} delegated execution(s) of ${finished.task}`, { task: finished.task, outcome: reading.outcome, appended: finished.records.length });
|
|
2165
3973
|
}
|
|
2166
|
-
function describeToolCall(input, adapter, protectedPaths, cwd) {
|
|
3974
|
+
function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
|
|
2167
3975
|
if (adapter.kind === "codex" && input.toolName === "apply_patch") {
|
|
3976
|
+
// APRV-363. The app-server's LEGACY `applyPatchApproval` carries its change
|
|
3977
|
+
// as a MAP of path to change, inline on the request, and never as an
|
|
3978
|
+
// `apply_patch` envelope. Rendering the map into an envelope so the parser
|
|
3979
|
+
// below could read it would classify bytes this runtime wrote rather than
|
|
3980
|
+
// bytes the server sent, which is the hazard APRV-362 exists for on the
|
|
3981
|
+
// command side, so the map is classified as a map: by the paths it names,
|
|
3982
|
+
// through the same protected-path rules every other file tool uses, with
|
|
3983
|
+
// the change itself bound verbatim. It is described HERE rather than by the
|
|
3984
|
+
// caller because one describer answers "what is this call", and a caller
|
|
3985
|
+
// that could hand this module its own classes would be the party under
|
|
3986
|
+
// oversight choosing its own scrutiny (SPEC §11.1 invariant 4).
|
|
3987
|
+
//
|
|
3988
|
+
// APRV-379 widened the material this branch reads and changed nothing about
|
|
3989
|
+
// the reading. The ITEM-BASED API delivers the same change set as an ARRAY
|
|
3990
|
+
// on an earlier `item/started` frame, and the bridge correlates that frame
|
|
3991
|
+
// to the approval request by item id before calling in here. One describer
|
|
3992
|
+
// still answers the question for both APIs, which is the point: two
|
|
3993
|
+
// describers would be two answers to "what is this call", and the second
|
|
3994
|
+
// one would be the one nobody reviewed.
|
|
3995
|
+
const changes = codexFileChanges(input.toolInput);
|
|
3996
|
+
if (changes !== null) {
|
|
3997
|
+
return describeCodexFileChanges(changes, protectedPaths, cwd);
|
|
3998
|
+
}
|
|
2168
3999
|
const raw = readString(input.toolInput, "command");
|
|
2169
4000
|
if (raw === null) {
|
|
2170
4001
|
return { kind: "deny", code: "hook-io", detail: "apply_patch tool_input carries no command string" };
|
|
@@ -2192,17 +4023,55 @@ function describeToolCall(input, adapter, protectedPaths, cwd) {
|
|
|
2192
4023
|
detail: `${adapter.shellTool} tool_input carries no command string`,
|
|
2193
4024
|
};
|
|
2194
4025
|
}
|
|
4026
|
+
// APRV-362. A call may state the argv its command renders, and the app-server
|
|
4027
|
+
// bridge does, so the payload can name the words the kernel receives rather
|
|
4028
|
+
// than a string somebody still has to parse. Two accounts of one call that
|
|
4029
|
+
// disagree describe no call at all, so the disagreement is refused here
|
|
4030
|
+
// instead of being resolved in favour of either. `codexArgv` accepts an
|
|
4031
|
+
// argv only when the command splits to exactly it, which is why the only
|
|
4032
|
+
// reachable outcomes are "absent", "agrees" and this.
|
|
4033
|
+
if (adapter.kind === "codex" && codexArgvDisagrees(input.toolInput, raw)) {
|
|
4034
|
+
return {
|
|
4035
|
+
kind: "deny",
|
|
4036
|
+
code: "hook-io",
|
|
4037
|
+
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",
|
|
4038
|
+
};
|
|
4039
|
+
}
|
|
4040
|
+
// APRV-350: the PER-CALL working directory, where the harness sends one.
|
|
4041
|
+
//
|
|
4042
|
+
// Muse's `bash` carries `workdir`, and it is the directory the command will
|
|
4043
|
+
// actually run in; the top-level `cwd` is the session root and the two can
|
|
4044
|
+
// differ. The classifier resolves relative paths against this, so binding
|
|
4045
|
+
// the session root instead would classify `rm -rf docs` against the wrong
|
|
4046
|
+
// tree. Only an ABSOLUTE value is taken: a relative `workdir` is the
|
|
4047
|
+
// harness describing a location this runtime cannot place, and a
|
|
4048
|
+
// self-reported field may not talk its way into a narrower answer
|
|
4049
|
+
// (SPEC §11.1), so the fallback is the value that was already trusted.
|
|
4050
|
+
// `null` for every adapter that declares no key, which leaves both the
|
|
4051
|
+
// payload and the classification EXACTLY as they were. That is not
|
|
4052
|
+
// defensiveness: `input.cwd` and the hook's own `cwd` are different values,
|
|
4053
|
+
// and a first version of this that used the envelope's `cwd` for every
|
|
4054
|
+
// adapter re-classified Grok's commands against a directory that does not
|
|
4055
|
+
// exist on disk and denied them all.
|
|
4056
|
+
const perCallCwd = adapter.shellCwdKey === undefined
|
|
4057
|
+
? null
|
|
4058
|
+
: readString(input.toolInput, adapter.shellCwdKey);
|
|
4059
|
+
const shellCwd = perCallCwd !== null && isAbsolute(perCallCwd) ? perCallCwd : null;
|
|
2195
4060
|
// Unchanged since APRV-117, deliberately: the payload is the WHOLE command
|
|
2196
4061
|
// and the directory it runs in, so the FULL PAYLOAD block on the phone
|
|
2197
4062
|
// carries every byte the harness will execute. Only `summary` is shortened.
|
|
2198
4063
|
const payload = adapter.bindToolName
|
|
2199
4064
|
? codexBinding(input, cwd).payload
|
|
2200
|
-
: { command: raw, cwd: input.cwd };
|
|
4065
|
+
: { command: raw, cwd: shellCwd ?? input.cwd };
|
|
2201
4066
|
// APRV-108: a local rewrite of history this checkout never published is a
|
|
2202
4067
|
// commit. APRV-267: a delete confined to the agent's own scratch is not a
|
|
2203
4068
|
// decision. Both run in the hook's own cwd, after classification and never
|
|
2204
4069
|
// inside it, and neither claims anything it cannot establish from the disk.
|
|
2205
|
-
|
|
4070
|
+
// Classified against the directory the command will RUN in, not the
|
|
4071
|
+
// hook's own, so a relative path in the command resolves the way the shell
|
|
4072
|
+
// will resolve it. For every adapter without a per-call working directory
|
|
4073
|
+
// this is `input.cwd` or the hook's own cwd exactly as before.
|
|
4074
|
+
const refined = classifyForHook(raw, protectedPaths, shellCwd ?? cwd, readRoots);
|
|
2206
4075
|
const classified = refined.result;
|
|
2207
4076
|
if (!classified.ok) {
|
|
2208
4077
|
return {
|
|
@@ -2212,6 +4081,20 @@ function describeToolCall(input, adapter, protectedPaths, cwd) {
|
|
|
2212
4081
|
};
|
|
2213
4082
|
}
|
|
2214
4083
|
const classes = classified.classes.filter((cls) => cls !== GATE_SELF_CLASS);
|
|
4084
|
+
// APRV-445 refutation (B2, L10). The classifier matches protected paths by
|
|
4085
|
+
// their SEGMENTS, and a relative word carries only the segments it was
|
|
4086
|
+
// written with: `echo X >> .env` run in `$HERMES_HOME` names `.env`, not
|
|
4087
|
+
// `.hermes/.env`, and classified as a workspace write. Where the harness
|
|
4088
|
+
// states the per-call directory the command runs in, every relative word
|
|
4089
|
+
// and every write target is ALSO judged resolved against it: a write that
|
|
4090
|
+
// lands on an organ takes the organ's class, and a read that lands on a
|
|
4091
|
+
// credential takes `account.credential`. It only ever adds a class.
|
|
4092
|
+
if (shellCwd !== null) {
|
|
4093
|
+
for (const cls of resolvedPathClasses(raw, classified.segments, shellCwd, protectedPaths)) {
|
|
4094
|
+
if (!classes.includes(cls))
|
|
4095
|
+
classes.push(cls);
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
2215
4098
|
if (adapter.kind === "codex") {
|
|
2216
4099
|
// The pure shell classifier sees each segment independently. Preserve
|
|
2217
4100
|
// Codex hook organs when an earlier simple `cd` changes the directory or
|
|
@@ -2293,6 +4176,52 @@ function describeToolCall(input, adapter, protectedPaths, cwd) {
|
|
|
2293
4176
|
segments: classified.segments,
|
|
2294
4177
|
};
|
|
2295
4178
|
}
|
|
4179
|
+
// APRV-445: the harness's own side-effecting tools, by name and arguments.
|
|
4180
|
+
// The payload is the whole call, so a grant binds every argument.
|
|
4181
|
+
const ruled = toolRuleOf(adapter, input);
|
|
4182
|
+
if (ruled !== null) {
|
|
4183
|
+
if (ruled.kind === "read")
|
|
4184
|
+
return { kind: "allow", reason: ruled.reason };
|
|
4185
|
+
return {
|
|
4186
|
+
kind: "gated",
|
|
4187
|
+
classes: [ruled.cls],
|
|
4188
|
+
payload: { tool: input.toolName, input: input.toolInput },
|
|
4189
|
+
headline: ruled.headline,
|
|
4190
|
+
notes: [],
|
|
4191
|
+
};
|
|
4192
|
+
}
|
|
4193
|
+
// APRV-347, above the file-tool branch because the two lists are disjoint and
|
|
4194
|
+
// a read is the cheaper question to answer: a read inside the scope, or one
|
|
4195
|
+
// naming no path at all, returns `null` here and takes the allow below.
|
|
4196
|
+
if (adapter.readTools.includes(input.toolName)) {
|
|
4197
|
+
// APRV-445 refutation (B2): a read tool naming a credential file is
|
|
4198
|
+
// `account.credential` wherever the file sits, inside the read scope
|
|
4199
|
+
// included, as the same read through the shell already was.
|
|
4200
|
+
const credential = credentialReadGate(input.toolName, input.toolInput, cwd);
|
|
4201
|
+
if (credential !== null) {
|
|
4202
|
+
return {
|
|
4203
|
+
kind: "gated",
|
|
4204
|
+
classes: ["account.credential"],
|
|
4205
|
+
payload: credential.payload,
|
|
4206
|
+
headline: credential.summary,
|
|
4207
|
+
notes: [],
|
|
4208
|
+
};
|
|
4209
|
+
}
|
|
4210
|
+
const read = readToolGate(input.toolName, input.toolInput, readRoots, cwd);
|
|
4211
|
+
if (read === null) {
|
|
4212
|
+
return {
|
|
4213
|
+
kind: "allow",
|
|
4214
|
+
reason: `${input.toolName} is not a gated tool`,
|
|
4215
|
+
};
|
|
4216
|
+
}
|
|
4217
|
+
return {
|
|
4218
|
+
kind: "gated",
|
|
4219
|
+
classes: [READ_OUT_OF_SCOPE_CLASS],
|
|
4220
|
+
payload: read.payload,
|
|
4221
|
+
headline: read.summary,
|
|
4222
|
+
notes: [readScopeNote(read, readRoots)],
|
|
4223
|
+
};
|
|
4224
|
+
}
|
|
2296
4225
|
const gated = fileToolGate(input.toolName, input.toolInput, protectedPaths, cwd);
|
|
2297
4226
|
if (gated === null) {
|
|
2298
4227
|
return { kind: "allow", reason: `${input.toolName} names no file, so there is nothing to gate` };
|
|
@@ -2307,6 +4236,465 @@ function describeToolCall(input, adapter, protectedPaths, cwd) {
|
|
|
2307
4236
|
notes: gated.protectedPath ? [fileTierNote(gated)] : [],
|
|
2308
4237
|
};
|
|
2309
4238
|
}
|
|
4239
|
+
/**
|
|
4240
|
+
* The classes a shell command's PATHS take once they are resolved the way the
|
|
4241
|
+
* shell and the filesystem will resolve them (APRV-445 refutation B2 and L10,
|
|
4242
|
+
* recheck SF2 and L-e), for a harness that states the directory each call runs
|
|
4243
|
+
* in.
|
|
4244
|
+
*
|
|
4245
|
+
* The classifier matches organs and credentials by path SEGMENTS of the words
|
|
4246
|
+
* as written. A word written relative (`.env` run in `$HERMES_HOME`), through a
|
|
4247
|
+
* variable (`$HERMES_HOME/.env`), after a `cd`, or through a symlinked parent
|
|
4248
|
+
* (`h/.env` where `h -> .hermes`) carries none of the segments that matter. So
|
|
4249
|
+
* each path a segment touches is also judged here:
|
|
4250
|
+
*
|
|
4251
|
+
* - WRITTEN paths are the redirect targets (heredocs included) and the
|
|
4252
|
+
* write-argument positions of the binaries whose writes are known (L-e):
|
|
4253
|
+
* never the binary, never a word that is merely an argument. A written path
|
|
4254
|
+
* resolving to an organ takes its class; one carrying a glob or a variable
|
|
4255
|
+
* this hook cannot expand is `policy.core` when it lands in one of the gate's
|
|
4256
|
+
* own directories ({@link gateRootKind}) or a pattern in it could name the
|
|
4257
|
+
* home or an organ (recheck 3); an unexpanded `$HERMES_HOME` in a written
|
|
4258
|
+
* path is `policy.core` wherever it is written from. A copy into a directory
|
|
4259
|
+
* writes `<dir>/<source name>`; an extraction writes into its directory.
|
|
4260
|
+
* - READ paths (a read segment's arguments, `<` targets, a copy's sources) that
|
|
4261
|
+
* resolve to a credential take `account.credential`, and so does a directory
|
|
4262
|
+
* that holds credentials ({@link holdsCredentials}) handed to a binary that
|
|
4263
|
+
* reads directories recursively (`grep -r`, `rg`, `find`, `tar`, ...).
|
|
4264
|
+
* - After a `cd` this hook cannot resolve, every later relative write is
|
|
4265
|
+
* `policy.core` and every later relative read `account.credential`.
|
|
4266
|
+
* - `cd` moves the directory for the segments after it. Like the Codex arm, a
|
|
4267
|
+
* `cd` may or may not have run (`||`, a failed `cd`), so the directories
|
|
4268
|
+
* before and after it are both kept and every later path is judged from each.
|
|
4269
|
+
* - Every path is resolved through `realpath` on its deepest existing ancestor,
|
|
4270
|
+
* so a symlinked parent or working directory is judged by where it lands.
|
|
4271
|
+
* - `$HERMES_HOME` / `${HERMES_HOME}` expand to `APPROVAL_HERMES_HOME`, else
|
|
4272
|
+
* `HERMES_HOME`, from this hook process's environment; `~`, `$HOME` and
|
|
4273
|
+
* `${HOME}` to `HOME`. Under `approval serve` the process is the daemon, so a
|
|
4274
|
+
* hosted operator sets `APPROVAL_HERMES_HOME` there; without it, the
|
|
4275
|
+
* conservative rules above apply to the unexpanded spelling.
|
|
4276
|
+
*
|
|
4277
|
+
* What stays out of reach is listed in `docs/hermes-hook.md`: inline programs
|
|
4278
|
+
* (`python -c`, which the classifier already refuses as opaque), `dd`/`install`
|
|
4279
|
+
* (unclassified, so already denied), and a recursive search of an ANCESTOR of
|
|
4280
|
+
* the home with a filename filter.
|
|
4281
|
+
*
|
|
4282
|
+
* It only ever adds classes.
|
|
4283
|
+
*/
|
|
4284
|
+
function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.env) {
|
|
4285
|
+
const found = [];
|
|
4286
|
+
const add = (cls) => {
|
|
4287
|
+
if (cls !== null && !found.includes(cls))
|
|
4288
|
+
found.push(cls);
|
|
4289
|
+
};
|
|
4290
|
+
const shapes = commandSegmentShapes(raw) ?? [];
|
|
4291
|
+
// Trusted as given: the operator sets it (under co-location, the control
|
|
4292
|
+
// plane sets it from the tenant's home), and nothing the agent writes reaches
|
|
4293
|
+
// this process's environment.
|
|
4294
|
+
const hermesHome = env["APPROVAL_HERMES_HOME"] ?? env["HERMES_HOME"] ?? null;
|
|
4295
|
+
const home = env["HOME"] ?? null;
|
|
4296
|
+
/** The word with the variables this hook can know expanded. */
|
|
4297
|
+
const expand = (word) => {
|
|
4298
|
+
let text = word;
|
|
4299
|
+
let hermesUnknown = false;
|
|
4300
|
+
const hermesRef = /^\$(?:\{HERMES_HOME\}|HERMES_HOME)(?=\/|$)/u;
|
|
4301
|
+
if (hermesRef.test(text)) {
|
|
4302
|
+
if (hermesHome !== null && hermesHome.length > 0)
|
|
4303
|
+
text = text.replace(hermesRef, hermesHome);
|
|
4304
|
+
else
|
|
4305
|
+
hermesUnknown = true;
|
|
4306
|
+
}
|
|
4307
|
+
const homeRef = /^(?:~|\$\{HOME\}|\$HOME)(?=\/|$)/u;
|
|
4308
|
+
if (homeRef.test(text) && home !== null && home.length > 0)
|
|
4309
|
+
text = text.replace(homeRef, home);
|
|
4310
|
+
return { text, hermesUnknown, unresolved: hermesUnknown || /[$`]/u.test(text) };
|
|
4311
|
+
};
|
|
4312
|
+
// After a `cd` this hook cannot resolve (`cd $X`, `cd "$D"`, `cd -`), the
|
|
4313
|
+
// directory every later relative path lands in is unknown (recheck 3, SF2).
|
|
4314
|
+
let cwdUnknown = false;
|
|
4315
|
+
let cwds = [canonicalPath(cwd)];
|
|
4316
|
+
for (const [index, segment] of segments.entries()) {
|
|
4317
|
+
const shape = shapes.length === segments.length
|
|
4318
|
+
? shapes[index]
|
|
4319
|
+
: shapes.find((candidate) => candidate.text === segment.text);
|
|
4320
|
+
if (shape === undefined)
|
|
4321
|
+
continue;
|
|
4322
|
+
const bin = shape.bin === null ? null : basename(shape.bin);
|
|
4323
|
+
const operands = shape.args.filter((arg) => !arg.startsWith("-"));
|
|
4324
|
+
if (bin === "cd" || bin === "pushd") {
|
|
4325
|
+
const target = operands[0];
|
|
4326
|
+
if (target === undefined) {
|
|
4327
|
+
// A bare `cd` goes HOME; `cd -` and `pushd` with no operand go to a
|
|
4328
|
+
// directory recorded earlier in a shell this hook never saw.
|
|
4329
|
+
if (bin === "cd" && !shape.args.includes("-") && home !== null) {
|
|
4330
|
+
cwds = [...new Set([...cwds, canonicalPath(home)])];
|
|
4331
|
+
}
|
|
4332
|
+
else {
|
|
4333
|
+
cwdUnknown = true;
|
|
4334
|
+
}
|
|
4335
|
+
}
|
|
4336
|
+
else {
|
|
4337
|
+
const expanded = expand(target);
|
|
4338
|
+
if (expanded.unresolved || hasGlobChars(expanded.text)) {
|
|
4339
|
+
cwdUnknown = true;
|
|
4340
|
+
}
|
|
4341
|
+
else {
|
|
4342
|
+
const next = cwds.map((from) => canonicalPath(resolvePathSegments(from, expanded.text)));
|
|
4343
|
+
cwds = [...new Set([...cwds, ...next])];
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
continue;
|
|
4347
|
+
}
|
|
4348
|
+
if (bin === null && shape.writes.length === 0 && shape.reads.length === 0)
|
|
4349
|
+
continue;
|
|
4350
|
+
const judgeWrite = (word) => {
|
|
4351
|
+
const target = expand(word);
|
|
4352
|
+
if (target.hermesUnknown) {
|
|
4353
|
+
add("policy.core");
|
|
4354
|
+
return;
|
|
4355
|
+
}
|
|
4356
|
+
if (!isAbsolute(target.text) && cwdUnknown) {
|
|
4357
|
+
add("policy.core");
|
|
4358
|
+
return;
|
|
4359
|
+
}
|
|
4360
|
+
const globbed = target.unresolved || hasGlobChars(target.text);
|
|
4361
|
+
for (const from of cwds) {
|
|
4362
|
+
const absoluteTarget = resolvePathSegments(from, target.text);
|
|
4363
|
+
if (globbed) {
|
|
4364
|
+
// SF3a: a pattern in any component that could name the home, the
|
|
4365
|
+
// approval home or an organ is an organ write; a pattern written
|
|
4366
|
+
// into one of the gate's own directories (SF1b) may be any file
|
|
4367
|
+
// there. Anywhere else it is a workspace write.
|
|
4368
|
+
if (globCouldNameOrgan(absoluteTarget) || writesIntoGateRoot(canonicalPath(dirname(absoluteTarget)))) {
|
|
4369
|
+
add("policy.core");
|
|
4370
|
+
}
|
|
4371
|
+
continue;
|
|
4372
|
+
}
|
|
4373
|
+
add(protectedPathClass(canonicalPath(absoluteTarget), protectedPaths));
|
|
4374
|
+
}
|
|
4375
|
+
};
|
|
4376
|
+
/** A copy or move INTO a directory writes `<dir>/<basename of source>`. */
|
|
4377
|
+
const judgeWriteInto = (directory, sources) => {
|
|
4378
|
+
for (const source of sources) {
|
|
4379
|
+
const name = basename(expand(source).text);
|
|
4380
|
+
if (name.length === 0 || name === "." || name === "..") {
|
|
4381
|
+
// `cp -r stuff/. dir`: the contents land in the directory itself.
|
|
4382
|
+
judgeExtractInto(directory);
|
|
4383
|
+
continue;
|
|
4384
|
+
}
|
|
4385
|
+
judgeWrite(join(directory, name));
|
|
4386
|
+
}
|
|
4387
|
+
};
|
|
4388
|
+
/** An extraction or a contents-copy writes unknown names into `directory`. */
|
|
4389
|
+
const judgeExtractInto = (directory) => {
|
|
4390
|
+
const target = expand(directory);
|
|
4391
|
+
if (target.hermesUnknown || (cwdUnknown && !isAbsolute(target.text))) {
|
|
4392
|
+
add("policy.core");
|
|
4393
|
+
return;
|
|
4394
|
+
}
|
|
4395
|
+
if (target.unresolved)
|
|
4396
|
+
return;
|
|
4397
|
+
for (const from of cwds) {
|
|
4398
|
+
if (writesIntoGateRoot(canonicalPath(resolvePathSegments(from, target.text))))
|
|
4399
|
+
add("policy.core");
|
|
4400
|
+
}
|
|
4401
|
+
};
|
|
4402
|
+
const judgeRead = (word, recursive) => {
|
|
4403
|
+
const target = expand(word);
|
|
4404
|
+
if (target.hermesUnknown) {
|
|
4405
|
+
add("account.credential");
|
|
4406
|
+
return;
|
|
4407
|
+
}
|
|
4408
|
+
if (!isAbsolute(target.text) && cwdUnknown) {
|
|
4409
|
+
add("account.credential");
|
|
4410
|
+
return;
|
|
4411
|
+
}
|
|
4412
|
+
if (target.unresolved)
|
|
4413
|
+
return;
|
|
4414
|
+
for (const from of cwds) {
|
|
4415
|
+
const absoluteTarget = resolvePathSegments(from, target.text);
|
|
4416
|
+
if (hasGlobChars(target.text)) {
|
|
4417
|
+
// SF3b: expand against the directory when it is known; otherwise a
|
|
4418
|
+
// pattern that could name a credential is read as one.
|
|
4419
|
+
const candidates = expandGlob(absoluteTarget);
|
|
4420
|
+
if (candidates === null) {
|
|
4421
|
+
if (globCouldNameCredential(absoluteTarget))
|
|
4422
|
+
add("account.credential");
|
|
4423
|
+
}
|
|
4424
|
+
else {
|
|
4425
|
+
for (const candidate of candidates) {
|
|
4426
|
+
if (isCredentialPath(canonicalPath(candidate)))
|
|
4427
|
+
add("account.credential");
|
|
4428
|
+
}
|
|
4429
|
+
}
|
|
4430
|
+
continue;
|
|
4431
|
+
}
|
|
4432
|
+
const resolved = canonicalPath(absoluteTarget);
|
|
4433
|
+
if (isCredentialPath(resolved))
|
|
4434
|
+
add("account.credential");
|
|
4435
|
+
else if (recursive && holdsCredentials(resolved))
|
|
4436
|
+
add("account.credential");
|
|
4437
|
+
}
|
|
4438
|
+
};
|
|
4439
|
+
for (const target of shape.writes)
|
|
4440
|
+
judgeWrite(target);
|
|
4441
|
+
for (const source of shape.reads)
|
|
4442
|
+
judgeRead(source, false);
|
|
4443
|
+
if (bin === null)
|
|
4444
|
+
continue;
|
|
4445
|
+
// `-t DIR` / `--target-directory=DIR` names the destination up front.
|
|
4446
|
+
const targetDirFlag = () => {
|
|
4447
|
+
for (const [at, arg] of shape.args.entries()) {
|
|
4448
|
+
if (arg === "-t")
|
|
4449
|
+
return shape.args[at + 1] ?? null;
|
|
4450
|
+
if (arg.startsWith("--target-directory="))
|
|
4451
|
+
return arg.slice("--target-directory=".length);
|
|
4452
|
+
}
|
|
4453
|
+
return null;
|
|
4454
|
+
};
|
|
4455
|
+
if (WRITE_EVERY_OPERAND.has(bin)) {
|
|
4456
|
+
for (const operand of operands)
|
|
4457
|
+
judgeWrite(operand);
|
|
4458
|
+
}
|
|
4459
|
+
else if (WRITE_LAST_OPERAND.has(bin)) {
|
|
4460
|
+
const targetDir = targetDirFlag();
|
|
4461
|
+
const sources = targetDir === null ? operands.slice(0, -1) : operands.filter((operand) => operand !== targetDir);
|
|
4462
|
+
const destination = targetDir ?? operands[operands.length - 1];
|
|
4463
|
+
for (const source of sources)
|
|
4464
|
+
judgeRead(source, true);
|
|
4465
|
+
if (destination !== undefined) {
|
|
4466
|
+
const expanded = expand(destination);
|
|
4467
|
+
const intoDirectory = targetDir !== null ||
|
|
4468
|
+
destination.endsWith("/") ||
|
|
4469
|
+
(!expanded.unresolved &&
|
|
4470
|
+
cwds.some((from) => isDirectoryPath(canonicalPath(resolvePathSegments(from, expanded.text)))));
|
|
4471
|
+
if (intoDirectory)
|
|
4472
|
+
judgeWriteInto(destination, sources);
|
|
4473
|
+
else
|
|
4474
|
+
judgeWrite(destination);
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
4477
|
+
else if (WRITE_AFTER_FIRST_OPERAND.has(bin)) {
|
|
4478
|
+
for (const operand of operands.slice(1))
|
|
4479
|
+
judgeWrite(operand);
|
|
4480
|
+
}
|
|
4481
|
+
else if (bin === "sed" && shape.args.some((arg) => arg === "--in-place" || /^-[a-zA-Z]*i/u.test(arg))) {
|
|
4482
|
+
const scripted = shape.args.some((arg) => arg === "-e" || arg === "-f");
|
|
4483
|
+
for (const operand of scripted ? operands : operands.slice(1))
|
|
4484
|
+
judgeWrite(operand);
|
|
4485
|
+
}
|
|
4486
|
+
else if (bin === "dd") {
|
|
4487
|
+
for (const arg of shape.args) {
|
|
4488
|
+
if (arg.startsWith("of="))
|
|
4489
|
+
judgeWrite(arg.slice(3));
|
|
4490
|
+
if (arg.startsWith("if="))
|
|
4491
|
+
judgeRead(arg.slice(3), false);
|
|
4492
|
+
}
|
|
4493
|
+
}
|
|
4494
|
+
else if (bin === "tar" && isTarExtract(shape.args)) {
|
|
4495
|
+
// Lows: an extraction writes the archive's names into `-C DIR` or the
|
|
4496
|
+
// working directory.
|
|
4497
|
+
judgeExtractInto(flagValue(shape.args, "-C", "--directory") ?? ".");
|
|
4498
|
+
}
|
|
4499
|
+
else if (bin === "unzip") {
|
|
4500
|
+
judgeExtractInto(flagValue(shape.args, "-d", null) ?? ".");
|
|
4501
|
+
}
|
|
4502
|
+
if (segment.class.startsWith("read.") || RECURSIVE_READERS.has(bin)) {
|
|
4503
|
+
for (const operand of operands)
|
|
4504
|
+
judgeRead(operand, RECURSIVE_READERS.has(bin));
|
|
4505
|
+
}
|
|
4506
|
+
}
|
|
4507
|
+
return found;
|
|
4508
|
+
}
|
|
4509
|
+
/** `*`, `?` or `[` in a path word. */
|
|
4510
|
+
function hasGlobChars(text) {
|
|
4511
|
+
return /[*?[]/u.test(text);
|
|
4512
|
+
}
|
|
4513
|
+
/** A shell glob component as an anchored regular expression. */
|
|
4514
|
+
function globRegex(component) {
|
|
4515
|
+
let source = "";
|
|
4516
|
+
for (const char of component) {
|
|
4517
|
+
if (char === "*")
|
|
4518
|
+
source += ".*";
|
|
4519
|
+
else if (char === "?")
|
|
4520
|
+
source += ".";
|
|
4521
|
+
else if (char === "[" || char === "]")
|
|
4522
|
+
source += char;
|
|
4523
|
+
else
|
|
4524
|
+
source += char.replace(/[.+^${}()|\\]/gu, "\\$&");
|
|
4525
|
+
}
|
|
4526
|
+
try {
|
|
4527
|
+
return new RegExp(`^${source}$`, "u");
|
|
4528
|
+
}
|
|
4529
|
+
catch {
|
|
4530
|
+
return /^.*$/u;
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
4533
|
+
/** Names a credential file goes by, for a pattern to be tested against. */
|
|
4534
|
+
const CREDENTIAL_NAMES = [".env", ".env.local", "auth.json", "env", "vault.enc", "keys"];
|
|
4535
|
+
/** Names an organ goes by, beyond the credentials. */
|
|
4536
|
+
const ORGAN_NAMES = [
|
|
4537
|
+
...CREDENTIAL_NAMES,
|
|
4538
|
+
"config.yaml",
|
|
4539
|
+
"config.yml",
|
|
4540
|
+
"shell-hooks-allowlist.json",
|
|
4541
|
+
"shell-hooks-allowlist.json.lock",
|
|
4542
|
+
"agent-hooks",
|
|
4543
|
+
"approval",
|
|
4544
|
+
"scripts",
|
|
4545
|
+
];
|
|
4546
|
+
/** Could some component of `path`'s pattern name the home or the approval home? */
|
|
4547
|
+
function globCouldNameGateHome(path) {
|
|
4548
|
+
return path
|
|
4549
|
+
.split(/[/\\]+/u)
|
|
4550
|
+
.some((component) => hasGlobChars(component) && [".hermes", ".approval"].some((name) => globRegex(component).test(name)));
|
|
4551
|
+
}
|
|
4552
|
+
/** SF3a: a written pattern that could land on an organ. */
|
|
4553
|
+
function globCouldNameOrgan(path) {
|
|
4554
|
+
if (globCouldNameGateHome(path))
|
|
4555
|
+
return true;
|
|
4556
|
+
const last = basename(path);
|
|
4557
|
+
if (!hasGlobChars(last))
|
|
4558
|
+
return false;
|
|
4559
|
+
const pattern = globRegex(last);
|
|
4560
|
+
const parent = dirname(path);
|
|
4561
|
+
const parentIsHome = parent.split(/[/\\]+/u).some((segment) => segment === ".hermes" || segment === ".approval") ||
|
|
4562
|
+
hasGlobChars(parent);
|
|
4563
|
+
return parentIsHome && ORGAN_NAMES.some((name) => pattern.test(name));
|
|
4564
|
+
}
|
|
4565
|
+
/** SF3b: a read pattern whose directory is unknown, that could name a credential. */
|
|
4566
|
+
function globCouldNameCredential(path) {
|
|
4567
|
+
const last = basename(path);
|
|
4568
|
+
if (!hasGlobChars(last))
|
|
4569
|
+
return globCouldNameGateHome(path);
|
|
4570
|
+
const pattern = globRegex(last);
|
|
4571
|
+
return CREDENTIAL_NAMES.some((name) => pattern.test(name)) || globCouldNameGateHome(path);
|
|
4572
|
+
}
|
|
4573
|
+
/** The entries a final-component glob matches, or `null` when the directory is unknown. */
|
|
4574
|
+
function expandGlob(path) {
|
|
4575
|
+
const parent = dirname(path);
|
|
4576
|
+
if (hasGlobChars(parent))
|
|
4577
|
+
return null;
|
|
4578
|
+
try {
|
|
4579
|
+
const pattern = globRegex(basename(path));
|
|
4580
|
+
return readdirSync(canonicalPath(parent))
|
|
4581
|
+
.filter((name) => pattern.test(name))
|
|
4582
|
+
.map((name) => join(parent, name));
|
|
4583
|
+
}
|
|
4584
|
+
catch {
|
|
4585
|
+
return null;
|
|
4586
|
+
}
|
|
4587
|
+
}
|
|
4588
|
+
function isDirectoryPath(path) {
|
|
4589
|
+
try {
|
|
4590
|
+
return statSync(path).isDirectory();
|
|
4591
|
+
}
|
|
4592
|
+
catch {
|
|
4593
|
+
return false;
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
4596
|
+
/**
|
|
4597
|
+
* The gate's own directories (recheck 3, SF1): a Hermes home (or profile home)
|
|
4598
|
+
* root, an approval home, a home's `approval/`, and its `scripts/`. Everything
|
|
4599
|
+
* else under a home (`workspace/`, `skills/`, `memories/`, `sessions/`) is
|
|
4600
|
+
* ordinary work, which matters because a hosted image runs ALL agent work under
|
|
4601
|
+
* `$HERMES_HOME`.
|
|
4602
|
+
*/
|
|
4603
|
+
function gateRootKind(directory) {
|
|
4604
|
+
const segments = directory.split(/[/\\]+/u).filter((segment) => segment.length > 0);
|
|
4605
|
+
const last = segments[segments.length - 1];
|
|
4606
|
+
const isHome = (at) => segments[at] === ".hermes" ||
|
|
4607
|
+
(segments[at - 2] === ".hermes" && segments[at - 1] === "profiles" && segments[at] !== undefined);
|
|
4608
|
+
if (last === undefined)
|
|
4609
|
+
return null;
|
|
4610
|
+
if (isHome(segments.length - 1))
|
|
4611
|
+
return "home";
|
|
4612
|
+
if (last === ".approval")
|
|
4613
|
+
return "approval-home";
|
|
4614
|
+
if (last === "approval" && isHome(segments.length - 2))
|
|
4615
|
+
return "home-approval";
|
|
4616
|
+
if (last === "scripts" && isHome(segments.length - 2))
|
|
4617
|
+
return "scripts";
|
|
4618
|
+
return null;
|
|
4619
|
+
}
|
|
4620
|
+
/** A glob or an unknown name written here may be an organ (SF1b). */
|
|
4621
|
+
function writesIntoGateRoot(directory) {
|
|
4622
|
+
return gateRootKind(directory) !== null;
|
|
4623
|
+
}
|
|
4624
|
+
/**
|
|
4625
|
+
* A directory a recursive read of which reads credentials (SF1a): the home
|
|
4626
|
+
* root, the approval home, a home's `approval/`, and any directory under a home
|
|
4627
|
+
* that directly holds a credential file.
|
|
4628
|
+
*/
|
|
4629
|
+
function holdsCredentials(directory) {
|
|
4630
|
+
if (!isDirectoryPath(directory))
|
|
4631
|
+
return false;
|
|
4632
|
+
const kind = gateRootKind(directory);
|
|
4633
|
+
if (kind === "home" || kind === "approval-home" || kind === "home-approval")
|
|
4634
|
+
return true;
|
|
4635
|
+
if (!directory.split(/[/\\]+/u).some((segment) => segment === ".hermes" || segment === ".approval"))
|
|
4636
|
+
return false;
|
|
4637
|
+
return CREDENTIAL_FILE_NAMES.some((name) => existsSync(join(directory, name)));
|
|
4638
|
+
}
|
|
4639
|
+
const CREDENTIAL_FILE_NAMES = [
|
|
4640
|
+
".env",
|
|
4641
|
+
"auth.json",
|
|
4642
|
+
"config.yaml",
|
|
4643
|
+
"config.yml",
|
|
4644
|
+
"shell-hooks-allowlist.json",
|
|
4645
|
+
"shell-hooks-allowlist.json.lock",
|
|
4646
|
+
];
|
|
4647
|
+
function isTarExtract(args) {
|
|
4648
|
+
const first = args[0] ?? "";
|
|
4649
|
+
return args.some((arg) => arg === "--extract" || arg === "--get" || /^-[a-zA-Z]*x/u.test(arg)) ||
|
|
4650
|
+
(!first.startsWith("-") && first.includes("x"));
|
|
4651
|
+
}
|
|
4652
|
+
function flagValue(args, short, long) {
|
|
4653
|
+
for (const [at, arg] of args.entries()) {
|
|
4654
|
+
if (arg === short)
|
|
4655
|
+
return args[at + 1] ?? null;
|
|
4656
|
+
if (arg.startsWith(short) && arg.length > short.length && !arg.startsWith("--"))
|
|
4657
|
+
return arg.slice(short.length);
|
|
4658
|
+
if (long !== null && arg.startsWith(`${long}=`))
|
|
4659
|
+
return arg.slice(long.length + 1);
|
|
4660
|
+
if (long !== null && arg === long)
|
|
4661
|
+
return args[at + 1] ?? null;
|
|
4662
|
+
}
|
|
4663
|
+
return null;
|
|
4664
|
+
}
|
|
4665
|
+
/** Binaries every operand of which is a path they create, change or remove. */
|
|
4666
|
+
const WRITE_EVERY_OPERAND = new Set([
|
|
4667
|
+
"touch", "truncate", "mkdir", "rm", "rmdir", "shred", "tee", "unlink",
|
|
4668
|
+
]);
|
|
4669
|
+
/** Binaries whose LAST operand is the destination and the rest are sources. */
|
|
4670
|
+
const WRITE_LAST_OPERAND = new Set(["cp", "mv", "ln", "install", "rsync"]);
|
|
4671
|
+
/** Binaries whose first operand is a mode or owner and the rest are paths. */
|
|
4672
|
+
const WRITE_AFTER_FIRST_OPERAND = new Set(["chmod", "chown", "chgrp"]);
|
|
4673
|
+
/** Readers that walk a directory they are handed. */
|
|
4674
|
+
const RECURSIVE_READERS = new Set([
|
|
4675
|
+
"grep", "egrep", "fgrep", "rg", "ag", "ack", "find", "tar", "zip", "du", "tree",
|
|
4676
|
+
]);
|
|
4677
|
+
/**
|
|
4678
|
+
* `path` resolved through the realpath of its deepest existing ancestor, so a
|
|
4679
|
+
* symlinked parent is judged by where it lands and a path that does not exist
|
|
4680
|
+
* yet is judged by where it would be created (APRV-445 recheck SF2(e)).
|
|
4681
|
+
*/
|
|
4682
|
+
function canonicalPath(path) {
|
|
4683
|
+
let current = path;
|
|
4684
|
+
const tail = [];
|
|
4685
|
+
for (;;) {
|
|
4686
|
+
try {
|
|
4687
|
+
return resolvePathSegments(realpathSync(current), ...tail.reverse());
|
|
4688
|
+
}
|
|
4689
|
+
catch {
|
|
4690
|
+
const parent = dirname(current);
|
|
4691
|
+
if (parent === current)
|
|
4692
|
+
return resolvePathSegments(path);
|
|
4693
|
+
tail.push(basename(current));
|
|
4694
|
+
current = parent;
|
|
4695
|
+
}
|
|
4696
|
+
}
|
|
4697
|
+
}
|
|
2310
4698
|
/** The environment variable that turns the sandbox requirement on (APRV-193). */
|
|
2311
4699
|
export const REQUIRE_SANDBOX_ENV = "APPROVAL_HOOK_REQUIRE_SANDBOX";
|
|
2312
4700
|
/**
|
|
@@ -2439,7 +4827,11 @@ decidedOn) {
|
|
|
2439
4827
|
const policyNote = load.ok
|
|
2440
4828
|
? null
|
|
2441
4829
|
: `the policy did not load (${load.code}: ${load.message}), so no protected path beyond the built-ins was known here and no class could be resolved to human-only`;
|
|
2442
|
-
const described = describeToolCall(input, adapter, protectedPaths, cwd
|
|
4830
|
+
const described = describeToolCall(input, adapter, protectedPaths, cwd,
|
|
4831
|
+
// APRV-347. The window suspends the POLICY, not the classification: a read
|
|
4832
|
+
// outside the scope is described as one here too, so the bypass RECORD says
|
|
4833
|
+
// what was actually authorized rather than calling it an ordinary read.
|
|
4834
|
+
resolveReadRoots(cwd, policyRootOf(load, scope.root), load.ok ? load.policy.read_scope?.roots : undefined));
|
|
2443
4835
|
if (described.kind === "deny") {
|
|
2444
4836
|
return deny(streams, described.code, `${described.detail} The open window does not reach this: a command the classifier cannot read is a command nothing here can establish is safe to run unapproved.`, adapter.kind);
|
|
2445
4837
|
}
|
|
@@ -2459,6 +4851,15 @@ decidedOn) {
|
|
|
2459
4851
|
return deny(streams, "hook-class-human-only", `${mutation} is never reachable through the open window: the window suspends the POLICY, and the log is what the window itself is derived from. A bypass able to write the log could rewrite its own authorization. Nothing was appended; a human writes the log directory by hand or not at all.`, adapter.kind);
|
|
2460
4852
|
}
|
|
2461
4853
|
if (load.ok) {
|
|
4854
|
+
// APRV-354, above the human-only check here for the same reason it sits
|
|
4855
|
+
// above it on the ordinary path. A window suspends what the policy DECIDES;
|
|
4856
|
+
// it cannot supply a decision the policy never made. A launch the policy
|
|
4857
|
+
// names no rule for is not a class the window is holding open — it is a
|
|
4858
|
+
// class nobody has opted into — so the window does not reach it either.
|
|
4859
|
+
const unruled = classes.find((cls) => harnessLaunchNeedsRule(cls, resolvePolicy(load, cls)));
|
|
4860
|
+
if (unruled !== undefined) {
|
|
4861
|
+
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);
|
|
4862
|
+
}
|
|
2462
4863
|
const reserved = classes.find((cls) => resolvePolicy(load, cls).autonomy === "human-only");
|
|
2463
4864
|
if (reserved !== undefined) {
|
|
2464
4865
|
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);
|
|
@@ -2497,8 +4898,13 @@ decidedOn) {
|
|
|
2497
4898
|
const notes = [...described.notes, ...(policyNote === null ? [] : [policyNote])];
|
|
2498
4899
|
return allow(streams, `gate-open: ${classes.join(", ")} bypassed by the window opened at seq ${String(window.seq)} by ${window.openedBy} (expires ${window.expiresAt}); recorded as gate.bypassed seq ${String(recorded.record.seq)}${notes.length === 0 ? "" : ` (${notes.join("; ")})`}`, adapter.kind, codexCommand);
|
|
2499
4900
|
}
|
|
2500
|
-
function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
2501
|
-
|
|
4901
|
+
function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
4902
|
+
// Codex and (APRV-445) Hermes answer a misconfigured entry with a verdict:
|
|
4903
|
+
// on both a usage error's empty stdout is a harness that runs the call, and
|
|
4904
|
+
// on Hermes a non-zero exit with an empty stdout is an allow outright.
|
|
4905
|
+
const configurationError = (message) => adapter.kind === "codex" || adapter.kind === "hermes"
|
|
4906
|
+
? deny(streams, "hook-io", message, adapter.kind)
|
|
4907
|
+
: usageError(streams, message);
|
|
2502
4908
|
const parsed = parseFlags(argv, {
|
|
2503
4909
|
...COMMON_FLAGS,
|
|
2504
4910
|
...POLICY_FLAGS,
|
|
@@ -2507,11 +4913,31 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2507
4913
|
"--timeout": "string",
|
|
2508
4914
|
"--interval": "string",
|
|
2509
4915
|
"--retry-grace": "string",
|
|
4916
|
+
"--harness-cap": "string",
|
|
2510
4917
|
});
|
|
2511
4918
|
if (!parsed.ok)
|
|
2512
4919
|
return configurationError(parsed.message);
|
|
2513
4920
|
if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
|
|
2514
|
-
|
|
4921
|
+
// APRV-243. Grok's help carries the `.grok/hooks/*.json` the human commits,
|
|
4922
|
+
// because that file's `timeout` is load-bearing: it must exceed `--timeout`
|
|
4923
|
+
// or Grok abandons the hook mid-wait and, failing open, runs the command.
|
|
4924
|
+
// Muse gets its own for the same reason and one more: its `.muse/hooks.json`
|
|
4925
|
+
// shape is not Claude's file under a different name, and an operator who
|
|
4926
|
+
// guessed would get "Hooks: 0 runnable" and a session that looks gated.
|
|
4927
|
+
// Hermes gets its own for a third reason on top of those two (APRV-398):
|
|
4928
|
+
// the file the human commits is YAML, it is not in the repository at all
|
|
4929
|
+
// (`$HERMES_HOME/config.yaml`), and the entry carries the one key that
|
|
4930
|
+
// decides whether this adapter is a gate or a backstop — `fail_closed`. A
|
|
4931
|
+
// reader who copied a JSON block from another harness's help would install
|
|
4932
|
+
// nothing.
|
|
4933
|
+
const harnessHelp = adapter.kind === "grok"
|
|
4934
|
+
? HOOK_GROK_HELP
|
|
4935
|
+
: adapter.kind === "muse"
|
|
4936
|
+
? HOOK_MUSE_HELP
|
|
4937
|
+
: adapter.kind === "hermes"
|
|
4938
|
+
? HOOK_HERMES_HELP
|
|
4939
|
+
: HOOK_HELP;
|
|
4940
|
+
streams.out(`${harnessHelp}\n`);
|
|
2515
4941
|
return EXIT_OK;
|
|
2516
4942
|
}
|
|
2517
4943
|
const extra = parsed.positionals[0];
|
|
@@ -2541,16 +4967,94 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2541
4967
|
if (graceMs === null) {
|
|
2542
4968
|
return configurationError(`--retry-grace expects a duration like 5m, 30s, 1ms, got ${JSON.stringify(graceText)}`);
|
|
2543
4969
|
}
|
|
2544
|
-
|
|
4970
|
+
// APRV-423. The ceiling this process runs under, which the hook cannot
|
|
4971
|
+
// discover for itself: the harness spawns it and tells it nothing. The
|
|
4972
|
+
// OPERATOR states the entry timeout they configured, and the adapter
|
|
4973
|
+
// contributes the harness's own documented maximum where its contract has
|
|
4974
|
+
// one. The smaller wins, because a stated cap above a contractual ceiling is
|
|
4975
|
+
// a statement the harness will not honour, and a cap may only ever shorten
|
|
4976
|
+
// the window (SPEC.md §11.1 invariant 4).
|
|
4977
|
+
const capText = stringFlag(parsed.flags, "--harness-cap");
|
|
4978
|
+
const statedCapMs = capText === null ? null : parseDuration(capText);
|
|
4979
|
+
if (capText !== null && statedCapMs === null) {
|
|
4980
|
+
return configurationError(`--harness-cap expects a duration like 300s, 4m, got ${JSON.stringify(capText)}`);
|
|
4981
|
+
}
|
|
4982
|
+
// Second review pass. A stated cap is clamped to the contractual ceiling
|
|
4983
|
+
// (Hermes will not honour an entry above 300 s however it is written); an
|
|
4984
|
+
// absent flag takes the harness's DEFAULT bound where the adapter documents
|
|
4985
|
+
// one, and only then falls back to the ceiling. The default is the stricter
|
|
4986
|
+
// number on purpose: an operator who has said nothing is assumed to run the
|
|
4987
|
+
// harness as shipped, and a runtime that assumed the ceiling instead would
|
|
4988
|
+
// assert a deadline the harness will not keep (APRV-410 with a fictional
|
|
4989
|
+
// T+240 s, which is exactly what the first pass did on a default Hermes).
|
|
4990
|
+
const capMs = effectiveHarnessCapMs(adapter, statedCapMs);
|
|
4991
|
+
// APRV-427 review: charged for the time the call spent before this line,
|
|
4992
|
+
// using the remainder the embedding caller admitted it on, unchanged. See
|
|
4993
|
+
// HookWaitSeam.remainingCapMs.
|
|
4994
|
+
const remainingCapMs = waitSeam?.remainingCapMs;
|
|
4995
|
+
const harnessCapMs = capMs === null || remainingCapMs === undefined
|
|
4996
|
+
? capMs
|
|
4997
|
+
: Math.max(0, Math.min(capMs, remainingCapMs));
|
|
4998
|
+
const harnessCapStated = statedCapMs !== null;
|
|
4999
|
+
const parsedInput = parseHookInput(readStdin(), adapter.camelCaseEnvelope === true);
|
|
2545
5000
|
if (!parsedInput.ok)
|
|
2546
5001
|
return deny(streams, "hook-io", parsedInput.detail, adapter.kind);
|
|
2547
5002
|
const input = parsedInput.input;
|
|
2548
5003
|
if (adapter.kind === "codex") {
|
|
2549
5004
|
const checked = checkCodexHookInput(input, cwd);
|
|
2550
|
-
if (!checked.ok)
|
|
2551
|
-
|
|
5005
|
+
if (!checked.ok) {
|
|
5006
|
+
// APRV-311. WHICH PHASE the malformed event belongs to decides which
|
|
5007
|
+
// vocabulary refuses it, and until now both took the pre-execution one.
|
|
5008
|
+
// A `PostToolUse` event naming an unsupported tool, or carrying an id
|
|
5009
|
+
// this adapter will not accept, printed
|
|
5010
|
+
// `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"}}`
|
|
5011
|
+
// at exit 0: a permission decision about a call that has already run, in
|
|
5012
|
+
// the same words the pre-execution refusal uses, so nothing downstream
|
|
5013
|
+
// could tell the two apart. Nothing was ever going to be authorized here.
|
|
5014
|
+
//
|
|
5015
|
+
// The strict answer on this side is the one the rest of the post path
|
|
5016
|
+
// already gives: say so on stderr at the exit code that makes the line
|
|
5017
|
+
// visible, append nothing, and print no verdict. The event name is read
|
|
5018
|
+
// off the raw input rather than off the check, because the check is what
|
|
5019
|
+
// just failed.
|
|
5020
|
+
return input.hookEventName === CODEX_POST_TOOL_EVENT
|
|
5021
|
+
? report(streams, "post-tool-io", `${checked.detail}; nothing was appended, so the start this event would have closed is still open`)
|
|
5022
|
+
: deny(streams, "hook-io", checked.detail, adapter.kind);
|
|
5023
|
+
}
|
|
2552
5024
|
}
|
|
2553
5025
|
const codexCommand = adapter.kind === "codex" ? codexBinding(input, cwd).payload.command : undefined;
|
|
5026
|
+
// APRV-350. The contributor guard runs BEFORE the event dispatch, the policy
|
|
5027
|
+
// and everything else, because it is not a question about the action: a
|
|
5028
|
+
// Contributor-tier session discloses every byte it reads, so there is nothing
|
|
5029
|
+
// for a policy to authorize. Refusing first also means no unparseable payload,
|
|
5030
|
+
// no missing log and no classifier quirk can route around it.
|
|
5031
|
+
//
|
|
5032
|
+
// On a POST event it refuses in the post vocabulary and prints no verdict:
|
|
5033
|
+
// Muse rejects a permission field on a post event, and a rejected hook is a
|
|
5034
|
+
// FAILED hook, which fails open. There is nothing to block after the fact
|
|
5035
|
+
// anyway; the line exists so the disclosure is on a stream somebody reads.
|
|
5036
|
+
if (adapter.contributorModelGuard === true) {
|
|
5037
|
+
const refusal = contributorModelRefusal(input.model);
|
|
5038
|
+
if (refusal !== null) {
|
|
5039
|
+
const postEvent = isPostToolEvent(adapter, input.hookEventName);
|
|
5040
|
+
return postEvent
|
|
5041
|
+
? report(streams, MUSE_CONTRIBUTOR_REFUSAL, `${refusal} Nothing was appended.`)
|
|
5042
|
+
: deny(streams, MUSE_CONTRIBUTOR_REFUSAL, refusal, adapter.kind);
|
|
5043
|
+
}
|
|
5044
|
+
}
|
|
5045
|
+
// APRV-350. The harness's own bookkeeping tool, answered and never gated.
|
|
5046
|
+
// Muse fires both events for `submit_reminder_decision` continuously — 100 of
|
|
5047
|
+
// the 139 events in the live capture — and it records a self-assessment and
|
|
5048
|
+
// touches nothing. It is answered with an explicit allow rather than left to
|
|
5049
|
+
// fall through, so the verdict is one dialect and nothing else, which is the
|
|
5050
|
+
// only shape this harness parses.
|
|
5051
|
+
if (adapter.passThroughTools?.includes(input.toolName) === true) {
|
|
5052
|
+
const passPostEvent = isPostToolEvent(adapter, input.hookEventName);
|
|
5053
|
+
if (passPostEvent) {
|
|
5054
|
+
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`);
|
|
5055
|
+
}
|
|
5056
|
+
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);
|
|
5057
|
+
}
|
|
2554
5058
|
// APRV-145: WHICH EVENT THIS IS, read first and read at all. One command is
|
|
2555
5059
|
// registered for two events, and they do opposite things — one answers before
|
|
2556
5060
|
// the tool runs, the other records how it went — so the dispatch is the first
|
|
@@ -2561,9 +5065,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2561
5065
|
// harness whose event this runtime does not recognize is a harness about to
|
|
2562
5066
|
// run a command, and treating an unknown name as a no-op would be an ungated
|
|
2563
5067
|
// one.
|
|
2564
|
-
const postToolEvent = adapter.
|
|
2565
|
-
? input.hookEventName === CODEX_POST_TOOL_EVENT
|
|
2566
|
-
: input.hookEventName !== null && POST_TOOL_EVENTS.includes(input.hookEventName);
|
|
5068
|
+
const postToolEvent = isPostToolEvent(adapter, input.hookEventName);
|
|
2567
5069
|
if (postToolEvent) {
|
|
2568
5070
|
// APRV-303. `commandHarnessHook`'s catch turns a throw into a DENY, which is
|
|
2569
5071
|
// the right answer for a call that has not run yet and exactly the wrong one
|
|
@@ -2571,11 +5073,27 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2571
5073
|
// already finished, and the reason the counterpart did not land would be
|
|
2572
5074
|
// dressed as a permission decision. A throw on this path is `post-tool-io`,
|
|
2573
5075
|
// on stderr, at the exit code that makes the line visible.
|
|
5076
|
+
//
|
|
5077
|
+
// APRV-243. Grok Build reads exit 2 as DENY, full stop, so the visibility
|
|
5078
|
+
// exit this path uses everywhere else would be a permission decision about
|
|
5079
|
+
// a tool call that has already run. On this one harness the post-execution
|
|
5080
|
+
// path therefore always exits 0. The machine-readable line still goes to
|
|
5081
|
+
// stderr; whether Grok shows it is Grok's business, and losing a debug line
|
|
5082
|
+
// is a smaller harm than emitting a verdict the protocol will act on.
|
|
5083
|
+
//
|
|
5084
|
+
// APRV-398. Hermes joins Grok on that rule, and for the same reason read off
|
|
5085
|
+
// its own contract: Hermes documents exit 2 as BLOCKING, so a visibility
|
|
5086
|
+
// exit here would be a block aimed at a tool call that has already run.
|
|
5087
|
+
// This adapter's whole stance on this harness is that the exit status
|
|
5088
|
+
// carries no verdict (see `HERMES_DENY_EXIT`), and that has to hold on both
|
|
5089
|
+
// halves or it holds nowhere. The machine-readable line still goes to
|
|
5090
|
+
// stderr, and whether Hermes shows it is Hermes's business.
|
|
5091
|
+
const settle = (code) => adapter.kind === "grok" || adapter.kind === "hermes" ? EXIT_OK : code;
|
|
2574
5092
|
try {
|
|
2575
|
-
return runPostToolUse(parsed.flags, streams, cwd, input, actor, adapter);
|
|
5093
|
+
return settle(runPostToolUse(parsed.flags, streams, cwd, input, actor, adapter));
|
|
2576
5094
|
}
|
|
2577
5095
|
catch (cause) {
|
|
2578
|
-
return report(streams, "post-tool-io", `the counterpart failed: ${cause instanceof Error ? cause.message : String(cause)}; nothing was appended, so the start this event would have closed is still open`);
|
|
5096
|
+
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`));
|
|
2579
5097
|
}
|
|
2580
5098
|
}
|
|
2581
5099
|
// Codex 0.152.1 can execute Bash in a per-call working directory that is
|
|
@@ -2585,10 +5103,72 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2585
5103
|
// harness executes. Refuse before the open-window, gate-self, carry, or
|
|
2586
5104
|
// registration paths; none of those can supply the missing directory fact.
|
|
2587
5105
|
if (adapter.kind === "codex" && input.toolName === "Bash") {
|
|
2588
|
-
return deny(streams, "hook-
|
|
5106
|
+
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);
|
|
5107
|
+
}
|
|
5108
|
+
// APRV-398, and placed exactly where the Codex refusal above is placed, for
|
|
5109
|
+
// exactly its reason: before the open window, the gate-self path, the carry
|
|
5110
|
+
// and the registration, because none of those can supply a fact the call does
|
|
5111
|
+
// not carry. Hermes's `execute_code` takes a `code` string and nothing else —
|
|
5112
|
+
// no path, no argv, no working directory — so there is nothing for the
|
|
5113
|
+
// classifier to read and nothing a payload could bind. A verdict over
|
|
5114
|
+
// `{code}` would authorize a program this runtime never parsed.
|
|
5115
|
+
//
|
|
5116
|
+
// It is refused rather than classified `hook-opaque` because the repair
|
|
5117
|
+
// differs: an opaque command line can be rewritten, and there is no spelling
|
|
5118
|
+
// of an `execute_code` call that this hook could answer differently. Nothing
|
|
5119
|
+
// appends on this path and no gate lifecycle opens. An OPEN WINDOW does not
|
|
5120
|
+
// reach it either, which is the strict reading a window deserves here: a
|
|
5121
|
+
// window suspends the policy, and this refusal is not a policy question.
|
|
5122
|
+
if (adapter.kind === "hermes" && input.toolName === HERMES_EXECUTE_CODE_TOOL) {
|
|
5123
|
+
return deny(streams, HERMES_EXECUTE_CODE_REFUSAL, `Hermes ${HERMES_EXECUTE_CODE_TOOL} is refused because the call carries a program and nothing else: no path, no argv and no working directory, so no class can be resolved and no payload can bind what it would do. Run the work through the \`${adapter.shellTool}\` tool, where the words are visible to the classifier, or through \`approval run\` with a granted token. No policy or open window authorizes bytes the hook cannot read`, adapter.kind);
|
|
5124
|
+
}
|
|
5125
|
+
// APRV-415, and placed here for the reason the two refusals above are: before
|
|
5126
|
+
// the open window, the gate-self path, the carry and the registration, because
|
|
5127
|
+
// none of those can supply the directory a Hermes call leaves out. The live
|
|
5128
|
+
// probe established that this harness reports NO effective directory for a
|
|
5129
|
+
// call that does not state one — see {@link hermesUnboundDirectory} — so the
|
|
5130
|
+
// refusal is the Codex `Bash` refusal one harness along, narrowed to the calls
|
|
5131
|
+
// that actually withhold the fact.
|
|
5132
|
+
if (adapter.kind === "hermes") {
|
|
5133
|
+
const unbound = hermesUnboundDirectory(input, adapter);
|
|
5134
|
+
if (unbound !== null) {
|
|
5135
|
+
return deny(streams, "hook-unsupported-execution-context", unbound, adapter.kind);
|
|
5136
|
+
}
|
|
5137
|
+
}
|
|
5138
|
+
// APRV-445: a tool the adapter's own rule table gates goes on to the gated
|
|
5139
|
+
// path below; one it reads is allowed here, with no policy load, exactly as
|
|
5140
|
+
// an in-scope read is.
|
|
5141
|
+
const ruledEarly = toolRuleOf(adapter, input);
|
|
5142
|
+
if (ruledEarly?.kind === "read") {
|
|
5143
|
+
return allow(streams, ruledEarly.reason, adapter.kind, codexCommand);
|
|
2589
5144
|
}
|
|
2590
|
-
if (
|
|
2591
|
-
|
|
5145
|
+
if (ruledEarly === null &&
|
|
5146
|
+
input.toolName !== adapter.shellTool &&
|
|
5147
|
+
!adapter.fileTools.includes(input.toolName)) {
|
|
5148
|
+
if (!adapter.readTools.includes(input.toolName)) {
|
|
5149
|
+
return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
|
|
5150
|
+
}
|
|
5151
|
+
// APRV-347, and the placement is the whole of its cost. A read tool call is
|
|
5152
|
+
// the most frequent event a session produces, and before this task it was
|
|
5153
|
+
// answered here with no policy load, no verified read of the log and no
|
|
5154
|
+
// window lookup. Everything below this line is expensive, so the reads that
|
|
5155
|
+
// do not need it must not pay for it.
|
|
5156
|
+
//
|
|
5157
|
+
// The short-circuit is sound because `read_scope` is ADDITIVE: it can only
|
|
5158
|
+
// widen the roots, so a target already inside the BUILT-IN roots is inside
|
|
5159
|
+
// the effective ones whatever the policy says, and the policy need not be
|
|
5160
|
+
// read to know it. A target outside them falls through, the policy is
|
|
5161
|
+
// loaded below, and `describeToolCall` asks again with the widened set —
|
|
5162
|
+
// which is where a policy-declared root actually takes effect.
|
|
5163
|
+
//
|
|
5164
|
+
// The cost of the fast path is a handful of `realpath` calls and no process
|
|
5165
|
+
// spawn, provided the hook is configured with `--dir` as the docs show
|
|
5166
|
+
// (otherwise `hookScope` runs `git rev-parse` to find the primary).
|
|
5167
|
+
const early = hookScope(parsed.flags, cwd);
|
|
5168
|
+
if (credentialReadGate(input.toolName, input.toolInput, cwd) === null &&
|
|
5169
|
+
readToolGate(input.toolName, input.toolInput, resolveReadRoots(cwd, early.root), cwd) === null) {
|
|
5170
|
+
return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
|
|
5171
|
+
}
|
|
2592
5172
|
}
|
|
2593
5173
|
// APRV-188. From here on this process may resume a verified read behind the
|
|
2594
5174
|
// snapshot the daemon published, instead of walking the chain from genesis:
|
|
@@ -2625,6 +5205,49 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2625
5205
|
// verdict and the record are one read of the log.
|
|
2626
5206
|
looked.records === null ? null : { records: looked.records, head: looked.head });
|
|
2627
5207
|
}
|
|
5208
|
+
return renderVerdict(streams, adapter, codexCommand, decideHarnessCall({
|
|
5209
|
+
streams,
|
|
5210
|
+
input,
|
|
5211
|
+
adapter,
|
|
5212
|
+
cwd,
|
|
5213
|
+
logPath,
|
|
5214
|
+
root,
|
|
5215
|
+
options,
|
|
5216
|
+
actor,
|
|
5217
|
+
timeoutMs,
|
|
5218
|
+
intervalMs,
|
|
5219
|
+
graceMs,
|
|
5220
|
+
harnessCapMs,
|
|
5221
|
+
harnessCapStated,
|
|
5222
|
+
// The ceiling before the time in line was charged, for the refusal's
|
|
5223
|
+
// wording only: the charged number is the one everything is judged by.
|
|
5224
|
+
...(harnessCapMs !== capMs && capMs !== null ? { harnessCapCeilingMs: capMs } : {}),
|
|
5225
|
+
codexCommand,
|
|
5226
|
+
windowRecords: looked.records,
|
|
5227
|
+
waitSeam,
|
|
5228
|
+
}));
|
|
5229
|
+
}
|
|
5230
|
+
/**
|
|
5231
|
+
* Classify, resolve, gate and wait: one harness tool call, from the event to a
|
|
5232
|
+
* verdict (APRV-361).
|
|
5233
|
+
*
|
|
5234
|
+
* Extracted from the hook's own verb so a SECOND caller can reach a decision
|
|
5235
|
+
* through exactly this sequence. `cli/codex-bridge.ts` answers Codex's
|
|
5236
|
+
* app-server approval requests over JSON-RPC rather than on stdout, and the
|
|
5237
|
+
* thing it must not do is re-implement any of what is below: the human-only
|
|
5238
|
+
* refusal, the unruled `harness.launch.*` refusal, the sandbox requirement, the
|
|
5239
|
+
* loop floor, the unattended guard, the autonomous charge, and the register,
|
|
5240
|
+
* request and wait that follow. Two implementations of that sequence would be
|
|
5241
|
+
* two gates, and the second one would be the one nobody reviewed.
|
|
5242
|
+
*
|
|
5243
|
+
* It returns a verdict and prints none. `streams.err` still carries the
|
|
5244
|
+
* progress and withdrawal lines, which are a report rather than a decision.
|
|
5245
|
+
*/
|
|
5246
|
+
export function decideHarnessCall(decide) {
|
|
5247
|
+
const { streams, input, adapter, cwd, logPath, root, options, actor, timeoutMs, intervalMs, graceMs, codexCommand, windowRecords, } = decide;
|
|
5248
|
+
const harnessCapMs = decide.harnessCapMs ?? null;
|
|
5249
|
+
const harnessCapStated = decide.harnessCapStated ?? false;
|
|
5250
|
+
const harnessCapCeilingMs = decide.harnessCapCeilingMs ?? null;
|
|
2628
5251
|
// The policy is read BEFORE the command is classified (APRV-107): the
|
|
2629
5252
|
// protected-path set is built-ins plus `policy.protected_paths`, so what
|
|
2630
5253
|
// counts as a protected path is a policy question and the classifier cannot be
|
|
@@ -2637,25 +5260,36 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2637
5260
|
? { dir: options.policy?.dir ?? cwd }
|
|
2638
5261
|
: { file: options.policy.file });
|
|
2639
5262
|
if (!load.ok) {
|
|
2640
|
-
return
|
|
5263
|
+
return {
|
|
5264
|
+
permission: "deny",
|
|
5265
|
+
code: "hook-policy-unavailable",
|
|
5266
|
+
detail: `${load.code}: ${load.message}; every class resolves to manual and the hook cannot verify a decision`,
|
|
5267
|
+
};
|
|
2641
5268
|
}
|
|
2642
5269
|
const protectedPaths = load.policy.protected_paths ?? [];
|
|
5270
|
+
// APRV-347. Resolved from the LOADED policy's own directory, so the scope is
|
|
5271
|
+
// anchored to the gate a decision will be recorded in, and widened by
|
|
5272
|
+
// `read_scope.roots` if that policy declares any.
|
|
5273
|
+
const readRoots = resolveReadRoots(cwd, policyRootOf(load, root), load.policy.read_scope?.roots);
|
|
2643
5274
|
// What is being asked for, as one or more classes. One description site for
|
|
2644
5275
|
// both paths since APRV-214 (see `describeToolCall`): the open window
|
|
2645
5276
|
// classifies exactly as the closed one does, and a second copy of this would
|
|
2646
5277
|
// be a second answer to "what is this command".
|
|
2647
|
-
const described = describeToolCall(input, adapter, protectedPaths, cwd);
|
|
5278
|
+
const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots);
|
|
2648
5279
|
if (described.kind === "deny") {
|
|
2649
|
-
return deny
|
|
5280
|
+
return { permission: "deny", code: described.code, detail: described.detail };
|
|
2650
5281
|
}
|
|
2651
5282
|
if (described.kind === "allow") {
|
|
2652
|
-
return allow
|
|
5283
|
+
return { permission: "allow", reason: described.reason };
|
|
2653
5284
|
}
|
|
2654
5285
|
const { classes, payload, headline } = described;
|
|
2655
5286
|
/** What the history-rewrite refinement did, for the decision reason. */
|
|
2656
5287
|
const notes = [...described.notes];
|
|
2657
5288
|
if (classes.length === 0) {
|
|
2658
|
-
return
|
|
5289
|
+
return {
|
|
5290
|
+
permission: "allow",
|
|
5291
|
+
reason: "the approval CLI is the gate itself and is not gated by it",
|
|
5292
|
+
};
|
|
2659
5293
|
}
|
|
2660
5294
|
// Every path from here needs the log, the fast paths included (APRV-139):
|
|
2661
5295
|
// attestation and loop-escalation are facts about the log, so the
|
|
@@ -2664,7 +5298,11 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2664
5298
|
// on-disk policy called autonomous; it now denies, which is the same answer
|
|
2665
5299
|
// it already gave every other class.
|
|
2666
5300
|
if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
|
|
2667
|
-
return
|
|
5301
|
+
return {
|
|
5302
|
+
permission: "deny",
|
|
5303
|
+
code: "hook-log-unreachable",
|
|
5304
|
+
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`,
|
|
5305
|
+
};
|
|
2668
5306
|
}
|
|
2669
5307
|
// Minted once, here, and carried into `gateAndWait`: the loop-escalation
|
|
2670
5308
|
// check below and any registration that follows must name the same task.
|
|
@@ -2672,6 +5310,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2672
5310
|
? codexBinding(input, cwd).task
|
|
2673
5311
|
: `hook:${input.sessionId}:${input.toolUseId ?? randomBytes(8).toString("hex")}`;
|
|
2674
5312
|
const run = {
|
|
5313
|
+
waitSeam: decide.waitSeam ?? null,
|
|
2675
5314
|
logPath,
|
|
2676
5315
|
options,
|
|
2677
5316
|
actor,
|
|
@@ -2679,6 +5318,13 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2679
5318
|
intervalMs,
|
|
2680
5319
|
graceMs,
|
|
2681
5320
|
ttlMs: load.durations.approvalTtlMs,
|
|
5321
|
+
harnessCapMs,
|
|
5322
|
+
harnessCapStated,
|
|
5323
|
+
harnessCapCeilingMs,
|
|
5324
|
+
harnessCapRepair: adapter.capRepair ?? null,
|
|
5325
|
+
waitMs: harnessCapMs === null
|
|
5326
|
+
? timeoutMs
|
|
5327
|
+
: Math.max(0, Math.min(timeoutMs, harnessCapMs - HARNESS_CAP_MARGIN_MS)),
|
|
2682
5328
|
harness: adapter.kind,
|
|
2683
5329
|
originApp: adapter.originApp,
|
|
2684
5330
|
...(codexCommand === undefined ? {} : { codexCommand }),
|
|
@@ -2690,7 +5336,26 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2690
5336
|
// state.
|
|
2691
5337
|
channels: Object.keys(load.policy.channels ?? {}).sort(),
|
|
2692
5338
|
};
|
|
2693
|
-
const
|
|
5339
|
+
const resolutions = classes.map((cls) => resolvePolicy(load, cls));
|
|
5340
|
+
const autonomies = resolutions.map((resolution) => resolution.autonomy);
|
|
5341
|
+
// APRV-354, and ABOVE the human-only deny because it is the narrower
|
|
5342
|
+
// statement: this class is not merely reserved, it is one the policy has not
|
|
5343
|
+
// spoken about at all, and the repair is a line rather than a person.
|
|
5344
|
+
//
|
|
5345
|
+
// A `harness.launch.*` class resolves only under a rule an operator wrote. A
|
|
5346
|
+
// policy naming neither the family nor the member refuses the launch instead
|
|
5347
|
+
// of falling to `defaults.autonomy`, which keeps arrival exactly as strict as
|
|
5348
|
+
// it was before the family existed (`hook-unclassified`, refused) until
|
|
5349
|
+
// somebody opts in. See `harnessLaunchNeedsRule` for why the family gets this
|
|
5350
|
+
// and no other class does.
|
|
5351
|
+
const unruled = classes.find((cls, index) => harnessLaunchNeedsRule(cls, resolutions[index]));
|
|
5352
|
+
if (unruled !== undefined) {
|
|
5353
|
+
return {
|
|
5354
|
+
permission: "deny",
|
|
5355
|
+
code: "hook-harness-launch-unruled",
|
|
5356
|
+
detail: harnessLaunchUnruledRefusal(unruled, "this command may not run under an agent"),
|
|
5357
|
+
};
|
|
5358
|
+
}
|
|
2694
5359
|
// APRV-185, amended SPEC.md §5.2, and the first verdict this function reaches
|
|
2695
5360
|
// once the classes have autonomies. A command touching a class the policy
|
|
2696
5361
|
// reserves to human hands is denied outright: no request is opened, no task is
|
|
@@ -2706,7 +5371,11 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2706
5371
|
// strictest thing in it.
|
|
2707
5372
|
const reserved = classes.find((_cls, index) => autonomies[index] === "human-only");
|
|
2708
5373
|
if (reserved !== undefined) {
|
|
2709
|
-
return
|
|
5374
|
+
return {
|
|
5375
|
+
permission: "deny",
|
|
5376
|
+
code: "hook-class-human-only",
|
|
5377
|
+
detail: `${humanOnlyRefusal(reserved, "this command may not run under an agent")} The gate's own code for this fact is \`class-human-only\`.`,
|
|
5378
|
+
};
|
|
2710
5379
|
}
|
|
2711
5380
|
// APRV-193, and BELOW the human-only deny for the same reason that one sits
|
|
2712
5381
|
// above the floor: a class no agent may run is answered before a question
|
|
@@ -2714,7 +5383,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2714
5383
|
// refused command leaves the log exactly as it found it.
|
|
2715
5384
|
const unsandboxed = sandboxRequirement(described.segments, autonomies);
|
|
2716
5385
|
if (unsandboxed !== null) {
|
|
2717
|
-
return deny
|
|
5386
|
+
return { permission: "deny", code: "hook-sandbox-required", detail: unsandboxed };
|
|
2718
5387
|
}
|
|
2719
5388
|
// APRV-145, amended SPEC.md §10.2: loop safety on a surface that mints a
|
|
2720
5389
|
// fresh task id per tool call. The floor is applied AFTER class resolution and
|
|
@@ -2729,9 +5398,9 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2729
5398
|
// have proceeded is routed to the human gate for this invocation (APRV-297
|
|
2730
5399
|
// narrowed it to those); a class that already resolves manual is untouched,
|
|
2731
5400
|
// because it was already going there.
|
|
2732
|
-
const floored = harnessFloor(logPath, task, actor,
|
|
5401
|
+
const floored = harnessFloor(logPath, task, actor, windowRecords);
|
|
2733
5402
|
if (!floored.ok)
|
|
2734
|
-
return deny
|
|
5403
|
+
return { permission: "deny", code: "hook-io", detail: floored.detail };
|
|
2735
5404
|
/**
|
|
2736
5405
|
* The streak the log shows, before the read carve-out (APRV-297).
|
|
2737
5406
|
*
|
|
@@ -2780,11 +5449,39 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2780
5449
|
/** No class here needs a human, so nothing downstream will ask for one. */
|
|
2781
5450
|
const unattended = floor === null && autonomies.every((autonomy) => autonomy !== "manual");
|
|
2782
5451
|
if (unattended) {
|
|
2783
|
-
const refused = unattendedGuard(logPath, load.source.path, task,
|
|
2784
|
-
if (refused !== null)
|
|
2785
|
-
return deny
|
|
5452
|
+
const refused = unattendedGuard(logPath, load.source.path, task, windowRecords);
|
|
5453
|
+
if (refused !== null) {
|
|
5454
|
+
return { permission: "deny", code: refused.code, detail: refused.detail };
|
|
5455
|
+
}
|
|
2786
5456
|
}
|
|
2787
5457
|
if (floor === null && autonomies.every((autonomy) => autonomy === "autonomous")) {
|
|
5458
|
+
// APRV-410. The sweep that takes back this actor's abandoned questions used
|
|
5459
|
+
// to live only in `gateHarnessCall`, which an autonomous command never
|
|
5460
|
+
// reaches — so a session whose retry classified autonomously left its
|
|
5461
|
+
// expired question standing until the TTL, and a human's tap on it six
|
|
5462
|
+
// minutes past the grace was recorded as a grant nobody held (log seq
|
|
5463
|
+
// 64473, 2026-09-20). The withdrawal has to be made by the REQUESTER, which
|
|
5464
|
+
// is this actor: `withdraw` refuses a `system:` actor in as many words, and
|
|
5465
|
+
// the event schema's cross-rule binds a `system:` withdrawal to
|
|
5466
|
+
// `policy-drift` alone (APRV-235), so the daemon is not a candidate and the
|
|
5467
|
+
// next invocation of the asking actor is the only one there is.
|
|
5468
|
+
//
|
|
5469
|
+
// `windowRecords` is the verified read `lookupWindow` already performed on
|
|
5470
|
+
// this invocation, so the sweep costs one append when there is something to
|
|
5471
|
+
// take back and nothing at all when there is not. `withdraw` re-reads and
|
|
5472
|
+
// compare-and-appends against its own head (SPEC.md §11.1 invariant 5), so
|
|
5473
|
+
// a question decided between that read and this call refuses
|
|
5474
|
+
// `already-decided` and is passed over rather than overwritten.
|
|
5475
|
+
//
|
|
5476
|
+
// `keepHash` is this invocation's own bytes, exactly as the intake sweep
|
|
5477
|
+
// passes it: a sibling hook process waiting on a question about the same
|
|
5478
|
+
// payload is never taken back from under it.
|
|
5479
|
+
if (windowRecords !== null) {
|
|
5480
|
+
const swept = withdrawAbandoned(run, streams, windowRecords, new Date().toISOString(), payloadHash(payload));
|
|
5481
|
+
if (swept.length > 0) {
|
|
5482
|
+
streams.err(`approval: withdrew ${String(swept.length)} abandoned harness request(s) nothing retried (${swept.join(", ")}); a tap on one of them now authorizes nothing\n`);
|
|
5483
|
+
}
|
|
5484
|
+
}
|
|
2788
5485
|
// No approval lifecycle: an autonomous action has none (amended SPEC.md
|
|
2789
5486
|
// §6.3), so nothing is requested, decided or granted here. What IS appended
|
|
2790
5487
|
// since APRV-141 is the execution record itself — the moment the policy
|
|
@@ -2792,9 +5489,13 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2792
5489
|
// charge is not a budget. See `recordUnattended`.
|
|
2793
5490
|
const charged = recordUnattended(run, task, classes, payloadHash(payload));
|
|
2794
5491
|
if (charged !== null) {
|
|
2795
|
-
return
|
|
5492
|
+
return {
|
|
5493
|
+
permission: "deny",
|
|
5494
|
+
code: `hook-gate-refused:${charged.code}`,
|
|
5495
|
+
detail: charged.message,
|
|
5496
|
+
};
|
|
2796
5497
|
}
|
|
2797
|
-
return allow
|
|
5498
|
+
return { permission: "allow", reason: `autonomous: ${classes.join(", ")}${note}` };
|
|
2798
5499
|
}
|
|
2799
5500
|
// Past here the hook appends. It writes to a log that already exists and
|
|
2800
5501
|
// creates none: a log the hook scaffolded where it happened to be standing
|
|
@@ -2802,11 +5503,14 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2802
5503
|
// do not survive a merge. An initialized-but-empty `.approval/log/` counts as
|
|
2803
5504
|
// reachable — an audit trail that has recorded nothing is an empty log, not a
|
|
2804
5505
|
// missing one (see `preflightLog`) — and `register` appends the first line.
|
|
2805
|
-
return
|
|
5506
|
+
return gateHarnessCall(streams, run, classes, payload, headline, task, note, floor);
|
|
2806
5507
|
}
|
|
2807
|
-
function commandHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
5508
|
+
function commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
5509
|
+
if (adapter.kind === "hermes") {
|
|
5510
|
+
return hermesFailClosed(argv, streams, cwd, readStdin, adapter, waitSeam);
|
|
5511
|
+
}
|
|
2808
5512
|
try {
|
|
2809
|
-
return runHarnessHook(argv, streams, cwd, readStdin, adapter);
|
|
5513
|
+
return runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam);
|
|
2810
5514
|
}
|
|
2811
5515
|
catch (cause) {
|
|
2812
5516
|
// A hook that throws is a hook the harness treats as a non-blocking error,
|
|
@@ -2816,6 +5520,129 @@ function commandHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2816
5520
|
return deny(streams, "hook-io", `the hook failed: ${cause instanceof Error ? cause.message : String(cause)}`, adapter.kind);
|
|
2817
5521
|
}
|
|
2818
5522
|
}
|
|
5523
|
+
/**
|
|
5524
|
+
* The Hermes hook's last line of defence (APRV-445): whatever happened inside,
|
|
5525
|
+
* a `pre_tool_call` answer leaves this function as a verdict Hermes blocks on or
|
|
5526
|
+
* as an explicit allow, and never as anything Hermes would read as an allow by
|
|
5527
|
+
* default.
|
|
5528
|
+
*
|
|
5529
|
+
* Hermes (v2026.9.24, `agent/shell_hooks.py`) blocks on exit 2 whatever stdout
|
|
5530
|
+
* says, and blocks on any other non-zero exit only when stdout carries a block
|
|
5531
|
+
* directive; ANY OTHER NON-ZERO EXIT WITH AN EMPTY STDOUT IS AN ALLOW, with a
|
|
5532
|
+
* warning in a log nobody reads. So this enforces two rules on every path:
|
|
5533
|
+
*
|
|
5534
|
+
* - a non-zero exit always carries the `{action:"block"}` directive on stdout
|
|
5535
|
+
* and is always exactly 2;
|
|
5536
|
+
* - exit 0 on a pre-event is only ever the adapter's own `{}` allow. A path that
|
|
5537
|
+
* returned 0 having printed nothing is a path nobody wrote a verdict for, and
|
|
5538
|
+
* it blocks.
|
|
5539
|
+
*
|
|
5540
|
+
* A post-event is untouched: it never blocks (see the post path), and it prints
|
|
5541
|
+
* nothing on stdout by design. A throw becomes the same block the other
|
|
5542
|
+
* adapters' catch produces.
|
|
5543
|
+
*/
|
|
5544
|
+
function hermesFailClosed(argv, streams, cwd, readStdin, adapter,
|
|
5545
|
+
/** APRV-427: serve's wait seam, passed through untouched (null on the CLI). */
|
|
5546
|
+
waitSeam) {
|
|
5547
|
+
let stdout = "";
|
|
5548
|
+
const tracked = {
|
|
5549
|
+
out: (text) => {
|
|
5550
|
+
stdout += text;
|
|
5551
|
+
streams.out(text);
|
|
5552
|
+
},
|
|
5553
|
+
err: (text) => streams.err(text),
|
|
5554
|
+
};
|
|
5555
|
+
// The post-event question is answered from the raw input, once, before the
|
|
5556
|
+
// run: the run itself may be what fails to read it.
|
|
5557
|
+
let raw = null;
|
|
5558
|
+
const reading = () => {
|
|
5559
|
+
raw ??= readStdin();
|
|
5560
|
+
return raw;
|
|
5561
|
+
};
|
|
5562
|
+
// A signal before the wait installs its own handler. The default disposition
|
|
5563
|
+
// of SIGTERM and SIGINT is to die with nothing on stdout, which Hermes reads
|
|
5564
|
+
// as an allow, and the wait's handler (which withdraws and prints
|
|
5565
|
+
// `hook-interrupted`) exists only from the moment the hook starts waiting.
|
|
5566
|
+
// This guard covers the whole run before and after that stretch; the wait's
|
|
5567
|
+
// handler is prepended, so inside the wait it answers first and exits. CLI
|
|
5568
|
+
// only: under `approval serve` (a wait seam) the process's signals belong to
|
|
5569
|
+
// the server.
|
|
5570
|
+
const onEarlySignal = (signal) => {
|
|
5571
|
+
if (raw !== null && isHermesPostEvent(raw))
|
|
5572
|
+
process.exit(EXIT_OK);
|
|
5573
|
+
if (stdout.length === 0) {
|
|
5574
|
+
try {
|
|
5575
|
+
writeSync(1, harnessBlockDirective("hook-interrupted", `the hook received ${signal} before it reached a verdict; nothing authorizes this call`, "hermes").stdout);
|
|
5576
|
+
}
|
|
5577
|
+
catch {
|
|
5578
|
+
// stdout is gone; the exit code below is the whole verdict.
|
|
5579
|
+
}
|
|
5580
|
+
}
|
|
5581
|
+
process.exit(HERMES_DENY_EXIT);
|
|
5582
|
+
};
|
|
5583
|
+
const onEarlyTerm = () => onEarlySignal("SIGTERM");
|
|
5584
|
+
const onEarlyInt = () => onEarlySignal("SIGINT");
|
|
5585
|
+
if (waitSeam === null) {
|
|
5586
|
+
process.on("SIGTERM", onEarlyTerm);
|
|
5587
|
+
process.on("SIGINT", onEarlyInt);
|
|
5588
|
+
}
|
|
5589
|
+
try {
|
|
5590
|
+
return hermesVerdict();
|
|
5591
|
+
}
|
|
5592
|
+
finally {
|
|
5593
|
+
if (waitSeam === null) {
|
|
5594
|
+
process.off("SIGTERM", onEarlyTerm);
|
|
5595
|
+
process.off("SIGINT", onEarlyInt);
|
|
5596
|
+
}
|
|
5597
|
+
}
|
|
5598
|
+
function hermesVerdict() {
|
|
5599
|
+
let code;
|
|
5600
|
+
try {
|
|
5601
|
+
code = runHarnessHook(argv, tracked, cwd, reading, adapter, waitSeam);
|
|
5602
|
+
}
|
|
5603
|
+
catch (cause) {
|
|
5604
|
+
// A post-event never blocks: the call already ran (see the post path).
|
|
5605
|
+
if (raw !== null && isHermesPostEvent(raw))
|
|
5606
|
+
return EXIT_OK;
|
|
5607
|
+
if (stdout.length > 0) {
|
|
5608
|
+
// A verdict was already printed; one more object would be unparseable.
|
|
5609
|
+
return HERMES_DENY_EXIT;
|
|
5610
|
+
}
|
|
5611
|
+
return deny(tracked, "hook-io", `the hook failed: ${cause instanceof Error ? cause.message : String(cause)}`, adapter.kind);
|
|
5612
|
+
}
|
|
5613
|
+
const post = raw !== null && isHermesPostEvent(raw);
|
|
5614
|
+
if (post)
|
|
5615
|
+
return code;
|
|
5616
|
+
const blocked = /"action"\s*:\s*"block"/u.test(stdout);
|
|
5617
|
+
if (code === EXIT_OK && stdout.length > 0)
|
|
5618
|
+
return code;
|
|
5619
|
+
if (code === EXIT_OK || !blocked) {
|
|
5620
|
+
if (stdout.length > 0) {
|
|
5621
|
+
// Something that is not a block is already on stdout, and a second
|
|
5622
|
+
// object after it is unparseable stdout, which `fail_closed` blocks and a
|
|
5623
|
+
// build without it allows. The exit code is the verdict that cannot be
|
|
5624
|
+
// misread, so it carries the block.
|
|
5625
|
+
return HERMES_DENY_EXIT;
|
|
5626
|
+
}
|
|
5627
|
+
return deny(tracked, "hook-io", code === EXIT_OK
|
|
5628
|
+
? "the hook reached no verdict for this call; nothing authorizes it"
|
|
5629
|
+
: `the hook exited ${String(code)} without a verdict; nothing authorizes this call`, adapter.kind);
|
|
5630
|
+
}
|
|
5631
|
+
return HERMES_DENY_EXIT;
|
|
5632
|
+
}
|
|
5633
|
+
}
|
|
5634
|
+
/** Is this raw Hermes event a `post_tool_call`? Unreadable input is not. */
|
|
5635
|
+
function isHermesPostEvent(raw) {
|
|
5636
|
+
try {
|
|
5637
|
+
const parsed = JSON.parse(raw);
|
|
5638
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
5639
|
+
return false;
|
|
5640
|
+
return parsed["hook_event_name"] === HERMES_POST_TOOL_EVENT;
|
|
5641
|
+
}
|
|
5642
|
+
catch {
|
|
5643
|
+
return false;
|
|
5644
|
+
}
|
|
5645
|
+
}
|
|
2819
5646
|
// ===========================================================================
|
|
2820
5647
|
// Dispatch
|
|
2821
5648
|
// ===========================================================================
|
|
@@ -2823,7 +5650,13 @@ function commandHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2823
5650
|
function defaultStdin() {
|
|
2824
5651
|
return readFileSync(0, "utf8");
|
|
2825
5652
|
}
|
|
2826
|
-
export function commandHook(argv, streams, cwd, readStdin = defaultStdin
|
|
5653
|
+
export function commandHook(argv, streams, cwd, readStdin = defaultStdin,
|
|
5654
|
+
/**
|
|
5655
|
+
* APRV-427: where a harness hook's wait begins and ends, for `approval serve`,
|
|
5656
|
+
* which runs the call off its main thread and releases its store lock while
|
|
5657
|
+
* the call polls. Every CLI route omits it. See {@link HookWaitSeam}.
|
|
5658
|
+
*/
|
|
5659
|
+
waitSeam = null) {
|
|
2827
5660
|
const sub = argv[0];
|
|
2828
5661
|
const rest = argv.slice(1);
|
|
2829
5662
|
if (sub === undefined) {
|
|
@@ -2833,17 +5666,14 @@ export function commandHook(argv, streams, cwd, readStdin = defaultStdin) {
|
|
|
2833
5666
|
streams.out(`${HOOK_HELP}\n`);
|
|
2834
5667
|
return EXIT_OK;
|
|
2835
5668
|
}
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
case "codex":
|
|
2842
|
-
return commandHarnessHook(rest, streams, cwd, readStdin, CODEX_ADAPTER);
|
|
2843
|
-
case "classify":
|
|
2844
|
-
return commandClassify(rest, streams, cwd);
|
|
2845
|
-
default:
|
|
2846
|
-
return usageError(streams, `unknown subcommand ${JSON.stringify(sub)} for \`approval hook\``);
|
|
5669
|
+
// APRV-358: one list. `isHarnessKind` is the same predicate the write
|
|
5670
|
+
// boundary and doctor use, so a kind that can be recorded is a kind that can
|
|
5671
|
+
// be invoked, and neither can be added without the other.
|
|
5672
|
+
if (isHarnessKind(sub)) {
|
|
5673
|
+
return commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub], waitSeam);
|
|
2847
5674
|
}
|
|
5675
|
+
if (sub === "classify")
|
|
5676
|
+
return commandClassify(rest, streams, cwd);
|
|
5677
|
+
return usageError(streams, `unknown subcommand ${JSON.stringify(sub)} for \`approval hook\``);
|
|
2848
5678
|
}
|
|
2849
5679
|
//# sourceMappingURL=hook.js.map
|