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/doctor.js
CHANGED
|
@@ -54,23 +54,28 @@
|
|
|
54
54
|
* precisely what this command reports, so they are check failures (exit 1).
|
|
55
55
|
*/
|
|
56
56
|
import { createServer } from "node:net";
|
|
57
|
-
import { closeSync, existsSync, openSync, readFileSync, readdirSync, statSync, unlinkSync, } from "node:fs";
|
|
57
|
+
import { closeSync, existsSync, openSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, } from "node:fs";
|
|
58
|
+
import { homedir } from "node:os";
|
|
58
59
|
import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments } from "node:path";
|
|
59
60
|
import { WEB_DEFAULT_PORT } from "../channels/web.js";
|
|
60
61
|
import { TELEGRAM_DEFAULT_API_BASE, telegramChatEnvFor, telegramTokenEnvFor, } from "../channels/telegram.js";
|
|
61
|
-
import { HUMAN_ACTOR_ENV, checkAttestation, findOrganAttestation, latestOrganAttestation, policyBytesHash, resolveHumanActor, } from "../core/attest.js";
|
|
62
|
-
import { isGateOrganPath } from "../core/command-class.js";
|
|
62
|
+
import { HUMAN_ACTOR_ENV, attesterAt, checkAttestation, findOrganAttestation, findPathSignOff, latestOrganAttestation, latestPathSignOff, policyBytesHash, resolveHumanActor, } from "../core/attest.js";
|
|
63
|
+
import { isGateOrganPath, POLICY_EDIT_SUBCLASS, protectedPathClass, } from "../core/command-class.js";
|
|
63
64
|
import { VALUES_INFO_STRING, loadValues } from "../core/values.js";
|
|
64
65
|
import { KEYSTORE_DEFERRED, NON_RESOLVING_RUNNER, envFilePathFor, resolveEnvironment, } from "../core/env-file.js";
|
|
65
66
|
import { readTaskFile } from "../core/frontmatter.js";
|
|
67
|
+
import { derivedDaemonId, resolveDaemonAllowlist, resolveDaemonId, } from "../core/daemon-host.js";
|
|
68
|
+
import { DAEMON_ID_ENV } from "../core/daemon-identity.js";
|
|
66
69
|
import { instanceFindings, instanceHomeFor, instanceIdFor } from "../core/instance.js";
|
|
70
|
+
import { describeOwners, otherOwnersOf, ownedBot } from "../core/channel-owner.js";
|
|
67
71
|
import { checkLogAnchor } from "./log-anchor.js";
|
|
68
72
|
import { checkLogCheckpoints, checkpointPolicyOf } from "../core/checkpoint.js";
|
|
69
|
-
import { payloadStoreCensus } from "../core/payload-census.js";
|
|
73
|
+
import { payloadIntegrity, payloadStoreCensus } from "../core/payload-census.js";
|
|
70
74
|
import { payloadStoreDirFor } from "../core/payload-store.js";
|
|
71
75
|
import { DEFAULT_TASKS_DIR, latestRegistration } from "../core/registration.js";
|
|
72
76
|
import { POLICY_FILENAMES, loadPolicy } from "../core/policy-load.js";
|
|
73
77
|
import { openObligations } from "../core/audit.js";
|
|
78
|
+
import { SENDER_CHANNELS, SENDER_KEY_ENV, mapsSendersFor, senderKeyFrom, senderMappingForms, } from "../core/sender-identity.js";
|
|
74
79
|
import { classSampling, resolveSampler } from "../core/sampler.js";
|
|
75
80
|
import { checkVault, passphraseEnvFor, passphraseFrom, vaultExists, vaultPathFor, } from "../core/vault.js";
|
|
76
81
|
import { admitSnapshot, logBytes, snapshotPathFor, snapshotSummary, } from "../core/verified-snapshot.js";
|
|
@@ -82,9 +87,13 @@ import { policyWebPort } from "./channel-web.js";
|
|
|
82
87
|
import { EXIT_INTEGRITY, EXIT_IO, EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
|
|
83
88
|
import { RESOLVE_DANGLING_COMMAND, lastAdvance, proveDanglingAdvances, } from "../core/advance-cycle.js";
|
|
84
89
|
import { DEFAULT_DARK_WINDOW_MS, reportDarkSessions, } from "../core/dark-session.js";
|
|
85
|
-
import { HARNESS_BINARY, HARNESS_KINDS, installedHarnessVersion, isHarnessKind, readHarnessProvenance, } from "../core/harness-version.js";
|
|
86
|
-
import { git, repoPath, repoRoot } from "./git-scope.js";
|
|
87
|
-
|
|
90
|
+
import { HARNESS_BINARY, HARNESS_KINDS, HERMES_FAIL_CLOSED_FLOOR, hermesFailClosedSupport, installedHarnessVersion, installedHarnessVersionRaw, isHarnessKind, readHarnessProvenance, } from "../core/harness-version.js";
|
|
91
|
+
import { git, primaryRoot, repoPath, repoRoot } from "./git-scope.js";
|
|
92
|
+
// APRV-408: the gated tool roster of the `harness-hook-wiring` row is READ from
|
|
93
|
+
// the adapter that answers the calls, never restated here. No new export; the
|
|
94
|
+
// map has been exported since APRV-398.
|
|
95
|
+
import { HARNESS_ADAPTERS } from "./hook.js";
|
|
96
|
+
import { ScanError, checkAttestedPolicyOnMain, checkBuildFreshness, checkMainBehindOrigin, installationRoot, } from "./preflight.js";
|
|
88
97
|
import { publishedState } from "./log-advance.js";
|
|
89
98
|
import { DOCTOR_HELP } from "./help.js";
|
|
90
99
|
import { DEFAULT_LOG_PATH, resolvePath } from "./paths.js";
|
|
@@ -246,12 +255,17 @@ function resolvePolicyPath(policyFlag, dir, cwd) {
|
|
|
246
255
|
function checkAttestationHealth(records, policyPath) {
|
|
247
256
|
const status = checkAttestation(records, policyPath);
|
|
248
257
|
switch (status.status) {
|
|
249
|
-
case "attested":
|
|
258
|
+
case "attested": {
|
|
259
|
+
// APRV-449: WHO set the policy in force, from the verified record at that
|
|
260
|
+
// seq and never from the file. A hosted tenant reads this row to learn
|
|
261
|
+
// whether their starting policy is the operator's or their own.
|
|
262
|
+
const by = attesterAt(records, status.seq);
|
|
250
263
|
return {
|
|
251
264
|
check: "attestation",
|
|
252
265
|
status: "pass",
|
|
253
|
-
detail: `${policyPath} is attested at seq ${status.seq} (sha256 ${status.sha256.slice(0, 12)}…)`,
|
|
266
|
+
detail: `${policyPath} is attested at seq ${status.seq}${by === null ? "" : ` by ${by}`} (sha256 ${status.sha256.slice(0, 12)}…)`,
|
|
254
267
|
};
|
|
268
|
+
}
|
|
255
269
|
case "not-attested":
|
|
256
270
|
return {
|
|
257
271
|
check: "attestation",
|
|
@@ -278,6 +292,14 @@ function checkAttestationHealth(records, policyPath) {
|
|
|
278
292
|
// ---------------------------------------------------------------------------
|
|
279
293
|
// 4. log
|
|
280
294
|
// ---------------------------------------------------------------------------
|
|
295
|
+
/**
|
|
296
|
+
* A path as one POSIX shell word: bare when it holds only characters no shell
|
|
297
|
+
* interprets, single-quoted otherwise (`'` closed, escaped and reopened), so a
|
|
298
|
+
* fix command pasted from a row names the file the row is about.
|
|
299
|
+
*/
|
|
300
|
+
function shellWord(path) {
|
|
301
|
+
return /^[A-Za-z0-9_./@:+-]+$/.test(path) ? path : `'${path.split("'").join(`'\\''`)}'`;
|
|
302
|
+
}
|
|
281
303
|
/** The chain verdict, in doctor's vocabulary. Reads; never writes. */
|
|
282
304
|
function checkLog(logPath, result) {
|
|
283
305
|
switch (result.status) {
|
|
@@ -289,13 +311,31 @@ function checkLog(logPath, result) {
|
|
|
289
311
|
? `${logPath} is empty (an audit trail that has recorded nothing is clean, not missing)`
|
|
290
312
|
: `${logPath} verifies: ${result.records} record(s), head seq ${result.head.seq} ${result.head.hash.slice(0, 12)}…`,
|
|
291
313
|
};
|
|
292
|
-
case "torn-tail":
|
|
314
|
+
case "torn-tail": {
|
|
315
|
+
const file = shellWord(logPath);
|
|
316
|
+
const keep = String(result.intactBytes);
|
|
317
|
+
// What the truncation removes, from the file's own size: verify counts
|
|
318
|
+
// the tail's DECODED bytes, which overstates a tail holding invalid UTF-8.
|
|
319
|
+
let drop = result.tornBytes;
|
|
320
|
+
try {
|
|
321
|
+
drop = statSync(logPath).size - result.intactBytes;
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// Unreadable now: the verify count stands.
|
|
325
|
+
}
|
|
293
326
|
return {
|
|
294
327
|
check: "log",
|
|
295
328
|
status: "fail",
|
|
296
|
-
detail:
|
|
297
|
-
|
|
329
|
+
detail: result.tear === "nul-filled"
|
|
330
|
+
? `${logPath} ends with ${String(result.tornBytes)} NUL byte(s) after record ${String(result.intactThroughSeq)}, the crash-before-writeback signature: the file grew but its data never reached the disk before the machine stopped, so nothing was tampered and whatever was appended there is lost; records 1..${String(result.intactThroughSeq)} verify clean`
|
|
331
|
+
: `${logPath} ends with an unterminated line of ${String(result.tornBytes)} byte(s) after record ${String(result.intactThroughSeq)}, the signature of a crashed write (a writer that died mid-line), not of tampering; records 1..${String(result.intactThroughSeq)} verify clean`,
|
|
332
|
+
// APRV-440: which bytes, exactly. The line still opens with the read,
|
|
333
|
+
// because truncating is a human decision taken after it, and the
|
|
334
|
+
// truncation is spelled in `node` because `truncate(1)` is not on
|
|
335
|
+
// macOS and node is wherever this CLI runs. Doctor truncates nothing.
|
|
336
|
+
fix: `approval log verify — read the full report; then, if you decide to repair, keep the first ${keep} byte(s) of ${logPath} and drop the final ${String(drop)}: \`node -e "require('fs').truncateSync(process.argv[1], ${keep})" ${file}\`. Nothing here truncates, because that is a human decision`,
|
|
298
337
|
};
|
|
338
|
+
}
|
|
299
339
|
case "corrupt":
|
|
300
340
|
return {
|
|
301
341
|
check: "log",
|
|
@@ -332,7 +372,27 @@ function redact(text, token) {
|
|
|
332
372
|
* operator to set a variable their policy never mentions is the failure mode
|
|
333
373
|
* this parameter removes.
|
|
334
374
|
*/
|
|
335
|
-
|
|
375
|
+
/**
|
|
376
|
+
* Who owns the bot this row is about (APRV-390), as one clause.
|
|
377
|
+
*
|
|
378
|
+
* Read from the two ownership records and never from the network: doctor's
|
|
379
|
+
* `getMe` answers WHICH bot, and this answers WHOSE. An instance that has
|
|
380
|
+
* never started a listener has no record and gets no clause, because "nothing
|
|
381
|
+
* has claimed it yet" is a state and printing it on every row would make the
|
|
382
|
+
* real finding — two instances on one bot — harder to see.
|
|
383
|
+
*/
|
|
384
|
+
function ownershipClause(logPath, botId, apiBase) {
|
|
385
|
+
const mine = ownedBot(logPath, "telegram");
|
|
386
|
+
const others = otherOwnersOf(logPath, botId, apiBase);
|
|
387
|
+
if (others.length > 0) {
|
|
388
|
+
return `; ALSO claimed on this machine by ${describeOwners(others)}, which is two gates on one bot`;
|
|
389
|
+
}
|
|
390
|
+
if (mine !== null && mine.botId === botId) {
|
|
391
|
+
return `; owned by this instance (${mine.instanceHome}, instance ${mine.instanceId})`;
|
|
392
|
+
}
|
|
393
|
+
return "";
|
|
394
|
+
}
|
|
395
|
+
async function checkTelegram(apiBase, load, logPath) {
|
|
336
396
|
const tokenEnv = telegramTokenEnvFor(load);
|
|
337
397
|
const chatEnv = telegramChatEnvFor(load);
|
|
338
398
|
const token = process.env[tokenEnv] ?? "";
|
|
@@ -385,11 +445,23 @@ async function checkTelegram(apiBase, load) {
|
|
|
385
445
|
}
|
|
386
446
|
const result = (envelope["result"] ?? {});
|
|
387
447
|
const username = typeof result["username"] === "string" ? `@${result["username"]}` : "unnamed";
|
|
388
|
-
const
|
|
448
|
+
const botId = result["id"] === undefined ? "" : String(result["id"]);
|
|
449
|
+
const id = botId.length === 0 ? "unknown id" : `id ${botId}`;
|
|
450
|
+
// APRV-390. Two gates on one bot is a FAIL, not a note: it is the state in
|
|
451
|
+
// which an approval tap is answered by whichever listener asked first.
|
|
452
|
+
const owners = botId.length === 0 ? [] : otherOwnersOf(logPath, botId, base);
|
|
453
|
+
if (owners.length > 0) {
|
|
454
|
+
return {
|
|
455
|
+
check: "telegram",
|
|
456
|
+
status: "fail",
|
|
457
|
+
detail: `token valid: ${username} (${id}) via ${base}, chat ${chat} — but this bot is also claimed on this machine by ${describeOwners(owners)}. Both gates long-poll it, their getUpdates offsets acknowledge each other's updates, and an approval tap is answered by whichever listener asked first`,
|
|
458
|
+
fix: "approval setup channel telegram — give this instance its own bot from @BotFather; `approval channel telegram health` names the current owner of each",
|
|
459
|
+
};
|
|
460
|
+
}
|
|
389
461
|
return {
|
|
390
462
|
check: "telegram",
|
|
391
463
|
status: "pass",
|
|
392
|
-
detail: `token valid: ${username} (${id}) via ${base}, chat ${chat}; no message was sent and no update was consumed`,
|
|
464
|
+
detail: `token valid: ${username} (${id}) via ${base}, chat ${chat}${botId.length === 0 ? "" : ownershipClause(logPath, botId, base)}; no message was sent and no update was consumed`,
|
|
393
465
|
};
|
|
394
466
|
}
|
|
395
467
|
catch (cause) {
|
|
@@ -482,8 +554,12 @@ const PAYLOAD_STORE_WARNING = "the store holds the bytes approvals bind to, keye
|
|
|
482
554
|
* The probe is a real create-and-remove in the store directory, not a `statSync`
|
|
483
555
|
* mode test: mode bits do not answer the question on a read-only mount, under an
|
|
484
556
|
* ACL, or in a container whose uid mapping differs from the one that made the
|
|
485
|
-
* directory. Nothing is left behind, and no payload file is
|
|
486
|
-
*
|
|
557
|
+
* directory. Nothing is left behind, and no payload file is written here.
|
|
558
|
+
*
|
|
559
|
+
* Since APRV-457 the bound payloads are read back and verified (see
|
|
560
|
+
* {@link payloadDamageCheck}): a store that is writable but holds a torn,
|
|
561
|
+
* missing or tampered payload a verified record binds fails, each reading with
|
|
562
|
+
* its own text.
|
|
487
563
|
*/
|
|
488
564
|
function checkPayloadStore(logPath, records) {
|
|
489
565
|
const storeDir = payloadStoreDirFor(logPath);
|
|
@@ -493,6 +569,14 @@ function checkPayloadStore(logPath, records) {
|
|
|
493
569
|
}
|
|
494
570
|
catch (cause) {
|
|
495
571
|
if (cause.code === "ENOENT") {
|
|
572
|
+
// APRV-457: an absent store is only "not created yet" when no verified
|
|
573
|
+
// record proves the store already held bytes. When one does, the whole
|
|
574
|
+
// directory is missing under it: the crash signature of a first store
|
|
575
|
+
// whose directory entry never reached the disk, or a store not carried
|
|
576
|
+
// with the log. Every such binding reads as lost.
|
|
577
|
+
const damage = payloadIntegrity(records, storeDir);
|
|
578
|
+
if (damage.length > 0)
|
|
579
|
+
return payloadDamageCheck(damage);
|
|
496
580
|
return {
|
|
497
581
|
check: "payload-store",
|
|
498
582
|
status: "pass",
|
|
@@ -555,12 +639,94 @@ function checkPayloadStore(logPath, records) {
|
|
|
555
639
|
const residue = census.awaitingRemoval === 0
|
|
556
640
|
? ""
|
|
557
641
|
: `, ${census.awaitingRemoval} already recorded as pruned and awaiting removal by the daemon`;
|
|
642
|
+
// APRV-457: the bytes verified records bind, read back. Only a damaged store
|
|
643
|
+
// changes the row; an intact one reads exactly as it did before.
|
|
644
|
+
const damage = payloadIntegrity(records, storeDir);
|
|
645
|
+
if (damage.length > 0)
|
|
646
|
+
return payloadDamageCheck(damage);
|
|
558
647
|
return {
|
|
559
648
|
check: "payload-store",
|
|
560
649
|
status: "pass",
|
|
561
650
|
detail: `${storeDir} is writable and holds ${files} payload file(s), ${census.pruned} pruned by the log, ${census.orphans} bound to no record${residue}; ${PAYLOAD_STORE_WARNING}`,
|
|
562
651
|
};
|
|
563
652
|
}
|
|
653
|
+
/** At most this many payloads are named per kind; the count covers the rest. */
|
|
654
|
+
const DAMAGE_NAMED = 5;
|
|
655
|
+
function namedDamage(items, describe) {
|
|
656
|
+
const named = items.slice(0, DAMAGE_NAMED).map(describe).join("; ");
|
|
657
|
+
const more = items.length > DAMAGE_NAMED ? `; and ${String(items.length - DAMAGE_NAMED)} more` : "";
|
|
658
|
+
return `${named}${more}`;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* The payload-store row when bound payloads are not intact (APRV-457).
|
|
662
|
+
*
|
|
663
|
+
* Two readings, kept apart because they call for opposite reactions:
|
|
664
|
+
*
|
|
665
|
+
* - **The crash-before-writeback signature.** A file that is empty or all NUL
|
|
666
|
+
* (`torn`), or a file that is absent although its record proves the store
|
|
667
|
+
* held it (`lost`). A crash leaves exactly these and nothing else: it can drop
|
|
668
|
+
* bytes and names, never write bytes nobody wrote. Before APRV-457 the store
|
|
669
|
+
* did not fsync, so a platform kill could keep the (fsynced) record and drop
|
|
670
|
+
* its payload. Nothing was tampered; the bytes are gone from this store.
|
|
671
|
+
* - **Tampering or corruption** (`mismatch`). The file holds at least one
|
|
672
|
+
* non-NUL byte and does not verify. That is not the pure crash shape, so it is never
|
|
673
|
+
* read as one (a partial writeback, valid prefix and NUL tail, lands here too: the stricter reading), and its fix moves the file aside for investigation.
|
|
674
|
+
*
|
|
675
|
+
* The torn reading cannot swallow a tampered file that still carries content:
|
|
676
|
+
* it requires every byte to be NUL. A file someone zeroed by hand reads as torn,
|
|
677
|
+
* which is honest, because its bytes say nothing either way and the material is
|
|
678
|
+
* lost either way. A partially written payload (valid prefix, NUL tail) is
|
|
679
|
+
* reported as a mismatch, the stricter of the two readings.
|
|
680
|
+
*
|
|
681
|
+
* Doctor moves, restores and deletes nothing; every fix is the operator's.
|
|
682
|
+
*/
|
|
683
|
+
function payloadDamageCheck(damage) {
|
|
684
|
+
const torn = damage.filter((item) => item.kind === "torn");
|
|
685
|
+
const lost = damage.filter((item) => item.kind === "lost");
|
|
686
|
+
const mismatch = damage.filter((item) => item.kind === "mismatch");
|
|
687
|
+
const unreadable = damage.filter((item) => item.kind === "unreadable");
|
|
688
|
+
const short = (hash) => `${hash.slice(0, 12)}…`;
|
|
689
|
+
const parts = [];
|
|
690
|
+
if (mismatch.length > 0) {
|
|
691
|
+
parts.push(`${String(mismatch.length)} payload(s) a verified record binds hold bytes that do not hash to their name and are NOT the crash signature (that is an empty or all-NUL file; a partial writeback can also leave a valid prefix with a NUL tail, so the stricter reading applies until shown otherwise): treat them as tampered or corrupted (${namedDamage(mismatch, (item) => `${short(item.hash)} bound at seq ${String(item.seq)}`)}); every read re-verifies, so no channel renders them`);
|
|
692
|
+
}
|
|
693
|
+
if (torn.length > 0) {
|
|
694
|
+
parts.push(`${String(torn.length)} payload(s) a verified record binds are empty or NUL-filled, the crash-before-writeback signature: the name reached the disk and the bytes did not, so nothing was tampered and the material is lost from this store (${namedDamage(torn, (item) => `${short(item.hash)} bound at seq ${String(item.seq)}, ${String(item.bytes)} NUL byte(s)`)})`);
|
|
695
|
+
}
|
|
696
|
+
if (lost.length > 0) {
|
|
697
|
+
parts.push(`${String(lost.length)} payload(s) are missing although the record that bound them proves the store held them (${namedDamage(lost, (item) => `${short(item.hash)}, ${item.event} at seq ${String(item.seq)}`)}): the crash-before-writeback signature when this store has always lived beside this log (the name never reached the disk), or a store that was not carried with the log`);
|
|
698
|
+
}
|
|
699
|
+
if (unreadable.length > 0) {
|
|
700
|
+
parts.push(`${String(unreadable.length)} payload(s) a verified record binds could not be read (${namedDamage(unreadable, (item) => `${short(item.hash)} bound at seq ${String(item.seq)}: ${item.detail}`)})`);
|
|
701
|
+
}
|
|
702
|
+
// One command leads the fix, for the reading that matters most: suspected
|
|
703
|
+
// tampering first, then a torn file (which must be moved before a copy can go
|
|
704
|
+
// back), then a lost one, then an unreadable one.
|
|
705
|
+
const restore = (hash, path) => `restore ${hash}.json from wherever else it lives (another checkout, a backup, the git history of the store) once \`approval payload hash\` prints ${hash} for the copy, and put it at ${path}; the log records the binding and never the material, so nothing rebuilds it`;
|
|
706
|
+
let fix;
|
|
707
|
+
const firstMismatch = mismatch[0];
|
|
708
|
+
const firstTorn = torn[0];
|
|
709
|
+
const firstLost = lost[0];
|
|
710
|
+
if (firstMismatch !== undefined) {
|
|
711
|
+
fix = `mv ${shellWord(firstMismatch.path)} ${shellWord(`${firstMismatch.path}.suspect`)} — move each payload that does not verify aside (do not delete it), find out what wrote it, and ${restore(firstMismatch.hash, firstMismatch.path)}`;
|
|
712
|
+
}
|
|
713
|
+
else if (firstTorn !== undefined) {
|
|
714
|
+
fix = `mv ${shellWord(firstTorn.path)} ${shellWord(`${firstTorn.path}.torn`)} — move each torn payload aside (the nonmanual intake refuses to replace a file that does not verify), then ${restore(firstTorn.hash, firstTorn.path)}`;
|
|
715
|
+
}
|
|
716
|
+
else if (firstLost !== undefined) {
|
|
717
|
+
fix = `approval payload hash <copy> — ${restore(firstLost.hash, firstLost.path)}`;
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
const first = unreadable[0];
|
|
721
|
+
fix = `chmod u+r ${shellWord(first.path)} — make each payload readable by the user running approval, check its ownership and the disk, then run approval doctor again`;
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
check: "payload-store",
|
|
725
|
+
status: "fail",
|
|
726
|
+
detail: `${parts.join("; ")}; ${PAYLOAD_STORE_WARNING}`,
|
|
727
|
+
fix: `${fix}. Doctor moves, restores and deletes nothing`,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
564
730
|
// ---------------------------------------------------------------------------
|
|
565
731
|
// 7b. the verified-head snapshot (APRV-188)
|
|
566
732
|
// ---------------------------------------------------------------------------
|
|
@@ -744,11 +910,19 @@ function checkValuesBlock(policyPath, policyFlagged, dir) {
|
|
|
744
910
|
detail: oneLine(`${result.message}, so there is no values block to read. The policy file's own absence is reported by the attestation row above.`),
|
|
745
911
|
};
|
|
746
912
|
}
|
|
913
|
+
// A block of the format APRV-336 replaced is a fixable block rather than a
|
|
914
|
+
// broken one, so its fix names the two edits instead of the load code.
|
|
915
|
+
const fix = result.code === "version-unsupported"
|
|
916
|
+
? `edit the block in ${result.source?.filename ?? "the policy file"}: fold \`wants:\` into \`like:\`, rename \`responds:\` to \`communication:\`, and set \`version: "0.2"\` with the quotes; then re-attest, since the attestation digests the whole file`
|
|
917
|
+
: `approval values --json — prints the same failure with its code (${result.code}); fix the block in ${result.source?.filename ?? "the policy file"} and re-attest, since the attestation digests the whole file`;
|
|
747
918
|
return {
|
|
748
919
|
check: "values-block",
|
|
749
920
|
status: "fail",
|
|
750
|
-
detail: oneLine(
|
|
751
|
-
|
|
921
|
+
detail: oneLine(
|
|
922
|
+
// The loader's own message may end in a full stop (the version refusals
|
|
923
|
+
// are whole sentences), and two of them in a row reads as a typo.
|
|
924
|
+
`a \`\`\`${VALUES_INFO_STRING} block is present and could not be read (${result.code}): ${result.message.replace(/\.$/u, "")}. Nothing about the policy changed — guidance is not enforcement — but the operator's stated values reach no agent until this parses.`),
|
|
925
|
+
fix,
|
|
752
926
|
};
|
|
753
927
|
}
|
|
754
928
|
if (!result.present) {
|
|
@@ -758,12 +932,160 @@ function checkValuesBlock(policyPath, policyFlagged, dir) {
|
|
|
758
932
|
detail: `${result.source.filename}: no approval-values block; the operator has declared no values here. That is a declaration rather than a gap, and \`approval values\` says so in those words.`,
|
|
759
933
|
};
|
|
760
934
|
}
|
|
761
|
-
const declared = ["love", "like", "dislike"
|
|
762
|
-
const
|
|
935
|
+
const declared = ["love", "like", "dislike"].filter((key) => result.values[key] !== undefined);
|
|
936
|
+
const communication = result.values.communication === undefined ? "" : ", communication";
|
|
763
937
|
return {
|
|
764
938
|
check: "values-block",
|
|
765
939
|
status: "pass",
|
|
766
|
-
detail: `${result.source.filename}: the values block parses and validates (version ${
|
|
940
|
+
detail: `${result.source.filename}: the values block parses and validates (version ${JSON.stringify(result.values.version)}; ${declared.length === 0 ? "no list" : declared.join(", ")}${communication}). It is guidance, so nothing here is enforced; read it with \`approval values\`.`,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
// ---------------------------------------------------------------------------
|
|
944
|
+
// 7e. the deprecated bare `supervised` (APRV-335)
|
|
945
|
+
// ---------------------------------------------------------------------------
|
|
946
|
+
/**
|
|
947
|
+
* Which rules of this policy still write the deprecated bare `supervised`.
|
|
948
|
+
*
|
|
949
|
+
* SPEC.md §5.2 keeps the spelling parsing as `supervised-retro` so that no
|
|
950
|
+
* published policy breaks, and the loader records a note wherever it appears.
|
|
951
|
+
* A note is read by whoever happens to run a verb that prints notes; the
|
|
952
|
+
* deprecation needs a place an operator looks at the state of their repository
|
|
953
|
+
* on purpose, which is this report. SPEC.md §5.2 asks implementations for
|
|
954
|
+
* exactly that.
|
|
955
|
+
*
|
|
956
|
+
* It is never a `fail` and never a `warn`. The policy says what its author
|
|
957
|
+
* meant, every gate resolves it identically, and nothing on this machine is
|
|
958
|
+
* broken by a spelling: a red row here would be doctor going red over prose. A
|
|
959
|
+
* `skip` is the unreadable policy, whose own failure the attestation and
|
|
960
|
+
* `policy check` rows report; everything else is a `pass` that either names the
|
|
961
|
+
* rules to rewrite or says plainly that there are none.
|
|
962
|
+
*/
|
|
963
|
+
function checkAutonomyAlias(policyLoad) {
|
|
964
|
+
if (!policyLoad.ok) {
|
|
965
|
+
return {
|
|
966
|
+
check: "autonomy-alias",
|
|
967
|
+
status: "skip",
|
|
968
|
+
detail: `the policy did not load (${policyLoad.code}), so no rule could be read for the deprecated bare \`supervised\`. A failed load resolves every class to \`manual\` and names no level at all; the failure itself is reported by \`approval policy check\`.`,
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
// The same walk `aliasNotes` makes in the loader, and deliberately the same
|
|
972
|
+
// order: `defaults` first, then class patterns sorted, so the row and the
|
|
973
|
+
// load-time notes name the same places in the same sequence.
|
|
974
|
+
const where = [];
|
|
975
|
+
if (policyLoad.policy.defaults?.autonomy === "supervised")
|
|
976
|
+
where.push("defaults.autonomy");
|
|
977
|
+
for (const pattern of Object.keys(policyLoad.policy.classes ?? {}).sort()) {
|
|
978
|
+
if (policyLoad.policy.classes?.[pattern]?.autonomy === "supervised")
|
|
979
|
+
where.push(pattern);
|
|
980
|
+
}
|
|
981
|
+
if (where.length === 0) {
|
|
982
|
+
return {
|
|
983
|
+
check: "autonomy-alias",
|
|
984
|
+
status: "pass",
|
|
985
|
+
detail: `${policyLoad.source.filename}: no rule uses the deprecated bare \`supervised\`. Every supervised class says which bargain it means, \`supervised-retro\` or \`supervised-live\`, so nothing here changes when a future schema version drops the alias.`,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
return {
|
|
989
|
+
check: "autonomy-alias",
|
|
990
|
+
status: "pass",
|
|
991
|
+
detail: oneLine(`deprecated bare \`supervised\` on ${String(where.length)} rule(s): ${where.join(", ")}. Each parses as \`supervised-retro\` and is enforced as one, so the policy means today what its author meant; the spelling is deprecated and a future version of the policy schema removes it (SPEC.md §5.2, APRV-335).`),
|
|
992
|
+
// FIX_COMMAND_PREFIXES: the repair is a hand edit of the policy file plus
|
|
993
|
+
// the re-attestation that edit costs, and `policy amend` is the one verb
|
|
994
|
+
// that owns both. The prose after it is the edit itself: write
|
|
995
|
+
// supervised-retro.
|
|
996
|
+
fix: "approval policy amend — write supervised-retro in place of the bare supervised on the rules above, and re-attest in the same step; the paste-ready draft for this repo is docs/proposals/approval-md-2026-09.md",
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Which approvers can still be recognized by the channels they are listed for
|
|
1001
|
+
* (APRV-324, `design/channel-sender-identity.md` §5 item 13).
|
|
1002
|
+
*
|
|
1003
|
+
* The row exists for one transition and says which side of it this deployment
|
|
1004
|
+
* is on. Declaring the first `senders` entry for a channel turns enforcement on
|
|
1005
|
+
* for that whole channel: from then on a decision arriving with an account the
|
|
1006
|
+
* policy does not name is REFUSED rather than recorded under the listener's own
|
|
1007
|
+
* identity. An operator who maps themselves and forgets a colleague has built a
|
|
1008
|
+
* gate that will refuse that colleague's next tap, and the file reads as though
|
|
1009
|
+
* both are approvers.
|
|
1010
|
+
*
|
|
1011
|
+
* Three answers, and the verdicts are chosen for what each one actually is:
|
|
1012
|
+
*
|
|
1013
|
+
* - **SKIP** when no approver declares a mapping. That is every installation
|
|
1014
|
+
* before this key existed and most of them after, it is fully correct, and a
|
|
1015
|
+
* red row for it would be a red row people learn to skip past.
|
|
1016
|
+
* - **FAIL** for an approver listed for a mapped channel with no id of their
|
|
1017
|
+
* own on it. Not a state: the policy says that person decides on that
|
|
1018
|
+
* channel, and the runtime will not honour it.
|
|
1019
|
+
* - **PASS** when every approver the policy can reach on a mapped channel is
|
|
1020
|
+
* recognizable there.
|
|
1021
|
+
*
|
|
1022
|
+
* It reads the policy and nothing else — no log, no network, no credential —
|
|
1023
|
+
* and it prints the id of nobody: a doctor row is read over a shoulder, the
|
|
1024
|
+
* mapping is in a file the operator can open, and the row's job is to name who
|
|
1025
|
+
* is missing rather than to recite everyone who is not.
|
|
1026
|
+
*/
|
|
1027
|
+
function checkSenderMapping(policyLoad, env = process.env) {
|
|
1028
|
+
if (!policyLoad.ok) {
|
|
1029
|
+
return {
|
|
1030
|
+
check: "sender-mapping",
|
|
1031
|
+
status: "skip",
|
|
1032
|
+
detail: `the policy did not load (${policyLoad.code}), so no approver roster could be read. A failed load carries no sender mapping either, and a decision arriving with an authenticated sender is refused \`sender-unmapped\` until the file parses again — the \`attestation\` row above and \`approval policy check\` say what is wrong with it.`,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
const approvers = policyLoad.policy.approvers;
|
|
1036
|
+
const mapped = SENDER_CHANNELS.filter((channel) => mapsSendersFor(approvers, channel));
|
|
1037
|
+
if (mapped.length === 0) {
|
|
1038
|
+
return {
|
|
1039
|
+
check: "sender-mapping",
|
|
1040
|
+
status: "skip",
|
|
1041
|
+
detail: `${policyLoad.source.filename}: no approver declares a \`senders\` block, so every decision is recorded against the identity the deciding process was launched with (\`--as\` / APPROVAL_HUMAN), exactly as it was before the mapping existed. Anyone who can reach a configured channel decides as that identity; map an approver's account id to record who actually tapped (SPEC.md §5.2, APRV-324).`,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
// APRV-370. Which FORM each mapped channel is written in, and for a keyed one
|
|
1045
|
+
// whether the key this process would compare with resolves at all. The row
|
|
1046
|
+
// reports the form because an operator reading it cannot otherwise tell a
|
|
1047
|
+
// gate that publishes its accounts from one that does not, and it reports the
|
|
1048
|
+
// key because a keyed channel without one refuses every tap on it.
|
|
1049
|
+
const key = senderKeyFrom(env);
|
|
1050
|
+
const keyed = mapped.filter((channel) => senderMappingForms(approvers, channel).keyed);
|
|
1051
|
+
const mixed = keyed.filter((channel) => senderMappingForms(approvers, channel).raw);
|
|
1052
|
+
const forms = keyed.length === 0
|
|
1053
|
+
? "raw account ids"
|
|
1054
|
+
: mixed.length > 0
|
|
1055
|
+
? `both forms (${mixed.join(", ")} carries raw ids beside keyed ones)`
|
|
1056
|
+
: `the keyed form (${SENDER_KEY_ENV})`;
|
|
1057
|
+
if (keyed.length > 0 && key === null) {
|
|
1058
|
+
return {
|
|
1059
|
+
check: "sender-mapping",
|
|
1060
|
+
status: "fail",
|
|
1061
|
+
detail: oneLine(`${policyLoad.source.filename} maps ${keyed.join(", ")} senders in the keyed form and ${SENDER_KEY_ENV} is unset or empty in this process, so no account on those channels can be resolved at all and EVERY decision there is refused \`sender-key-unavailable\`. Without the key the runtime can compute no digest, so it cannot tell a mapped account from an unmapped one and will not guess; there is no fallback to a raw comparison, by design.`),
|
|
1062
|
+
fix: `approval setup sender-key mints and stores the key; \`eval "$(approval env)"\` establishes it in the shell that starts the listener, and the listener has to be restarted to pick it up. \`approval env --check\` says where the value lives and prints no values`,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
const gaps = [];
|
|
1066
|
+
for (const channel of mapped) {
|
|
1067
|
+
for (const id of Object.keys(approvers ?? {}).sort()) {
|
|
1068
|
+
const entry = approvers?.[id];
|
|
1069
|
+
if (entry === undefined)
|
|
1070
|
+
continue;
|
|
1071
|
+
if (!entry.channels.includes(channel))
|
|
1072
|
+
continue;
|
|
1073
|
+
if (entry.senders?.[channel] === undefined)
|
|
1074
|
+
gaps.push(`${id} on ${channel}`);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if (gaps.length > 0) {
|
|
1078
|
+
return {
|
|
1079
|
+
check: "sender-mapping",
|
|
1080
|
+
status: "fail",
|
|
1081
|
+
detail: oneLine(`${policyLoad.source.filename} maps ${mapped.join(", ")} senders in ${forms}, and ${String(gaps.length)} approver(s) listed for a mapped channel carry no id there: ${gaps.join(", ")}. A decision from an account this file does not name is refused \`sender-unmapped\` and nothing is recorded, so the policy currently says those people may decide on a channel where their taps will not be honoured.`),
|
|
1082
|
+
fix: "approval policy amend — give each approver above a `senders` entry for that channel, or drop the channel from their `channels` list if they are not meant to decide there; the mapping is attested policy, so the edit is inoperative until it is re-attested",
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
return {
|
|
1086
|
+
check: "sender-mapping",
|
|
1087
|
+
status: "pass",
|
|
1088
|
+
detail: oneLine(`${policyLoad.source.filename} maps ${mapped.join(", ")} senders in ${forms}, and every approver listed for a mapped channel carries an id there.${keyed.length === 0 ? "" : ` ${SENDER_KEY_ENV} resolves, so the keyed ids can be compared.`} Each decision on those channels is recorded against the person the operator attested that account to; an account this file does not name is refused rather than attributed to the listener (SPEC.md §10.3, APRV-324, APRV-370).`),
|
|
767
1089
|
};
|
|
768
1090
|
}
|
|
769
1091
|
// ---------------------------------------------------------------------------
|
|
@@ -1508,6 +1830,80 @@ function checkKeychainScope(logPath, load) {
|
|
|
1508
1830
|
};
|
|
1509
1831
|
}
|
|
1510
1832
|
// ---------------------------------------------------------------------------
|
|
1833
|
+
// 32. daemon-identity (APRV-383)
|
|
1834
|
+
// ---------------------------------------------------------------------------
|
|
1835
|
+
/**
|
|
1836
|
+
* Which daemon would write here, and whether the attested policy admits it
|
|
1837
|
+
* (APRV-383).
|
|
1838
|
+
*
|
|
1839
|
+
* The sibling of `keychain-scope` above, one layer along: that row answers
|
|
1840
|
+
* "whose CREDENTIALS is this instance using", this one answers "whose NAME would
|
|
1841
|
+
* this instance's daemon write onto every record it appends". Both derive from the
|
|
1842
|
+
* same instance id, which is the point — an operator comparing `daemon-3f2a9c11`
|
|
1843
|
+
* in a record against `approval-tg-token-3f2a9c11` in `.approval/env` is looking
|
|
1844
|
+
* at one gate.
|
|
1845
|
+
*
|
|
1846
|
+
* ## The verdict rule
|
|
1847
|
+
*
|
|
1848
|
+
* - **FAIL** for an `APPROVAL_DAEMON_ID` in this shell that is not a usable id.
|
|
1849
|
+
* That is a broken launch environment wherever it is read from: a daemon started
|
|
1850
|
+
* from this shell would refuse every append, and the repair is one variable.
|
|
1851
|
+
* - **SKIP**, named and loud, for an id the policy's `daemons` list does not
|
|
1852
|
+
* admit. Deliberately not a fail, and the reason is the one `keychain-scope`
|
|
1853
|
+
* states for the legacy item: under the deployment this key exists FOR, the
|
|
1854
|
+
* tenant's policy names a daemon id belonging to somebody else's machine, so a
|
|
1855
|
+
* red row here would be red on every tenant's laptop forever, and a red row on
|
|
1856
|
+
* every installation is a red row people learn to skip past. The line says
|
|
1857
|
+
* plainly that a daemon started HERE would write nothing.
|
|
1858
|
+
* - **PASS** when the list names this id, and when there is no list at all, which
|
|
1859
|
+
* is every installation that never adopts the key.
|
|
1860
|
+
*
|
|
1861
|
+
* It reads a policy and an environment variable's SHAPE. No value is printed on
|
|
1862
|
+
* any path, because the only value involved is an id, which the policy and the log
|
|
1863
|
+
* both carry in the open.
|
|
1864
|
+
*/
|
|
1865
|
+
function checkDaemonIdentity(logPath, records, load) {
|
|
1866
|
+
const resolved = resolveDaemonId(logPath);
|
|
1867
|
+
if (!resolved.ok) {
|
|
1868
|
+
return {
|
|
1869
|
+
check: "daemon-identity",
|
|
1870
|
+
status: "fail",
|
|
1871
|
+
detail: `${resolved.message}. A daemon started from this shell would be refused \`daemon-id-invalid\` on every append, so it would read, render and report while writing nothing`,
|
|
1872
|
+
fix: `unset ${DAEMON_ID_ENV} to use the derived id ${derivedDaemonId(logPath)}, or set it to a usable one: lowercase letters, digits, \`.\`, \`-\` and \`_\`, starting with a letter or digit`,
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
const head = `a daemon run against ${instanceHomeFor(logPath)} writes \`daemon: ${resolved.id}\` onto every record it appends (${resolved.source === "environment" ? `declared in ${DAEMON_ID_ENV}` : "derived from this instance, the same id the keychain-scope row names"})`;
|
|
1876
|
+
const allowlist = resolveDaemonAllowlist(records, load);
|
|
1877
|
+
if (!allowlist.ok) {
|
|
1878
|
+
return {
|
|
1879
|
+
check: "daemon-identity",
|
|
1880
|
+
status: "pass",
|
|
1881
|
+
detail: `${head}. No \`daemons\` allowlist is in force (${allowlist.detail}), so any daemon may write, exactly as before the key existed`,
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
const allowed = allowlist.allowed;
|
|
1885
|
+
if (allowed === null) {
|
|
1886
|
+
return {
|
|
1887
|
+
check: "daemon-identity",
|
|
1888
|
+
status: "pass",
|
|
1889
|
+
detail: `${head}. The attested policy lists no allowed daemon ids, so any daemon may write — which is what every policy written before this key looked like`,
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
if (!allowed.includes(resolved.id)) {
|
|
1893
|
+
return {
|
|
1894
|
+
check: "daemon-identity",
|
|
1895
|
+
status: "skip",
|
|
1896
|
+
detail: `${head}. The attested policy's \`daemons\` list admits ${allowed.length === 0 ? "no daemon at all" : allowed.join(", ")}, so a daemon started HERE would be refused \`daemon-not-allowed\` on every append and would write nothing. Expected where the daemon for this log runs on another machine; wrong if that daemon is meant to be this one`,
|
|
1897
|
+
fix: `add ${resolved.id} to \`daemons\` and re-attest the policy, or run the daemon whose id the list already names`,
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
return {
|
|
1901
|
+
check: "daemon-identity",
|
|
1902
|
+
status: "pass",
|
|
1903
|
+
detail: `${head}, and the attested policy's \`daemons\` list admits it (${allowed.join(", ")}). Being listed grants nothing beyond the ability to write: no verdict, budget, floor, draw or token reads this id`,
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
// ---------------------------------------------------------------------------
|
|
1511
1907
|
// 12. log-drift (APRV-125)
|
|
1512
1908
|
// ---------------------------------------------------------------------------
|
|
1513
1909
|
/**
|
|
@@ -1745,28 +2141,180 @@ function checkAdvanceCadence(logPath, records) {
|
|
|
1745
2141
|
// ---------------------------------------------------------------------------
|
|
1746
2142
|
/** Where Claude Code keeps the hook registration a human commits. */
|
|
1747
2143
|
const CLAUDE_SETTINGS = join(".claude", "settings.json");
|
|
1748
|
-
/**
|
|
1749
|
-
|
|
2144
|
+
/** This runtime's own Claude Code hook adapter, the roster both rows read. */
|
|
2145
|
+
const CLAUDE_HOOK_ADAPTER = HARNESS_ADAPTERS["claude-code"];
|
|
2146
|
+
/**
|
|
2147
|
+
* The tools a protected-path write can arrive as, DERIVED (APRV-408).
|
|
2148
|
+
*
|
|
2149
|
+
* Until APRV-408 this was a hand list, `["Edit", "Write", "Bash"]`, and it had
|
|
2150
|
+
* drifted: the adapter gates `Bash` plus `Edit`, `Write`, `MultiEdit` and
|
|
2151
|
+
* `NotebookEdit`, so a matcher of `Bash|Edit|Write` passed the wiring row while
|
|
2152
|
+
* a `MultiEdit` to a protected path reached the file system unclassified. Two
|
|
2153
|
+
* lists of the same fact is one list too many, so there is now one: the
|
|
2154
|
+
* adapter's, because the adapter is what actually answers the call.
|
|
2155
|
+
*/
|
|
2156
|
+
function gatedClaudeTools() {
|
|
2157
|
+
return [CLAUDE_HOOK_ADAPTER.shellTool, ...CLAUDE_HOOK_ADAPTER.fileTools];
|
|
2158
|
+
}
|
|
2159
|
+
/**
|
|
2160
|
+
* Anything that makes a command line mean something other than its own words.
|
|
2161
|
+
*
|
|
2162
|
+
* Deliberately generous. A command carrying any of these is reported as
|
|
2163
|
+
* unresolved rather than parsed on a best guess, because the question this
|
|
2164
|
+
* parse answers — which checkout does the gate resolve its policy and log from
|
|
2165
|
+
* — has no safe approximation. Fail closed: SPEC §11.
|
|
2166
|
+
*/
|
|
2167
|
+
const SHELL_OPAQUE = /[$`\\;&|<>(){}\r\n*?[\]]/u;
|
|
2168
|
+
/**
|
|
2169
|
+
* The `hook claude-code` verb named anywhere in a command, however wrapped.
|
|
2170
|
+
*
|
|
2171
|
+
* The VERB and not the executable, because the executable is exactly what a
|
|
2172
|
+
* wrapper hides: `$(which approval) hook claude-code` and `bash -lc "approval
|
|
2173
|
+
* hook claude-code …"` both name this handler and neither carries the word
|
|
2174
|
+
* `approval` in a position any parse could trust. Naming the verb is what makes
|
|
2175
|
+
* such an entry `unresolved` (reported, never a pass) instead of `foreign`
|
|
2176
|
+
* (invisible, which is how a wrapped handler would read as "no hook
|
|
2177
|
+
* registered"). Another harness's entry parked in this file — `approval hook
|
|
2178
|
+
* cursor` — does not name it, and stays foreign.
|
|
2179
|
+
*/
|
|
2180
|
+
const CLAUDE_HOOK_MENTION = /\bhook\s+claude-code\b/u;
|
|
2181
|
+
/**
|
|
2182
|
+
* Split a command line into words, honouring simple quotes.
|
|
2183
|
+
*
|
|
2184
|
+
* Only ever reached for a command {@link SHELL_OPAQUE} has already cleared, so
|
|
2185
|
+
* there is no expansion, no escape and no operator left to get wrong; `null` is
|
|
2186
|
+
* an unclosed quote.
|
|
2187
|
+
*/
|
|
2188
|
+
function shellWords(command) {
|
|
2189
|
+
const tokens = [];
|
|
2190
|
+
let current = "";
|
|
2191
|
+
let quote = null;
|
|
2192
|
+
let started = false;
|
|
2193
|
+
for (const character of command) {
|
|
2194
|
+
if (quote !== null) {
|
|
2195
|
+
if (character === quote)
|
|
2196
|
+
quote = null;
|
|
2197
|
+
else
|
|
2198
|
+
current += character;
|
|
2199
|
+
continue;
|
|
2200
|
+
}
|
|
2201
|
+
if (character === '"' || character === "'") {
|
|
2202
|
+
quote = character;
|
|
2203
|
+
started = true;
|
|
2204
|
+
continue;
|
|
2205
|
+
}
|
|
2206
|
+
if (/\s/u.test(character)) {
|
|
2207
|
+
if (started)
|
|
2208
|
+
tokens.push(current);
|
|
2209
|
+
current = "";
|
|
2210
|
+
started = false;
|
|
2211
|
+
continue;
|
|
2212
|
+
}
|
|
2213
|
+
current += character;
|
|
2214
|
+
started = true;
|
|
2215
|
+
}
|
|
2216
|
+
if (quote !== null)
|
|
2217
|
+
return null;
|
|
2218
|
+
if (started)
|
|
2219
|
+
tokens.push(current);
|
|
2220
|
+
return tokens;
|
|
2221
|
+
}
|
|
2222
|
+
/**
|
|
2223
|
+
* Read a registered command as an `approval hook claude-code` invocation
|
|
2224
|
+
* (APRV-408).
|
|
2225
|
+
*
|
|
2226
|
+
* Modelled on {@link isDirectCodexHookCommand}, which the Codex rows have had
|
|
2227
|
+
* since APRV-313, and which the Claude Code rows had no counterpart of: they
|
|
2228
|
+
* matched the substring `approval hook`, so a wrapper script, a different
|
|
2229
|
+
* harness's entry and a handler bound to a checkout that does not exist on this
|
|
2230
|
+
* machine all read the same. Tolerant of flag order and of `--as`/`--timeout`
|
|
2231
|
+
* being absent, because those do not decide anything this row reports; strict
|
|
2232
|
+
* about the three facts that do — the executable is `approval`, the verb is
|
|
2233
|
+
* `hook claude-code`, and the `--dir` is whatever it is.
|
|
2234
|
+
*/
|
|
2235
|
+
function parseClaudeHookCommand(command) {
|
|
2236
|
+
const mentioned = CLAUDE_HOOK_MENTION.test(command);
|
|
2237
|
+
const unreadable = (reason) => mentioned ? { kind: "unresolved", reason } : { kind: "foreign" };
|
|
2238
|
+
if (SHELL_OPAQUE.test(command)) {
|
|
2239
|
+
return unreadable("it carries shell metacharacters, so what it runs depends on a shell rather than on these words");
|
|
2240
|
+
}
|
|
2241
|
+
const tokens = shellWords(command);
|
|
2242
|
+
if (tokens === null)
|
|
2243
|
+
return unreadable("its quoting does not close");
|
|
2244
|
+
const executable = tokens[0] ?? "";
|
|
2245
|
+
if (tokens[1] !== "hook" || tokens[2] !== "claude-code") {
|
|
2246
|
+
// `approval hook cursor` in this file is another harness's entry, and a
|
|
2247
|
+
// wrapper name with the verb buried inside it is ours and unreadable.
|
|
2248
|
+
return unreadable("its first words are not `approval hook claude-code`");
|
|
2249
|
+
}
|
|
2250
|
+
if (basename(executable) !== "approval") {
|
|
2251
|
+
return unreadable(`its executable ${JSON.stringify(executable)} is not \`approval\``);
|
|
2252
|
+
}
|
|
2253
|
+
if (executable.includes("/") && !isAbsolute(executable)) {
|
|
2254
|
+
return unreadable(`its executable ${JSON.stringify(executable)} is a relative path, so which binary runs depends on the working directory`);
|
|
2255
|
+
}
|
|
2256
|
+
let dir = null;
|
|
2257
|
+
for (let index = 3; index < tokens.length; index += 1) {
|
|
2258
|
+
const token = tokens[index] ?? "";
|
|
2259
|
+
if (token.startsWith("--dir=")) {
|
|
2260
|
+
dir = token.slice("--dir=".length);
|
|
2261
|
+
continue;
|
|
2262
|
+
}
|
|
2263
|
+
if (token !== "--dir")
|
|
2264
|
+
continue;
|
|
2265
|
+
const value = tokens[index + 1];
|
|
2266
|
+
if (value === undefined || value.startsWith("--")) {
|
|
2267
|
+
return unreadable("its `--dir` flag names no value");
|
|
2268
|
+
}
|
|
2269
|
+
dir = value;
|
|
2270
|
+
index += 1;
|
|
2271
|
+
}
|
|
2272
|
+
return { kind: "handler", dir };
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* Every `hooks.<event>` entry of this file that is OURS (APRV-408).
|
|
2276
|
+
*
|
|
2277
|
+
* One reading for both harness rows, so `harness-hook-outcomes` and
|
|
2278
|
+
* `harness-hook-wiring` cannot disagree about which entries they are talking
|
|
2279
|
+
* about. An unresolved entry counts as ours here — it names the verb, and a
|
|
2280
|
+
* command this file cannot read is a reason to report less confidently rather
|
|
2281
|
+
* than to report that no hook is registered.
|
|
2282
|
+
*/
|
|
2283
|
+
function claudeHookEntries(hooks, event) {
|
|
1750
2284
|
if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks))
|
|
1751
|
-
return
|
|
1752
|
-
const
|
|
1753
|
-
if (!Array.isArray(
|
|
1754
|
-
return
|
|
1755
|
-
|
|
1756
|
-
|
|
2285
|
+
return [];
|
|
2286
|
+
const groups = hooks[event];
|
|
2287
|
+
if (!Array.isArray(groups))
|
|
2288
|
+
return [];
|
|
2289
|
+
const found = [];
|
|
2290
|
+
for (const group of groups) {
|
|
2291
|
+
if (typeof group !== "object" || group === null)
|
|
1757
2292
|
continue;
|
|
1758
|
-
const entries =
|
|
2293
|
+
const entries = group["hooks"];
|
|
1759
2294
|
if (!Array.isArray(entries))
|
|
1760
2295
|
continue;
|
|
2296
|
+
const pattern = group["matcher"];
|
|
1761
2297
|
for (const entry of entries) {
|
|
1762
2298
|
if (typeof entry !== "object" || entry === null)
|
|
1763
2299
|
continue;
|
|
1764
2300
|
const command = entry["command"];
|
|
1765
|
-
if (typeof command
|
|
1766
|
-
|
|
2301
|
+
if (typeof command !== "string")
|
|
2302
|
+
continue;
|
|
2303
|
+
const parse = parseClaudeHookCommand(command);
|
|
2304
|
+
if (parse.kind === "foreign")
|
|
2305
|
+
continue;
|
|
2306
|
+
found.push({
|
|
2307
|
+
matcher: typeof pattern === "string" ? pattern : "",
|
|
2308
|
+
command,
|
|
2309
|
+
parse,
|
|
2310
|
+
});
|
|
1767
2311
|
}
|
|
1768
2312
|
}
|
|
1769
|
-
return
|
|
2313
|
+
return found;
|
|
2314
|
+
}
|
|
2315
|
+
/** Does any `hooks.<event>` entry run this CLI's Claude Code hook? */
|
|
2316
|
+
function registersApprovalHook(hooks, event) {
|
|
2317
|
+
return claudeHookEntries(hooks, event).length > 0;
|
|
1770
2318
|
}
|
|
1771
2319
|
/**
|
|
1772
2320
|
* Is the harness registered for the event that reports outcomes (APRV-145)?
|
|
@@ -1782,6 +2330,12 @@ function registersApprovalHook(hooks, event) {
|
|
|
1782
2330
|
* `policy.core` in this taxonomy — a file that configures the gate is part of
|
|
1783
2331
|
* the gate — so the repair is a line for a human to commit, printed by
|
|
1784
2332
|
* `approval instructions hook`.
|
|
2333
|
+
*
|
|
2334
|
+
* Which entries are OURS is read by {@link claudeHookEntries} since APRV-408,
|
|
2335
|
+
* the same reading `harness-hook-wiring` uses, so the two rows cannot come
|
|
2336
|
+
* apart on the question. Before that this row matched the substring `approval
|
|
2337
|
+
* hook`, which counted another harness's entry parked in Claude Code's own
|
|
2338
|
+
* settings file as a post-execution reporter for Claude Code.
|
|
1785
2339
|
*/
|
|
1786
2340
|
function checkHarnessOutcomes(dir) {
|
|
1787
2341
|
const check = "harness-hook-outcomes";
|
|
@@ -1814,53 +2368,138 @@ function checkHarnessOutcomes(dir) {
|
|
|
1814
2368
|
return {
|
|
1815
2369
|
check,
|
|
1816
2370
|
status: "skip",
|
|
1817
|
-
detail: `${path} registers no \`approval hook\` entry, so this checkout is not gated by the harness hook at all`,
|
|
2371
|
+
detail: `${path} registers no \`approval hook claude-code\` entry, so this checkout is not gated by the harness hook at all`,
|
|
1818
2372
|
};
|
|
1819
2373
|
}
|
|
1820
2374
|
if (!post) {
|
|
1821
2375
|
return {
|
|
1822
2376
|
check,
|
|
1823
2377
|
status: "fail",
|
|
1824
|
-
detail: `${path} registers \`approval hook\` for PreToolUse and not for PostToolUse, so no tool call ever reports an outcome: every harness execution.started stays delegated, and the loop escalation of SPEC.md §10.2 cannot accrue on this path`,
|
|
2378
|
+
detail: `${path} registers \`approval hook claude-code\` for PreToolUse and not for PostToolUse, so no tool call ever reports an outcome: every harness execution.started stays delegated, and the loop escalation of SPEC.md §10.2 cannot accrue on this path`,
|
|
1825
2379
|
fix: "approval hook claude-code --help — prints the PostToolUse entry to add, which a human commits (.claude/settings.json is policy.core)",
|
|
1826
2380
|
};
|
|
1827
2381
|
}
|
|
1828
2382
|
return {
|
|
1829
2383
|
check,
|
|
1830
2384
|
status: "pass",
|
|
1831
|
-
detail: `${path} registers \`approval hook\` for the ${pre ? "pre-execution and " : ""}post-execution event, so tool call outcomes reach the log and loop escalation can accrue`,
|
|
2385
|
+
detail: `${path} registers \`approval hook claude-code\` for the ${pre ? "pre-execution and " : ""}post-execution event, so tool call outcomes reach the log and loop escalation can accrue`,
|
|
1832
2386
|
};
|
|
1833
2387
|
}
|
|
1834
2388
|
// ---------------------------------------------------------------------------
|
|
1835
2389
|
// harness hook wiring in THIS worktree (APRV-151)
|
|
1836
2390
|
// ---------------------------------------------------------------------------
|
|
1837
|
-
/** The
|
|
1838
|
-
const
|
|
1839
|
-
/**
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
2391
|
+
/** The repair every wiring verdict but the two absences points at. */
|
|
2392
|
+
const HOOK_ENTRY_FIX = "approval instructions hook — prints the PreToolUse entry a human commits";
|
|
2393
|
+
/** A tool name as a `matcher` may spell it: no regex syntax anywhere in it. */
|
|
2394
|
+
const MATCHER_TOOL_NAME = /^[A-Za-z0-9_-]+$/u;
|
|
2395
|
+
/**
|
|
2396
|
+
* The tools a `matcher` names, or `"pattern"` when it is not a plain list
|
|
2397
|
+
* (APRV-408).
|
|
2398
|
+
*
|
|
2399
|
+
* Claude Code treats `matcher` as a regular expression, and this file will not
|
|
2400
|
+
* evaluate one: a regex is answered by matching it against candidate strings,
|
|
2401
|
+
* and the candidates here are "every tool name that exists", which is not a set
|
|
2402
|
+
* this runtime holds. So the only shape read is the documented one — tool names
|
|
2403
|
+
* joined by `|` — and everything else is reported as unresolved coverage rather
|
|
2404
|
+
* than guessed at. An empty matcher is `"pattern"` too: it selects every tool
|
|
2405
|
+
* in Claude Code, which is a coverage claim this row would rather see written
|
|
2406
|
+
* down than infer.
|
|
2407
|
+
*
|
|
2408
|
+
* Nothing here compiles the pattern, so a hostile one (`(a+)+$` and its kin)
|
|
2409
|
+
* costs one anchored character-class test per alternative and cannot make
|
|
2410
|
+
* `doctor` hang.
|
|
2411
|
+
*/
|
|
2412
|
+
function matcherTools(pattern) {
|
|
2413
|
+
if (pattern.trim().length === 0)
|
|
2414
|
+
return "pattern";
|
|
2415
|
+
const parts = pattern.split("|");
|
|
2416
|
+
if (parts.some((part) => !MATCHER_TOOL_NAME.test(part)))
|
|
2417
|
+
return "pattern";
|
|
2418
|
+
return parts;
|
|
2419
|
+
}
|
|
2420
|
+
/** `realpathSync` that answers the path itself when it cannot be resolved. */
|
|
2421
|
+
function realOrSelf(path) {
|
|
2422
|
+
try {
|
|
2423
|
+
return realpathSync(path);
|
|
2424
|
+
}
|
|
2425
|
+
catch {
|
|
2426
|
+
return path;
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
/**
|
|
2430
|
+
* Which checkout the registered handler resolves policy and log from
|
|
2431
|
+
* (APRV-408).
|
|
2432
|
+
*
|
|
2433
|
+
* The committed entry in this repository hardcodes one absolute `--dir`, which
|
|
2434
|
+
* is correct on the machine it was written on and names a directory that does
|
|
2435
|
+
* not exist in a clone anywhere else; nothing checked. A handler bound to
|
|
2436
|
+
* another checkout is not a weaker gate, it is a DIFFERENT gate: another
|
|
2437
|
+
* policy, another log, another open window. That is the one thing this row
|
|
2438
|
+
* fails on.
|
|
2439
|
+
*
|
|
2440
|
+
* `primaryRoot` first and `repoRoot` as the fallback, because the documented
|
|
2441
|
+
* `--dir` names the PRIMARY checkout: in a linked worktree the two differ, and
|
|
2442
|
+
* the primary is the one the gate must answer from.
|
|
2443
|
+
*/
|
|
2444
|
+
function describeBinding(entries, dir, fallbackRoot) {
|
|
2445
|
+
const primary = primaryRoot(dir);
|
|
2446
|
+
const expected = primary ?? fallbackRoot;
|
|
2447
|
+
const expectedName = primary === null ? "repository root" : "primary root";
|
|
2448
|
+
const rank = { pass: 0, skip: 1, fail: 2 };
|
|
2449
|
+
const reports = [];
|
|
2450
|
+
for (const entry of entries) {
|
|
2451
|
+
const parse = entry.parse;
|
|
2452
|
+
// `claudeHookEntries` keeps no foreign entry, and the narrowing is written
|
|
2453
|
+
// out anyway: a reader of this loop should not have to hold that invariant.
|
|
2454
|
+
if (parse.kind === "foreign")
|
|
1849
2455
|
continue;
|
|
1850
|
-
|
|
1851
|
-
|
|
2456
|
+
if (parse.kind === "unresolved") {
|
|
2457
|
+
reports.push({
|
|
2458
|
+
status: "skip",
|
|
2459
|
+
clause: `BINDING UNRESOLVED: the PreToolUse command ${JSON.stringify(entry.command)} cannot be read as an \`approval hook claude-code\` invocation (${parse.reason}), so which checkout it would gate is not established here — and unresolved is never a pass.`,
|
|
2460
|
+
});
|
|
2461
|
+
continue;
|
|
2462
|
+
}
|
|
2463
|
+
const declared = parse.dir;
|
|
2464
|
+
if (declared === null) {
|
|
2465
|
+
reports.push({
|
|
2466
|
+
status: "skip",
|
|
2467
|
+
clause: `BINDING UNSTATED: the PreToolUse command names no \`--dir\`, so the hook resolves its policy and log from wherever the harness happens to run it (APRV-101) rather than from a directory this row can read. The ${expectedName} here is ${expected ?? "unknown to git"}.`,
|
|
2468
|
+
});
|
|
2469
|
+
continue;
|
|
2470
|
+
}
|
|
2471
|
+
if (!isAbsolute(declared)) {
|
|
2472
|
+
reports.push({
|
|
2473
|
+
status: "skip",
|
|
2474
|
+
clause: `BINDING RELATIVE: the PreToolUse command binds \`--dir ${declared}\`, which names a different directory from every working directory it could be launched in. The ${expectedName} here is ${expected ?? "unknown to git"}.`,
|
|
2475
|
+
});
|
|
2476
|
+
continue;
|
|
2477
|
+
}
|
|
2478
|
+
if (expected === null) {
|
|
2479
|
+
reports.push({
|
|
2480
|
+
status: "skip",
|
|
2481
|
+
clause: `BINDING UNCHECKED: the PreToolUse command binds \`--dir ${declared}\`, and git could not say what checkout ${dir} belongs to, so the two cannot be compared here.`,
|
|
2482
|
+
});
|
|
1852
2483
|
continue;
|
|
1853
|
-
for (const entry of entries) {
|
|
1854
|
-
if (typeof entry !== "object" || entry === null)
|
|
1855
|
-
continue;
|
|
1856
|
-
const command = entry["command"];
|
|
1857
|
-
if (typeof command !== "string" || !/\bapproval hook\b/u.test(command))
|
|
1858
|
-
continue;
|
|
1859
|
-
const pattern = matcher["matcher"];
|
|
1860
|
-
found.push(typeof pattern === "string" ? pattern : "");
|
|
1861
2484
|
}
|
|
2485
|
+
if (realOrSelf(declared) !== realOrSelf(expected)) {
|
|
2486
|
+
reports.push({
|
|
2487
|
+
status: "fail",
|
|
2488
|
+
clause: `MISBOUND: the PreToolUse command binds \`--dir ${declared}\`, and this checkout's ${expectedName} is ${expected}. A hook answering from another checkout answers another checkout's policy, log and open window, so a call gated here is not gated by the gate a reader of this repository is looking at.`,
|
|
2489
|
+
});
|
|
2490
|
+
continue;
|
|
2491
|
+
}
|
|
2492
|
+
reports.push({
|
|
2493
|
+
status: "pass",
|
|
2494
|
+
clause: `BOUND to ${expected}, this checkout's ${expectedName}, by the \`--dir\` the PreToolUse command carries.`,
|
|
2495
|
+
});
|
|
1862
2496
|
}
|
|
1863
|
-
|
|
2497
|
+
if (reports.length === 0) {
|
|
2498
|
+
return { status: "skip", clause: "BINDING UNRESOLVED: no PreToolUse command to read." };
|
|
2499
|
+
}
|
|
2500
|
+
const status = reports.reduce((worst, report) => (rank[report.status] > rank[worst] ? report.status : worst), "pass");
|
|
2501
|
+
const clauses = [...new Set(reports.map((report) => report.clause))];
|
|
2502
|
+
return { status, clause: clauses.join(" ") };
|
|
1864
2503
|
}
|
|
1865
2504
|
/**
|
|
1866
2505
|
* Does the settings file THIS worktree carries register the pre-execution hook
|
|
@@ -1884,11 +2523,25 @@ function approvalHookMatchers(hooks, event) {
|
|
|
1884
2523
|
* CI-side, over the committed log, in `core/protected-path-guard.ts`: it does
|
|
1885
2524
|
* not trust session wiring, and this row does not claim to establish it.
|
|
1886
2525
|
*
|
|
1887
|
-
* Advisory, so it never fails the run
|
|
1888
|
-
*
|
|
2526
|
+
* Advisory, so it never fails the run — with the single exception APRV-408
|
|
2527
|
+
* added: a handler POSITIVELY bound to a different checkout. That is not a
|
|
2528
|
+
* weaker report of the same gate, it is a statement that the entry on disk
|
|
2529
|
+
* answers from somewhere else, which this row can establish from the file
|
|
2530
|
+
* alone. Everything else it learned in APRV-408 only ADDS lines from disk
|
|
2531
|
+
* facts: the gated roster comes from the adapter rather than a hand list, the
|
|
2532
|
+
* read tools the adapter declares and the matcher leaves out are named as the
|
|
2533
|
+
* documented default they are, and a matcher tool the adapter does not handle
|
|
2534
|
+
* is named as the `allow` it will actually receive. None of those feeds a
|
|
2535
|
+
* verdict, and none of them asks the session anything about itself (SPEC §11:
|
|
2536
|
+
* a self-reported field never reduces scrutiny, and this row reads no
|
|
2537
|
+
* self-report at all).
|
|
2538
|
+
*
|
|
2539
|
+
* Doctor reads and never writes; the file is `policy.core` and its repair is a
|
|
2540
|
+
* line for a human to commit.
|
|
1889
2541
|
*/
|
|
1890
2542
|
function checkHarnessWiring(dir) {
|
|
1891
2543
|
const check = "harness-hook-wiring";
|
|
2544
|
+
const gated = gatedClaudeTools();
|
|
1892
2545
|
const root = repoRoot(dir);
|
|
1893
2546
|
const where = root === null ? dir : root;
|
|
1894
2547
|
const path = join(where, CLAUDE_SETTINGS);
|
|
@@ -1922,29 +2575,72 @@ function checkHarnessWiring(dir) {
|
|
|
1922
2575
|
const hooks = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
1923
2576
|
? parsed["hooks"]
|
|
1924
2577
|
: null;
|
|
1925
|
-
const
|
|
1926
|
-
if (
|
|
2578
|
+
const entries = claudeHookEntries(hooks, "PreToolUse");
|
|
2579
|
+
if (entries.length === 0) {
|
|
1927
2580
|
return {
|
|
1928
2581
|
check,
|
|
1929
2582
|
status: "skip",
|
|
1930
|
-
detail: `NOT WIRED: ${path} registers no \`approval hook\` entry for PreToolUse, so a protected-path
|
|
1931
|
-
fix:
|
|
2583
|
+
detail: `NOT WIRED: ${path} registers no \`approval hook claude-code\` entry for PreToolUse, so a protected-path write arriving through ${gated.join(", ")} in this checkout reaches the file system unclassified.`,
|
|
2584
|
+
fix: HOOK_ENTRY_FIX,
|
|
1932
2585
|
};
|
|
1933
2586
|
}
|
|
1934
|
-
const
|
|
1935
|
-
const
|
|
2587
|
+
const matchers = entries.map((entry) => entry.matcher);
|
|
2588
|
+
const named = new Set();
|
|
2589
|
+
const patterns = [];
|
|
2590
|
+
for (const matcher of matchers) {
|
|
2591
|
+
const tools = matcherTools(matcher);
|
|
2592
|
+
if (tools === "pattern") {
|
|
2593
|
+
patterns.push(matcher);
|
|
2594
|
+
continue;
|
|
2595
|
+
}
|
|
2596
|
+
for (const tool of tools)
|
|
2597
|
+
named.add(tool);
|
|
2598
|
+
}
|
|
2599
|
+
const binding = describeBinding(entries, dir, root);
|
|
2600
|
+
const wired = `${path} registers \`approval hook claude-code\` for PreToolUse with matcher ${JSON.stringify(matchers.join(", "))}`;
|
|
2601
|
+
const notProof = `This is the file being present, NOT proof this session loaded it — the APRV-151 bypasses happened in worktrees carrying exactly this entry. The check that does not trust session wiring is the CI-side grant cross-check over the committed log, which asks whether the CHANGE was granted rather than whether the path ever was (APRV-202).`;
|
|
2602
|
+
// A matcher this file will not read leaves every coverage question open, so
|
|
2603
|
+
// the roster, read-tool and passthrough lines are withheld rather than
|
|
2604
|
+
// computed against a set that may be incomplete. The binding is independent
|
|
2605
|
+
// of the matcher and is still reported (and can still fail).
|
|
2606
|
+
if (patterns.length > 0) {
|
|
2607
|
+
return {
|
|
2608
|
+
check,
|
|
2609
|
+
status: binding.status === "fail" ? "fail" : "skip",
|
|
2610
|
+
detail: `MATCHED BY PATTERN: ${wired}. ${patterns
|
|
2611
|
+
.map((pattern) => JSON.stringify(pattern))
|
|
2612
|
+
.join(" and ")} is not a plain \`|\`-joined list of tool names, and this row does not evaluate a regular expression, so which of ${gated.join(", ")} it covers is not resolved here. ${binding.clause} ${notProof}`,
|
|
2613
|
+
fix: HOOK_ENTRY_FIX,
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
const missing = gated.filter((tool) => !named.has(tool));
|
|
1936
2617
|
if (missing.length > 0) {
|
|
1937
2618
|
return {
|
|
1938
2619
|
check,
|
|
1939
|
-
status: "skip",
|
|
1940
|
-
detail: `NOT WIRED for every tool: ${
|
|
1941
|
-
fix:
|
|
2620
|
+
status: binding.status === "fail" ? "fail" : "skip",
|
|
2621
|
+
detail: `NOT WIRED for every tool: ${wired}, which does not cover ${missing.join(", ")}. This runtime's Claude Code adapter gates ${gated.join(", ")}, so a protected-path write arriving through ${missing[0]} is never classified. ${binding.clause}`,
|
|
2622
|
+
fix: HOOK_ENTRY_FIX,
|
|
1942
2623
|
};
|
|
1943
2624
|
}
|
|
2625
|
+
// Informational, both of them, and neither is ever a verdict.
|
|
2626
|
+
const held = CLAUDE_HOOK_ADAPTER.readTools.filter((tool) => !named.has(tool));
|
|
2627
|
+
const readLine = held.length === 0
|
|
2628
|
+
? ""
|
|
2629
|
+
: ` READ TOOLS HELD BY DESIGN: the adapter also knows ${held.join(", ")}, and the matcher leaves ${held.length === 1 ? "it" : "them"} out, so a read is answered without reaching the gate. That is the documented default rather than a gap — see "The read scope" in docs/claude-code-hook.md, where the \`Bash|Edit|Write|MultiEdit|NotebookEdit|Read|Glob|Grep\` matcher is printed and deliberately left out of the installed one, because a read is the most frequent tool call a session makes and every matched call is a Node process start. This line never fails this row.`;
|
|
2630
|
+
const handled = new Set([
|
|
2631
|
+
...gated,
|
|
2632
|
+
...CLAUDE_HOOK_ADAPTER.readTools,
|
|
2633
|
+
...(CLAUDE_HOOK_ADAPTER.passThroughTools ?? []),
|
|
2634
|
+
]);
|
|
2635
|
+
const unhandled = [...named].filter((tool) => !handled.has(tool));
|
|
2636
|
+
const passthroughLine = unhandled.length === 0
|
|
2637
|
+
? ""
|
|
2638
|
+
: ` MATCHED AND NOT CLASSIFIED: ${unhandled.join(", ")} ${unhandled.length === 1 ? "is" : "are"} in the matcher and this runtime's Claude Code adapter handles ${unhandled.length === 1 ? "it" : "them"} as neither a shell, file nor read tool, so every such call spawns the hook and is answered \`allow\` with "is not a gated tool". The cost is real and the coverage is not.`;
|
|
1944
2639
|
return {
|
|
1945
2640
|
check,
|
|
1946
|
-
status:
|
|
1947
|
-
detail: `WIRED on disk: ${path} registers \`approval hook\` for PreToolUse over ${
|
|
2641
|
+
status: binding.status,
|
|
2642
|
+
detail: `WIRED on disk: ${path} registers \`approval hook claude-code\` for PreToolUse over ${gated.join(", ")}, the tools this runtime's own adapter gates. ${binding.clause}${readLine}${passthroughLine} ${notProof}`,
|
|
2643
|
+
...(binding.status === "pass" ? {} : { fix: HOOK_ENTRY_FIX }),
|
|
1948
2644
|
};
|
|
1949
2645
|
}
|
|
1950
2646
|
/** The project-local Codex hook files doctor can observe without asking Codex. */
|
|
@@ -2060,10 +2756,138 @@ export function checkCodexHookWiring(dir) {
|
|
|
2060
2756
|
// ---------------------------------------------------------------------------
|
|
2061
2757
|
/** The Cursor counterpart of {@link CLAUDE_SETTINGS}. */
|
|
2062
2758
|
const CURSOR_HOOKS = join(".cursor", "hooks.json");
|
|
2063
|
-
/**
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2759
|
+
/**
|
|
2760
|
+
* The environment variable Hermes Agent resolves its own configuration root
|
|
2761
|
+
* from, and the default it falls back to (APRV-398).
|
|
2762
|
+
*
|
|
2763
|
+
* Read here for ONE purpose: to know where to look for a registration this
|
|
2764
|
+
* checkout cannot hold. Hermes documents no project-local configuration
|
|
2765
|
+
* directory, so a repository-relative search for a Hermes hook would be empty on
|
|
2766
|
+
* every machine and the row would never be able to say anything true.
|
|
2767
|
+
*
|
|
2768
|
+
* Reading an environment variable is acceptable here in the narrow way it is
|
|
2769
|
+
* acceptable in `sandboxRequirement`: it moves where a DIAGNOSTIC looks, and the
|
|
2770
|
+
* row it feeds can only SKIP when it finds nothing and only ADD a line when it
|
|
2771
|
+
* finds something. Nothing an agent can set makes a red line go away, which is
|
|
2772
|
+
* the only property §11.1 invariant 4 asks of a self-reported input.
|
|
2773
|
+
*/
|
|
2774
|
+
const HERMES_HOME_ENV = "HERMES_HOME";
|
|
2775
|
+
const HERMES_HOME_DEFAULT = ".hermes";
|
|
2776
|
+
/**
|
|
2777
|
+
* Where each harness's `approval hook` registration can be written.
|
|
2778
|
+
*
|
|
2779
|
+
* A `Record<HarnessKind, …>` since APRV-398, and the type is the point, exactly
|
|
2780
|
+
* as it is for `HARNESS_ADAPTERS`: a kind added to {@link HARNESS_KINDS} with no
|
|
2781
|
+
* entry here fails to compile, and `tests/harness-enum.test.ts` pins the keys
|
|
2782
|
+
* set-equal to that list. Before this it was a flat array of three paths and a
|
|
2783
|
+
* regex naming three kinds, so `grok` and `muse` shipped adapters that this row
|
|
2784
|
+
* could not see — the same class of miss APRV-358 was filed for, in the one
|
|
2785
|
+
* place APRV-358 did not reach.
|
|
2786
|
+
*
|
|
2787
|
+
* An empty list is a statement rather than a gap, and `codex` has one on
|
|
2788
|
+
* purpose: `.codex/hooks.json` is already read by the Codex-specific rows above
|
|
2789
|
+
* ({@link CODEX_HOOKS}), and this list is consulted by `registeredHarnesses`,
|
|
2790
|
+
* whose behaviour for the three harnesses that were already in it must not
|
|
2791
|
+
* change by a byte in this task. Codex is therefore listed with the file it
|
|
2792
|
+
* always had and nothing new.
|
|
2793
|
+
*/
|
|
2794
|
+
export const HARNESS_SETTINGS = {
|
|
2795
|
+
"claude-code": [{ file: CLAUDE_SETTINGS }],
|
|
2796
|
+
cursor: [{ file: CURSOR_HOOKS }],
|
|
2797
|
+
codex: [{ file: CODEX_HOOKS }],
|
|
2798
|
+
// APRV-243: Grok Build installs a PreToolUse entry per file under
|
|
2799
|
+
// `.grok/hooks/`, so the directory is scanned rather than one name guessed.
|
|
2800
|
+
// It also READS `.claude/settings.json` and `.cursor/hooks.json` for
|
|
2801
|
+
// compatibility, and both of those are already scanned above; a Grok session
|
|
2802
|
+
// hosted by this repository's claude-code entry is the hazard
|
|
2803
|
+
// `docs/grok-hook.md` opens with, not a registration this row should claim.
|
|
2804
|
+
grok: [{ dir: join(".grok", "hooks") }],
|
|
2805
|
+
// APRV-350, observed: the installed build reads `.muse/hooks.json`.
|
|
2806
|
+
// `.muse/settings.json` is listed for the reason the classifier lists it —
|
|
2807
|
+
// Meta documents a user-level `settings.json` `hooks` block, so a
|
|
2808
|
+
// project-level copy would be the same organ under a second name — and an
|
|
2809
|
+
// entry for a file Muse does not read costs a miss rather than a false row.
|
|
2810
|
+
muse: [{ file: join(".muse", "hooks.json") }, { file: join(".muse", "settings.json") }],
|
|
2811
|
+
// APRV-398: not in the checkout at all. See {@link HERMES_HOME_ENV}.
|
|
2812
|
+
hermes: [
|
|
2813
|
+
{ home: "hermes", file: "config.yaml", text: true },
|
|
2814
|
+
{ home: "hermes", file: "config.yml", text: true },
|
|
2815
|
+
],
|
|
2816
|
+
};
|
|
2817
|
+
/**
|
|
2818
|
+
* `approval hook <kind>` inside a command string, whichever file shape holds it.
|
|
2819
|
+
*
|
|
2820
|
+
* DERIVED from {@link HARNESS_KINDS} since APRV-398 rather than spelled, so a
|
|
2821
|
+
* new adapter cannot ship with the pattern left behind — which is what happened
|
|
2822
|
+
* to `grok` and `muse`. Longest alternative first: the alternation is tried left
|
|
2823
|
+
* to right, and a kind that is a prefix of another kind would otherwise match
|
|
2824
|
+
* the shorter spelling and leave the rest of the name outside the capture.
|
|
2825
|
+
*/
|
|
2826
|
+
const HOOK_COMMAND = new RegExp(`\\bapproval["']?\\s+hook\\s+(${[...HARNESS_KINDS]
|
|
2827
|
+
.sort((left, right) => right.length - left.length)
|
|
2828
|
+
.join("|")})\\b`, "u");
|
|
2829
|
+
/**
|
|
2830
|
+
* The places {@link registeredHarnesses} looked, for the row that found nothing.
|
|
2831
|
+
*
|
|
2832
|
+
* Every location of every kind, in kind order, so the line a reader gets names
|
|
2833
|
+
* the file they have to write rather than three of the six. A home-resolved
|
|
2834
|
+
* location is printed with its `$HERMES_HOME/` prefix rather than expanded: the
|
|
2835
|
+
* expansion is this machine's answer, and the instruction is the general one.
|
|
2836
|
+
*/
|
|
2837
|
+
function harnessSettingNames() {
|
|
2838
|
+
return HARNESS_KINDS.flatMap((kind) => HARNESS_SETTINGS[kind].map((location) => {
|
|
2839
|
+
const name = location.file ?? `${location.dir ?? "?"}/`;
|
|
2840
|
+
return location.home === "hermes" ? `$HERMES_HOME/${name}` : name;
|
|
2841
|
+
})).join(" or ");
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* A fresh copy of {@link HOOK_COMMAND}, for the suite that pins it (APRV-398).
|
|
2845
|
+
*
|
|
2846
|
+
* A function rather than the constant, so a caller cannot carry state into this
|
|
2847
|
+
* module's own matching: the pattern is not global today, and a test that shared
|
|
2848
|
+
* the object would silently break on the day somebody adds a flag to it.
|
|
2849
|
+
*/
|
|
2850
|
+
export function hookCommandPattern() {
|
|
2851
|
+
return new RegExp(HOOK_COMMAND.source, HOOK_COMMAND.flags);
|
|
2852
|
+
}
|
|
2853
|
+
/** Hermes's configuration root, from the environment or its documented default. */
|
|
2854
|
+
function hermesHome(env = process.env) {
|
|
2855
|
+
const declared = env[HERMES_HOME_ENV];
|
|
2856
|
+
if (typeof declared === "string" && declared.trim() !== "")
|
|
2857
|
+
return declared;
|
|
2858
|
+
return join(homedir(), HERMES_HOME_DEFAULT);
|
|
2859
|
+
}
|
|
2860
|
+
/** Every file a location names that exists on disk, absolute. */
|
|
2861
|
+
function settingFiles(dir, location) {
|
|
2862
|
+
const base = location.home === "hermes" ? hermesHome() : dir;
|
|
2863
|
+
if (location.file !== undefined) {
|
|
2864
|
+
const path = join(base, location.file);
|
|
2865
|
+
return existsSync(path) ? [path] : [];
|
|
2866
|
+
}
|
|
2867
|
+
if (location.dir === undefined)
|
|
2868
|
+
return [];
|
|
2869
|
+
let names;
|
|
2870
|
+
try {
|
|
2871
|
+
names = readdirSync(join(base, location.dir));
|
|
2872
|
+
}
|
|
2873
|
+
catch {
|
|
2874
|
+
return [];
|
|
2875
|
+
}
|
|
2876
|
+
const found = [];
|
|
2877
|
+
for (const name of names.sort()) {
|
|
2878
|
+
if (location.prefix !== undefined && !name.startsWith(location.prefix))
|
|
2879
|
+
continue;
|
|
2880
|
+
const path = join(base, location.dir, name);
|
|
2881
|
+
try {
|
|
2882
|
+
if (statSync(path).isFile())
|
|
2883
|
+
found.push(path);
|
|
2884
|
+
}
|
|
2885
|
+
catch {
|
|
2886
|
+
continue;
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
return found;
|
|
2890
|
+
}
|
|
2067
2891
|
/**
|
|
2068
2892
|
* Every harness this checkout registers an `approval hook` command for.
|
|
2069
2893
|
*
|
|
@@ -2078,35 +2902,52 @@ const HOOK_COMMAND = /\bapproval["']?\s+hook\s+(claude-code|cursor|codex)\b/u;
|
|
|
2078
2902
|
*/
|
|
2079
2903
|
export function registeredHarnesses(dir) {
|
|
2080
2904
|
const found = new Set();
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
const match = HOOK_COMMAND.exec(node);
|
|
2097
|
-
if (match !== null &&
|
|
2098
|
-
isHarnessKind(match[1]) &&
|
|
2099
|
-
(match[1] !== "codex" || isDirectCodexHookCommand(node))) {
|
|
2100
|
-
found.add(match[1]);
|
|
2905
|
+
const locations = HARNESS_KINDS.flatMap((kind) => HARNESS_SETTINGS[kind]);
|
|
2906
|
+
for (const location of locations) {
|
|
2907
|
+
for (const path of settingFiles(dir, location)) {
|
|
2908
|
+
let parsed;
|
|
2909
|
+
if (location.text === true) {
|
|
2910
|
+
// A YAML document has no JSON leaves to walk, so the whole text is the
|
|
2911
|
+
// haystack. Only the hermes location asks for this; the three that were
|
|
2912
|
+
// here before this task keep the JSON walk they have always had, which
|
|
2913
|
+
// is what keeps their behaviour byte-identical.
|
|
2914
|
+
let text;
|
|
2915
|
+
try {
|
|
2916
|
+
text = readFileSync(path, "utf8");
|
|
2917
|
+
}
|
|
2918
|
+
catch {
|
|
2919
|
+
continue;
|
|
2101
2920
|
}
|
|
2921
|
+
const match = HOOK_COMMAND.exec(text);
|
|
2922
|
+
if (match !== null && isHarnessKind(match[1]))
|
|
2923
|
+
found.add(match[1]);
|
|
2102
2924
|
continue;
|
|
2103
2925
|
}
|
|
2104
|
-
|
|
2105
|
-
|
|
2926
|
+
try {
|
|
2927
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
2928
|
+
}
|
|
2929
|
+
catch {
|
|
2106
2930
|
continue;
|
|
2107
2931
|
}
|
|
2108
|
-
|
|
2109
|
-
|
|
2932
|
+
const stack = [parsed];
|
|
2933
|
+
while (stack.length > 0) {
|
|
2934
|
+
const node = stack.pop();
|
|
2935
|
+
if (typeof node === "string") {
|
|
2936
|
+
const match = HOOK_COMMAND.exec(node);
|
|
2937
|
+
if (match !== null &&
|
|
2938
|
+
isHarnessKind(match[1]) &&
|
|
2939
|
+
(match[1] !== "codex" || isDirectCodexHookCommand(node))) {
|
|
2940
|
+
found.add(match[1]);
|
|
2941
|
+
}
|
|
2942
|
+
continue;
|
|
2943
|
+
}
|
|
2944
|
+
if (Array.isArray(node)) {
|
|
2945
|
+
stack.push(...node);
|
|
2946
|
+
continue;
|
|
2947
|
+
}
|
|
2948
|
+
if (typeof node === "object" && node !== null) {
|
|
2949
|
+
stack.push(...Object.values(node));
|
|
2950
|
+
}
|
|
2110
2951
|
}
|
|
2111
2952
|
}
|
|
2112
2953
|
}
|
|
@@ -2178,6 +3019,20 @@ function recordedHarnessVersions(records) {
|
|
|
2178
3019
|
* has nothing to compare against, and inventing a baseline would be inventing
|
|
2179
3020
|
* the fact); and no such binary on PATH, since doctor may be running somewhere
|
|
2180
3021
|
* the harness is not installed, which is a state and not a fault.
|
|
3022
|
+
*
|
|
3023
|
+
* ## The Hermes fail-closed floor rides in this row (APRV-415)
|
|
3024
|
+
*
|
|
3025
|
+
* One harness has a second version question, and it is graver than the first. A
|
|
3026
|
+
* Hermes below `HERMES_FAIL_CLOSED_FLOOR` does not know `fail_closed` and ignores
|
|
3027
|
+
* it silently, so every broken hook PROCEEDS and the session looks gated while it
|
|
3028
|
+
* is not. That belongs here rather than in a row of its own because both findings
|
|
3029
|
+
* answer one question — is the binary hosting this hook in a state where the gate
|
|
3030
|
+
* is known to fire — and a reader who sees this row green has been told both. It
|
|
3031
|
+
* is read from `<binary> --version` rather than from a record, because Hermes's
|
|
3032
|
+
* build stamp sits behind a non-ASCII separator that the write boundary refuses,
|
|
3033
|
+
* so no record can ever carry it. The comparison is on the BUILD DATE and the
|
|
3034
|
+
* upstream commit: the two builds that differ on this behaviour report the same
|
|
3035
|
+
* semver.
|
|
2181
3036
|
*/
|
|
2182
3037
|
function checkHarnessVersion(dir, records) {
|
|
2183
3038
|
const check = "harness-version-unverified";
|
|
@@ -2188,13 +3043,41 @@ function checkHarnessVersion(dir, records) {
|
|
|
2188
3043
|
return {
|
|
2189
3044
|
check,
|
|
2190
3045
|
status: "skip",
|
|
2191
|
-
detail: `${where} registers no \`approval hook\` command in ${
|
|
3046
|
+
detail: `${where} registers no \`approval hook\` command in ${harnessSettingNames()}, so no harness hosts the hook here and there is no installed version for the log to be behind`,
|
|
2192
3047
|
};
|
|
2193
3048
|
}
|
|
2194
3049
|
const recorded = recordedHarnessVersions(records);
|
|
2195
3050
|
const mismatched = [];
|
|
2196
3051
|
const matched = [];
|
|
2197
3052
|
const unknown = [];
|
|
3053
|
+
// APRV-415. The VERSION FLOOR, which is a different question from the rest of
|
|
3054
|
+
// this row and belongs to it for one reason: both ask whether the binary
|
|
3055
|
+
// hosting this hook is in a state where the gate is known to fire. The row
|
|
3056
|
+
// above asks whether the binary CHANGED without the gate being exercised
|
|
3057
|
+
// since; this asks whether the binary is old enough that a broken hook would
|
|
3058
|
+
// not have stopped anything in the first place.
|
|
3059
|
+
//
|
|
3060
|
+
// It is read off the INSTALLED build rather than off a record, and that is not
|
|
3061
|
+
// a shortcut: Hermes prints its build stamp behind a non-ASCII separator, so
|
|
3062
|
+
// the write boundary's printable-ASCII rule (SPEC.md §11.1 invariant 3) refuses
|
|
3063
|
+
// the whole line and no record ever carries a Hermes version. The floor would be
|
|
3064
|
+
// permanently unknown if it waited for one.
|
|
3065
|
+
const floor = [];
|
|
3066
|
+
const belowFloor = [];
|
|
3067
|
+
if (kinds.includes("hermes")) {
|
|
3068
|
+
const raw = installedHarnessVersionRaw("hermes");
|
|
3069
|
+
const support = hermesFailClosedSupport(raw);
|
|
3070
|
+
const quoted = raw === null ? "(no answer)" : JSON.stringify(raw);
|
|
3071
|
+
if (support === "ignores") {
|
|
3072
|
+
belowFloor.push(`the installed Hermes is BELOW the fail_closed floor: \`${HARNESS_BINARY["hermes"]} --version\` says ${quoted}, and the floor is ${HERMES_FAIL_CLOSED_FLOOR.statement}. On a build that does not know the key, a hook crash, a hook timeout and unparseable hook output all PROCEED, silently, so every gated tool call in this checkout is a backstop rather than a gate and nothing in the session says so`);
|
|
3073
|
+
}
|
|
3074
|
+
else if (support === "honours") {
|
|
3075
|
+
floor.push(`hermes ${quoted} is at or above the fail_closed floor (${HERMES_FAIL_CLOSED_FLOOR.statement}), so a broken hook blocks rather than proceeding`);
|
|
3076
|
+
}
|
|
3077
|
+
else {
|
|
3078
|
+
floor.push(`hermes: the fail_closed floor could not be established from ${quoted} (no build date and not the floor's own commit; two commits cannot be ordered without a repository), so check it by hand — the floor is ${HERMES_FAIL_CLOSED_FLOOR.statement}`);
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
2198
3081
|
for (const kind of kinds) {
|
|
2199
3082
|
const last = recorded.get(kind);
|
|
2200
3083
|
if (last === undefined) {
|
|
@@ -2212,12 +3095,26 @@ function checkHarnessVersion(dir, records) {
|
|
|
2212
3095
|
}
|
|
2213
3096
|
mismatched.push(`${kind} is installed at ${JSON.stringify(installed)} and the last hook-written record (seq ${String(last.seq)}) was issued by ${JSON.stringify(last.version)}`);
|
|
2214
3097
|
}
|
|
3098
|
+
// APRV-415: the floor is reported FIRST when it is breached, because it is the
|
|
3099
|
+
// graver of the two findings. An unverified change means nobody has checked
|
|
3100
|
+
// whether the gate still fires; a build below the floor means a broken hook does
|
|
3101
|
+
// not stop anything, which is established rather than unchecked.
|
|
3102
|
+
if (belowFloor.length > 0) {
|
|
3103
|
+
return {
|
|
3104
|
+
check,
|
|
3105
|
+
status: "fail",
|
|
3106
|
+
detail: `${belowFloor.join("; ")}${mismatched.length === 0
|
|
3107
|
+
? ""
|
|
3108
|
+
: `. And the harness binary changed with no gate exercised since: ${mismatched.join("; ")}`}`,
|
|
3109
|
+
fix: `hermes update (or reinstall from main), then \`approval doctor\` again. The installed version is self-reported, so this row can only ask for a look: it never widens a verdict, and the adapter's own refusals are the same on every build.`,
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
2215
3112
|
if (mismatched.length > 0) {
|
|
2216
3113
|
const first = kinds[0];
|
|
2217
3114
|
return {
|
|
2218
3115
|
check,
|
|
2219
3116
|
status: "fail",
|
|
2220
|
-
detail: `the harness binary changed and the gate has not been exercised since: ${mismatched.join("; ")}. A release can change the hook envelope semantics, so until one record is written under the new binary nothing here shows the hook still fires. The recorded version is self-reported and reduces nothing: a match would not have proved the hook fired either, and what a mismatch says is that nobody has looked
|
|
3117
|
+
detail: `the harness binary changed and the gate has not been exercised since: ${mismatched.join("; ")}. A release can change the hook envelope semantics, so until one record is written under the new binary nothing here shows the hook still fires. The recorded version is self-reported and reduces nothing: a match would not have proved the hook fired either, and what a mismatch says is that nobody has looked.${floor.length === 0 ? "" : ` Separately: ${floor.join("; ")}.`}`,
|
|
2221
3118
|
fix: `approval hook ${first} --dir ${where} < one PreToolUse event for a supervised-class command — the self-test in docs/${first === "cursor" ? "cursor" : "claude-code"}-hook.md. It prompts nobody and writes one task.registered carrying the installed version.`,
|
|
2222
3119
|
};
|
|
2223
3120
|
}
|
|
@@ -2225,13 +3122,13 @@ function checkHarnessVersion(dir, records) {
|
|
|
2225
3122
|
return {
|
|
2226
3123
|
check,
|
|
2227
3124
|
status: "pass",
|
|
2228
|
-
detail: `${matched.join("; ")}${unknown.length === 0 ? "" : `; ${unknown.join("; ")}`}. A match is not proof the hook fired; it is the absence of the one thing this row can see, an unverified change of the binary hosting it
|
|
3125
|
+
detail: `${matched.join("; ")}${unknown.length === 0 ? "" : `; ${unknown.join("; ")}`}. A match is not proof the hook fired; it is the absence of the one thing this row can see, an unverified change of the binary hosting it.${floor.length === 0 ? "" : ` ${floor.join("; ")}.`}`,
|
|
2229
3126
|
};
|
|
2230
3127
|
}
|
|
2231
3128
|
return {
|
|
2232
3129
|
check,
|
|
2233
3130
|
status: "skip",
|
|
2234
|
-
detail: `${where} registers ${kinds.join(", ")} and no comparison could be made: ${unknown.join("; ")}`,
|
|
3131
|
+
detail: `${where} registers ${kinds.join(", ")} and no comparison could be made: ${unknown.join("; ")}${floor.length === 0 ? "" : `. ${floor.join("; ")}`}`,
|
|
2235
3132
|
};
|
|
2236
3133
|
}
|
|
2237
3134
|
// ---------------------------------------------------------------------------
|
|
@@ -2325,16 +3222,46 @@ function checkDarkSessions(logPath, dir, policyPath, records, verified) {
|
|
|
2325
3222
|
* is reported); this list only says where to look, so a directory nobody uses
|
|
2326
3223
|
* costs nothing and a file nobody named is not invented.
|
|
2327
3224
|
*/
|
|
2328
|
-
const ORGAN_SEARCH =
|
|
2329
|
-
{ dir: ".claude", prefix: "settings" },
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
3225
|
+
export const ORGAN_SEARCH = {
|
|
3226
|
+
"claude-code": [{ dir: ".claude", prefix: "settings" }],
|
|
3227
|
+
cursor: [
|
|
3228
|
+
{ dir: ".cursor", prefix: "hooks.json" },
|
|
3229
|
+
{ dir: join(".cursor", "hooks") },
|
|
3230
|
+
{ dir: join(".cursor", "agents") },
|
|
3231
|
+
],
|
|
3232
|
+
// EMPTY ON PURPOSE, and this is the one entry in the record that is a decision
|
|
3233
|
+
// rather than a list. `.codex/config.toml` and `.codex/hooks.json` are organs
|
|
3234
|
+
// by `core/command-class.ts`'s own word, and adding them here would change
|
|
3235
|
+
// what the `gate-organs` row reports in every Codex checkout — a change
|
|
3236
|
+
// APRV-398 has no business making while adding a different harness. The row
|
|
3237
|
+
// they belong in is their own task. What this record buys meanwhile is that
|
|
3238
|
+
// the omission is now VISIBLE and typed rather than an absence nobody could
|
|
3239
|
+
// see: a reader of this table can tell that codex was considered.
|
|
3240
|
+
codex: [],
|
|
3241
|
+
// APRV-243 and APRV-350: the organs the classifier has recognised since those
|
|
3242
|
+
// tasks, which this list never learned about. `.grok/hooks/` and
|
|
3243
|
+
// `.muse/hooks.json` are where each harness's hook is installed, so a
|
|
3244
|
+
// hand-edited one nobody attested is exactly what this row exists to report.
|
|
3245
|
+
grok: [
|
|
3246
|
+
{ dir: ".grok", prefix: "hooks.json" },
|
|
3247
|
+
{ dir: join(".grok", "hooks") },
|
|
3248
|
+
],
|
|
3249
|
+
muse: [{ dir: ".muse", prefix: "hooks" }, { dir: ".muse", prefix: "settings" }],
|
|
3250
|
+
// APRV-398. Hermes's real organ is `$HERMES_HOME/config.yaml` in the user
|
|
3251
|
+
// home, and this row is about THIS CHECKOUT, so what is listed is the
|
|
3252
|
+
// repository-relative directory the classifier would call an organ if a
|
|
3253
|
+
// checkout carried one. A directory nobody uses costs nothing (see the header),
|
|
3254
|
+
// and every candidate found here is put to the classifier before it is
|
|
3255
|
+
// reported, so nothing is invented. The home file is reached by the
|
|
3256
|
+
// harness-version row above, through {@link HARNESS_SETTINGS}.
|
|
3257
|
+
hermes: [{ dir: ".hermes", prefix: "config" }, { dir: join(".hermes", "agent-hooks") }],
|
|
3258
|
+
};
|
|
3259
|
+
/** Every organ directory to look in, across harnesses, in kind order. */
|
|
3260
|
+
const ORGAN_SEARCH_ENTRIES = HARNESS_KINDS.flatMap((kind) => ORGAN_SEARCH[kind]);
|
|
2334
3261
|
/** The organ files this checkout actually carries, repository-relative, sorted. */
|
|
2335
3262
|
function listGateOrgans(root) {
|
|
2336
3263
|
const found = [];
|
|
2337
|
-
for (const entry of
|
|
3264
|
+
for (const entry of ORGAN_SEARCH_ENTRIES) {
|
|
2338
3265
|
let names;
|
|
2339
3266
|
try {
|
|
2340
3267
|
names = readdirSync(join(root, entry.dir));
|
|
@@ -2379,6 +3306,78 @@ function listGateOrgans(root) {
|
|
|
2379
3306
|
* configuration here, which is a state and not a fault, exactly as
|
|
2380
3307
|
* `harness-hook-wiring` treats the same absence.
|
|
2381
3308
|
*/
|
|
3309
|
+
// ---------------------------------------------------------------------------
|
|
3310
|
+
// 30. codex-auto-reviewer (APRV-378)
|
|
3311
|
+
// ---------------------------------------------------------------------------
|
|
3312
|
+
/**
|
|
3313
|
+
* How recent a pre-empted question has to be for this row to FAIL.
|
|
3314
|
+
*
|
|
3315
|
+
* The same twenty-four hours `dark-sessions` watches, and for the same reason:
|
|
3316
|
+
* a row that failed forever over a fact an operator has already dealt with is a
|
|
3317
|
+
* row nobody reads. An older record is not forgotten, it is reported in the
|
|
3318
|
+
* detail of a passing row, which is where a durable fact belongs once the thing
|
|
3319
|
+
* it asks for has been done.
|
|
3320
|
+
*/
|
|
3321
|
+
const AUTO_REVIEWER_WINDOW_MS = DEFAULT_DARK_WINDOW_MS;
|
|
3322
|
+
/**
|
|
3323
|
+
* Has something other than this gate answered a question this gate exists to
|
|
3324
|
+
* ask (APRV-378)?
|
|
3325
|
+
*
|
|
3326
|
+
* It reads `audit.question_preempted` and nothing else. There is no
|
|
3327
|
+
* configuration here to inspect: whether Codex's auto-reviewer runs is gated in
|
|
3328
|
+
* the harness's own configuration under a key this repository has no record of,
|
|
3329
|
+
* and a config read written against a guessed key finds nothing and reports a
|
|
3330
|
+
* GREEN row, which is the worst failure direction a health check has. So this
|
|
3331
|
+
* row reports what was OBSERVED, and the only durable record of an observation
|
|
3332
|
+
* is the one `approval codex bridge` appends when it stops.
|
|
3333
|
+
*
|
|
3334
|
+
* Reading this record is a diagnosis and not enforcement: the row authorizes
|
|
3335
|
+
* nothing and refuses nothing, and `tests/question-preempted.test.ts` pins that
|
|
3336
|
+
* no enforcement module reads the type.
|
|
3337
|
+
*
|
|
3338
|
+
* A row that says `pass` is saying the log holds no such record. It is NOT
|
|
3339
|
+
* saying the auto-reviewer is off: nothing on this machine can say that, which
|
|
3340
|
+
* is why the bridge probes rather than reads (APRV-364).
|
|
3341
|
+
*/
|
|
3342
|
+
function checkCodexAutoReviewer(records, verified) {
|
|
3343
|
+
const check = "codex-auto-reviewer";
|
|
3344
|
+
if (!verified) {
|
|
3345
|
+
return {
|
|
3346
|
+
check,
|
|
3347
|
+
status: "skip",
|
|
3348
|
+
detail: "the chain did not verify, so the records this row reads cannot be trusted; see the log check above",
|
|
3349
|
+
};
|
|
3350
|
+
}
|
|
3351
|
+
const seen = records.filter((record) => record.event === "audit.question_preempted");
|
|
3352
|
+
if (seen.length === 0) {
|
|
3353
|
+
return {
|
|
3354
|
+
check,
|
|
3355
|
+
status: "pass",
|
|
3356
|
+
detail: "no record of anything other than this gate answering a question it exists to ask. That is what the log holds, and it is not a claim that a harness auto-reviewer is off: only a bridge session's own probe can establish that, and only for the question it probed",
|
|
3357
|
+
};
|
|
3358
|
+
}
|
|
3359
|
+
const newest = seen[seen.length - 1];
|
|
3360
|
+
const payload = (newest.payload ?? {});
|
|
3361
|
+
const question = (payload["question"] ?? {});
|
|
3362
|
+
const source = typeof payload["source"] === "string" ? payload["source"] : "an unnamed source";
|
|
3363
|
+
const id = typeof question["id"] === "string" ? question["id"] : "(no id)";
|
|
3364
|
+
const verdict = typeof payload["verdict"] === "string" ? payload["verdict"] : "none stated";
|
|
3365
|
+
const recent = seen.filter((record) => Date.parse(record.ts) >= Date.now() - AUTO_REVIEWER_WINDOW_MS);
|
|
3366
|
+
const where = `${source} answered question ${id} (verdict: ${verdict}) at seq ${String(newest.seq)}`;
|
|
3367
|
+
if (recent.length === 0) {
|
|
3368
|
+
return {
|
|
3369
|
+
check,
|
|
3370
|
+
status: "pass",
|
|
3371
|
+
detail: `${String(seen.length)} record(s), none in the last ${String(Math.round(AUTO_REVIEWER_WINDOW_MS / 3_600_000))}h. The most recent: ${where}`,
|
|
3372
|
+
};
|
|
3373
|
+
}
|
|
3374
|
+
return {
|
|
3375
|
+
check,
|
|
3376
|
+
status: "fail",
|
|
3377
|
+
detail: `${String(recent.length)} of ${String(seen.length)} record(s) in the last ${String(Math.round(AUTO_REVIEWER_WINDOW_MS / 3_600_000))}h: ${where}. A session whose questions something else can answer is a session whose silence means nothing, and the bridge stopped on it`,
|
|
3378
|
+
fix: "approval doctor --verbose, then turn the harness's own auto-reviewer off in its configuration before running `approval codex bridge` again; this runtime cannot turn it off and cannot read whether it is on",
|
|
3379
|
+
};
|
|
3380
|
+
}
|
|
2382
3381
|
function checkGateOrgans(dir, records) {
|
|
2383
3382
|
const check = "gate-organs";
|
|
2384
3383
|
const root = repoRoot(dir) ?? dir;
|
|
@@ -2387,7 +3386,7 @@ function checkGateOrgans(dir, records) {
|
|
|
2387
3386
|
return {
|
|
2388
3387
|
check,
|
|
2389
3388
|
status: "skip",
|
|
2390
|
-
detail: `${root} carries no gate organ files (${
|
|
3389
|
+
detail: `${root} carries no gate organ files (${ORGAN_SEARCH_ENTRIES.map((entry) => entry.dir).join(", ")}), so there is nothing here for a human to have attested`,
|
|
2391
3390
|
};
|
|
2392
3391
|
}
|
|
2393
3392
|
const unattested = [];
|
|
@@ -2432,6 +3431,167 @@ function checkGateOrgans(dir, records) {
|
|
|
2432
3431
|
};
|
|
2433
3432
|
}
|
|
2434
3433
|
// ---------------------------------------------------------------------------
|
|
3434
|
+
// 31. pending-sign-off (APRV-338)
|
|
3435
|
+
// ---------------------------------------------------------------------------
|
|
3436
|
+
/**
|
|
3437
|
+
* The marker SPEC.md's amendment-provenance rule puts on unratified text.
|
|
3438
|
+
*
|
|
3439
|
+
* Matched case-insensitively on the words alone rather than on the whole
|
|
3440
|
+
* `(Amended APRV-n, pending sign-off.)` form, because the form is prose a human
|
|
3441
|
+
* writes and a row that missed a hand-typed variant would report a clean file
|
|
3442
|
+
* that is not one. Over-reporting here costs a line of output; under-reporting
|
|
3443
|
+
* costs the thing the marker exists for.
|
|
3444
|
+
*/
|
|
3445
|
+
const PENDING_SIGN_OFF_MARKER = /pending sign-off/giu;
|
|
3446
|
+
/**
|
|
3447
|
+
* Where to look for protected files carrying the marker.
|
|
3448
|
+
*
|
|
3449
|
+
* One level deep and enumerated, exactly as {@link ORGAN_SEARCH} is, and for
|
|
3450
|
+
* the same reason: `core/command-class.ts` decides what IS a protected path
|
|
3451
|
+
* (every candidate found here is put to it), so this list only says where to
|
|
3452
|
+
* look. A repository-wide walk would be a health check that reads the whole
|
|
3453
|
+
* working tree on every run, and the marker lives in governing documents, which
|
|
3454
|
+
* sit at the root or in a directory a policy named.
|
|
3455
|
+
*/
|
|
3456
|
+
const SIGN_OFF_SEARCH_DIRS = [".", join(".github", "workflows"), "docs", "design"];
|
|
3457
|
+
/** How many bytes of a candidate are read before it is skipped as not-prose. */
|
|
3458
|
+
const SIGN_OFF_MAX_BYTES = 4 * 1024 * 1024;
|
|
3459
|
+
/**
|
|
3460
|
+
* The protected files this checkout carries that doctor will read,
|
|
3461
|
+
* repository-relative and sorted.
|
|
3462
|
+
*
|
|
3463
|
+
* The enumerated directories above, plus every exact path the policy's
|
|
3464
|
+
* `protected_paths` names (a directory entry is enumerated one level deep like
|
|
3465
|
+
* the rest). A candidate is kept only when the classifier calls it `policy.edit`
|
|
3466
|
+
* or a `policy.edit.*` sub-class, which is the same set `approval policy attest
|
|
3467
|
+
* --path` will sign off: a row that listed a file the verb refuses would be
|
|
3468
|
+
* advice nobody can take.
|
|
3469
|
+
*/
|
|
3470
|
+
function listSignableFiles(root, extra) {
|
|
3471
|
+
const dirs = new Set(SIGN_OFF_SEARCH_DIRS);
|
|
3472
|
+
const exact = new Set();
|
|
3473
|
+
for (const raw of extra) {
|
|
3474
|
+
const entry = typeof raw === "string" ? raw : raw.path;
|
|
3475
|
+
if (typeof entry !== "string" || entry.length === 0)
|
|
3476
|
+
continue;
|
|
3477
|
+
if (entry.includes("..") || isAbsolute(entry))
|
|
3478
|
+
continue;
|
|
3479
|
+
if (/[/\\]$/u.test(entry))
|
|
3480
|
+
dirs.add(entry.replace(/[/\\]+$/u, ""));
|
|
3481
|
+
else
|
|
3482
|
+
exact.add(entry.split(/[/\\]+/u).join("/"));
|
|
3483
|
+
}
|
|
3484
|
+
const found = new Set();
|
|
3485
|
+
const keep = (relative) => {
|
|
3486
|
+
let isFile;
|
|
3487
|
+
try {
|
|
3488
|
+
const stats = statSync(join(root, relative));
|
|
3489
|
+
isFile = stats.isFile() && stats.size <= SIGN_OFF_MAX_BYTES;
|
|
3490
|
+
}
|
|
3491
|
+
catch {
|
|
3492
|
+
return;
|
|
3493
|
+
}
|
|
3494
|
+
if (!isFile)
|
|
3495
|
+
return;
|
|
3496
|
+
const routed = protectedPathClass(relative, extra);
|
|
3497
|
+
if (routed === "policy.edit" || (routed !== null && POLICY_EDIT_SUBCLASS.test(routed))) {
|
|
3498
|
+
found.add(relative);
|
|
3499
|
+
}
|
|
3500
|
+
};
|
|
3501
|
+
for (const dir of dirs) {
|
|
3502
|
+
let names;
|
|
3503
|
+
try {
|
|
3504
|
+
names = readdirSync(join(root, dir));
|
|
3505
|
+
}
|
|
3506
|
+
catch {
|
|
3507
|
+
continue;
|
|
3508
|
+
}
|
|
3509
|
+
const prefix = dir === "." ? "" : `${dir.split(/[/\\]+/u).join("/")}/`;
|
|
3510
|
+
for (const name of names.sort())
|
|
3511
|
+
keep(`${prefix}${name}`);
|
|
3512
|
+
}
|
|
3513
|
+
for (const path of exact)
|
|
3514
|
+
keep(path);
|
|
3515
|
+
return [...found].sort();
|
|
3516
|
+
}
|
|
3517
|
+
/**
|
|
3518
|
+
* Which protected files carry a pending-sign-off marker that no record
|
|
3519
|
+
* resolves (APRV-338)?
|
|
3520
|
+
*
|
|
3521
|
+
* SPEC.md's amendment-provenance rule says text that reached a protected file
|
|
3522
|
+
* without a grant carries `(Amended APRV-n, pending sign-off.)` and holds no
|
|
3523
|
+
* more authority than a proposal until a human ratifies it. Since APRV-338 the
|
|
3524
|
+
* ratification is a record, `gate.path.signed_off`, so the state is finally
|
|
3525
|
+
* answerable: a file whose CURRENT bytes carry a sign-off is ratified as it
|
|
3526
|
+
* stands, and one that carries the marker with no such record is a debt
|
|
3527
|
+
* somebody owes.
|
|
3528
|
+
*
|
|
3529
|
+
* **This row never moves the exit code**, for the reasons `gate-organs` does
|
|
3530
|
+
* not. Nothing on this machine is broken by unratified prose; what the row buys
|
|
3531
|
+
* is that the debt is visible at the terminal rather than discovered when a
|
|
3532
|
+
* pull request fails. And a marker is a human's own sentence about their own
|
|
3533
|
+
* text: a health check that failed on one would be the runtime grading prose.
|
|
3534
|
+
*/
|
|
3535
|
+
function checkPendingSignOff(dir, records, policyLoad) {
|
|
3536
|
+
const check = "pending-sign-off";
|
|
3537
|
+
const root = repoRoot(dir) ?? dir;
|
|
3538
|
+
// A policy that did not load contributes no entries, which narrows the set
|
|
3539
|
+
// doctor reads rather than widening it — the same direction every other
|
|
3540
|
+
// reader of `protected_paths` takes when it cannot have the list.
|
|
3541
|
+
const extra = policyLoad.ok ? (policyLoad.policy.protected_paths ?? []) : [];
|
|
3542
|
+
const files = listSignableFiles(root, extra);
|
|
3543
|
+
const pending = [];
|
|
3544
|
+
const ratified = [];
|
|
3545
|
+
const unreadable = [];
|
|
3546
|
+
for (const file of files) {
|
|
3547
|
+
let text;
|
|
3548
|
+
let sha256;
|
|
3549
|
+
try {
|
|
3550
|
+
const bytes = readFileSync(join(root, file));
|
|
3551
|
+
text = bytes.toString("utf8");
|
|
3552
|
+
sha256 = policyBytesHash(bytes);
|
|
3553
|
+
}
|
|
3554
|
+
catch (cause) {
|
|
3555
|
+
unreadable.push(`${file} (${detailOf(cause)})`);
|
|
3556
|
+
continue;
|
|
3557
|
+
}
|
|
3558
|
+
const markers = text.match(PENDING_SIGN_OFF_MARKER)?.length ?? 0;
|
|
3559
|
+
if (markers === 0)
|
|
3560
|
+
continue;
|
|
3561
|
+
const plural = markers === 1 ? "" : "s";
|
|
3562
|
+
if (findPathSignOff(records, file, sha256) !== null) {
|
|
3563
|
+
ratified.push(`${file} (${String(markers)} marker${plural}, signed off at these bytes)`);
|
|
3564
|
+
continue;
|
|
3565
|
+
}
|
|
3566
|
+
const previous = latestPathSignOff(records, file);
|
|
3567
|
+
pending.push(previous === null
|
|
3568
|
+
? `${file} (${String(markers)} marker${plural}, never signed off, live ${sha256.slice(0, 12)}…)`
|
|
3569
|
+
: `${file} (${String(markers)} marker${plural}, edited since seq ${previous.record.seq}: signed ${previous.sha256.slice(0, 12)}…, live ${sha256.slice(0, 12)}…)`);
|
|
3570
|
+
}
|
|
3571
|
+
if (pending.length === 0 && unreadable.length === 0) {
|
|
3572
|
+
return {
|
|
3573
|
+
check,
|
|
3574
|
+
status: "pass",
|
|
3575
|
+
detail: ratified.length === 0
|
|
3576
|
+
? `no protected file in ${root} carries a pending-sign-off marker (${String(files.length)} file(s) read)`
|
|
3577
|
+
: `every pending-sign-off marker in ${root} sits in a file a human has signed off at its current bytes: ${ratified.join("; ")}`,
|
|
3578
|
+
};
|
|
3579
|
+
}
|
|
3580
|
+
const parts = [];
|
|
3581
|
+
if (pending.length > 0)
|
|
3582
|
+
parts.push(`AWAITING SIGN-OFF: ${pending.join("; ")}`);
|
|
3583
|
+
if (unreadable.length > 0)
|
|
3584
|
+
parts.push(`unreadable: ${unreadable.join("; ")}`);
|
|
3585
|
+
const first = (pending[0] ?? "<path>").split(" ")[0] ?? "<path>";
|
|
3586
|
+
return {
|
|
3587
|
+
check,
|
|
3588
|
+
// Never a fail: see the note above.
|
|
3589
|
+
status: "skip",
|
|
3590
|
+
detail: `${parts.join(". ")}. Text carrying that marker holds no more authority than a proposal until a human ratifies it (SPEC.md §5.2), and the ratification is a gate.path.signed_off record over the file's exact bytes. A marker whose text was later granted through the gate should lose the suffix instead; signing off is for text that reached the file without a grant`,
|
|
3591
|
+
fix: `approval policy attest --path ${first} --as human:<id> — after READING the file as it now stands`,
|
|
3592
|
+
};
|
|
3593
|
+
}
|
|
3594
|
+
// ---------------------------------------------------------------------------
|
|
2435
3595
|
// 28. sealed-keys (APRV-285)
|
|
2436
3596
|
// ---------------------------------------------------------------------------
|
|
2437
3597
|
/** The exact line doctor tells an operator to add for the sealed-token key store. */
|
|
@@ -2478,10 +3638,11 @@ function trackedPrivateKeys(root, keyDir) {
|
|
|
2478
3638
|
* `token_sealed` for as long as the token is unspent and inside its TTL, so the
|
|
2479
3639
|
* whole design of sealed delivery rests on the key never being committed.
|
|
2480
3640
|
*
|
|
2481
|
-
* Nothing enforced that. `.approval/payloads/`
|
|
2482
|
-
*
|
|
2483
|
-
*
|
|
2484
|
-
*
|
|
3641
|
+
* Nothing enforced that. `.approval/payloads/` was TRACKED by default until
|
|
3642
|
+
* APRV-445 (and still is wherever an operator keeps it in history, as this
|
|
3643
|
+
* repository does), so `.approval/` is a directory an operator adds from during
|
|
3644
|
+
* a records or ceremony commit, and a key store with no ignore line is swept in
|
|
3645
|
+
* by the same `git add` that carries the payloads.
|
|
2485
3646
|
*
|
|
2486
3647
|
* Two questions, in the order of what stays wrong the longest, the same reading
|
|
2487
3648
|
* the vault and environment rows use:
|
|
@@ -2530,7 +3691,7 @@ function checkSealedKeys(logPath, dir) {
|
|
|
2530
3691
|
return {
|
|
2531
3692
|
check,
|
|
2532
3693
|
status: "fail",
|
|
2533
|
-
detail: `${String(present.length)} sealed-delivery private key(s) in ${keyDir} are NOT gitignored in ${dir}: one \`git add .approval/\` — the command a records or ceremony commit uses,
|
|
3694
|
+
detail: `${String(present.length)} sealed-delivery private key(s) in ${keyDir} are NOT gitignored in ${dir}: one \`git add .approval/\` — the command a records or ceremony commit uses, wherever \`.approval/payloads/\` is kept in history — publishes a live key beside the ciphertext it opens`,
|
|
2534
3695
|
fix: `echo '${KEYS_IGNORE_LINE}' >> ${join(dir, ".gitignore")} — the line \`approval init\` writes; and treat every action whose token is still unspent inside its TTL as disclosed`,
|
|
2535
3696
|
};
|
|
2536
3697
|
}
|
|
@@ -2667,7 +3828,7 @@ export function commandDoctor(argv, streams, cwd) {
|
|
|
2667
3828
|
checkIdentity(),
|
|
2668
3829
|
checkAttestationHealth(verified.records, policyPath),
|
|
2669
3830
|
checkLog(logPath, verified.result),
|
|
2670
|
-
await checkTelegram(apiBase, policyLoad),
|
|
3831
|
+
await checkTelegram(apiBase, policyLoad, logPath),
|
|
2671
3832
|
await checkWebPort(port ?? WEB_DEFAULT_PORT),
|
|
2672
3833
|
checkPayloadStore(logPath, verified.records),
|
|
2673
3834
|
// APRV-271: asks the running daemon for the one half of this answer that
|
|
@@ -2710,6 +3871,15 @@ export function commandDoctor(argv, streams, cwd) {
|
|
|
2710
3871
|
// network to be more accurate would be acting on its own account, so the
|
|
2711
3872
|
// answer is as fresh as the operator's last fetch and says so.
|
|
2712
3873
|
checkMainBehindOrigin(logPath, queuePath, root),
|
|
3874
|
+
// APRV-342: the interregnum between an attestation and its pull request
|
|
3875
|
+
// merging. `attestation` above asks whether the LOCAL policy is attested;
|
|
3876
|
+
// this asks whether the attested policy is on the remote, which is the
|
|
3877
|
+
// question a fresh checkout of main answers with `policy-not-attested`.
|
|
3878
|
+
checkAttestedPolicyOnMain({
|
|
3879
|
+
policyPath,
|
|
3880
|
+
records: verified.records,
|
|
3881
|
+
root: repoRoot(dirname(logPath)),
|
|
3882
|
+
}),
|
|
2713
3883
|
// APRV-227: appended, fourteenth time, same reason. The only row that
|
|
2714
3884
|
// asks a question about a binary OUTSIDE this repository, and it asks it
|
|
2715
3885
|
// the one way a log can: what the last record said the harness was,
|
|
@@ -2738,13 +3908,43 @@ export function commandDoctor(argv, streams, cwd) {
|
|
|
2738
3908
|
checkGateOrgans(dir, verified.records),
|
|
2739
3909
|
// APRV-285: appended, eighteenth time, same reason. The sibling of the
|
|
2740
3910
|
// vault and environment rows for the one file under `.approval/` that is
|
|
2741
|
-
// a raw private key: `.approval/payloads/` is tracked
|
|
2742
|
-
//
|
|
2743
|
-
//
|
|
3911
|
+
// a raw private key: wherever `.approval/payloads/` is tracked (the
|
|
3912
|
+
// default before APRV-445, and this repository's choice), `.approval/` is
|
|
3913
|
+
// a directory people `git add` from, and the key store had nothing
|
|
3914
|
+
// telling them it must not come along.
|
|
2744
3915
|
checkSealedKeys(logPath, dir),
|
|
2745
3916
|
// APRV-313: appended, nineteenth time, same reason. Configuration on
|
|
2746
3917
|
// disk is distinct from Codex trust, loading and observed execution.
|
|
2747
3918
|
checkCodexHookWiring(dir),
|
|
3919
|
+
// APRV-335: appended, twentieth time, same reason. The health-report half
|
|
3920
|
+
// of the alias deprecation SPEC.md §5.2 now states: the loader's note is
|
|
3921
|
+
// seen by whoever runs a verb that prints notes, and this is the surface
|
|
3922
|
+
// an operator opens to read the state of their own repository.
|
|
3923
|
+
checkAutonomyAlias(policyLoad),
|
|
3924
|
+
// APRV-338: appended, twenty-first time, same reason. The terminal half
|
|
3925
|
+
// of the sign-off record: which protected files still carry SPEC.md's
|
|
3926
|
+
// pending-sign-off marker with no record ratifying their current bytes.
|
|
3927
|
+
// Informational and never a fail, exactly as gate-organs is — nothing on
|
|
3928
|
+
// this machine is broken by unratified prose, and a health check that
|
|
3929
|
+
// graded prose would be the runtime marking a human's homework.
|
|
3930
|
+
checkPendingSignOff(dir, verified.records, policyLoad),
|
|
3931
|
+
// APRV-324: appended, twenty-second time, same reason. The row design
|
|
3932
|
+
// §5 item 13 asks for, and the one an operator needs BEFORE the first
|
|
3933
|
+
// tap is refused: declaring a `senders` entry turns enforcement on for
|
|
3934
|
+
// that whole channel, so an approver left out of it is a person the
|
|
3935
|
+
// policy still lists and the gate will no longer honour.
|
|
3936
|
+
checkSenderMapping(policyLoad),
|
|
3937
|
+
// APRV-378: appended, twenty-third time, same reason. The terminal half
|
|
3938
|
+
// of `audit.question_preempted`: the bridge stops the session that saw
|
|
3939
|
+
// one, and this is where an operator finds out it happened without
|
|
3940
|
+
// reading the log by hand.
|
|
3941
|
+
checkCodexAutoReviewer(verified.records, verified.result.status === "clean"),
|
|
3942
|
+
// APRV-383: appended, twenty-fourth time, same reason. The sibling of
|
|
3943
|
+
// `keychain-scope` one layer along — that row says whose credentials this
|
|
3944
|
+
// instance uses, this one says whose name its daemon writes onto every
|
|
3945
|
+
// record — and the row an operator wiring up a hosted daemon needs BEFORE
|
|
3946
|
+
// anything is running.
|
|
3947
|
+
checkDaemonIdentity(logPath, verified.records, policyLoad),
|
|
2748
3948
|
];
|
|
2749
3949
|
const ok = checks.every((entry) => entry.status !== "fail");
|
|
2750
3950
|
if (json)
|