approval-md 0.3.0 → 0.4.1
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 +83 -914
- package/SPEC.md +101 -13
- package/cli.js +134 -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 +72 -0
- package/dist/src/channels/contract.js +161 -1
- 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.d.ts +25 -0
- package/dist/src/channels/tagging.js +63 -6
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-minimal.d.ts +251 -0
- package/dist/src/channels/telegram-minimal.js +618 -0
- package/dist/src/channels/telegram-minimal.js.map +1 -0
- 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 +725 -20
- package/dist/src/channels/telegram.js +1332 -123
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +358 -5
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.js +43 -6
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/audit-card.d.ts +1 -1
- package/dist/src/cli/audit-card.js +40 -1
- package/dist/src/cli/audit-card.js.map +1 -1
- package/dist/src/cli/audit.d.ts +7 -5
- package/dist/src/cli/audit.js +22 -10
- package/dist/src/cli/audit.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 +275 -0
- package/dist/src/cli/channel-telegram-webhook.js +716 -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 +648 -56
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +1 -1
- package/dist/src/cli/channel.js +5 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/daemon.js +37 -5
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +856 -111
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +29 -1
- package/dist/src/cli/execute.js +387 -21
- 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 +10 -1
- package/dist/src/cli/help.js +283 -54
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +485 -6
- package/dist/src/cli/hook.js +2370 -70
- package/dist/src/cli/hook.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/log-unlock.d.ts +23 -0
- package/dist/src/cli/log-unlock.js +109 -0
- package/dist/src/cli/log-unlock.js.map +1 -0
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +72 -4
- 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/scaffold.js +6 -0
- 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/up.js +26 -2
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +338 -39
- 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/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 +20 -3
- package/dist/src/core/attest.js +25 -0
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +153 -12
- package/dist/src/core/audit.js +415 -20
- 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/command-class.d.ts +51 -0
- package/dist/src/core/command-class.js +766 -8
- package/dist/src/core/command-class.js.map +1 -1
- 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/gate.d.ts +364 -6
- package/dist/src/core/gate.js +802 -27
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +179 -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/log-lock.d.ts +435 -0
- package/dist/src/core/log-lock.js +1093 -0
- package/dist/src/core/log-lock.js.map +1 -0
- 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 +130 -22
- package/dist/src/core/log.js +473 -50
- 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 +7 -1
- package/dist/src/core/policy-explain.js +16 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +24 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +38 -0
- package/dist/src/core/policy-match.js +35 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +40 -0
- package/dist/src/core/policy-proposal.js +37 -16
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/prompt-layout.d.ts +186 -1
- package/dist/src/core/prompt-layout.js +345 -3
- package/dist/src/core/prompt-layout.js.map +1 -1
- package/dist/src/core/read-scope.d.ts +10 -0
- package/dist/src/core/read-scope.js +4 -0
- package/dist/src/core/read-scope.js.map +1 -1
- 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/sender-identity.d.ts +59 -11
- package/dist/src/core/sender-identity.js +79 -11
- package/dist/src/core/sender-identity.js.map +1 -1
- 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/validate.d.ts +20 -6
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.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/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 +298 -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 +22 -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 +166 -0
- package/dist/src/serve/archive.js +363 -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 +768 -0
- package/docs/cli-reference.md +2139 -67
- package/package.json +4 -2
- package/schema/event.schema.json +413 -30
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -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/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-requested-harness-cap.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
- package/schema/fixtures/event/valid/audit-sampled.json +2 -1
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +16 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
- package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -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/policy.schema.json +102 -6
package/dist/src/cli/hook.js
CHANGED
|
@@ -72,29 +72,31 @@
|
|
|
72
72
|
* auditor holding the log alone. See `docs/claude-code-hook.md`.
|
|
73
73
|
*/
|
|
74
74
|
import { spawnSync } from "node:child_process";
|
|
75
|
-
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
75
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeSync } from "node:fs";
|
|
76
76
|
import { randomBytes } from "node:crypto";
|
|
77
77
|
import { tmpdir } from "node:os";
|
|
78
78
|
import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments, sep, } from "node:path";
|
|
79
79
|
import { attestationRefusal, checkAttestation } from "../core/attest.js";
|
|
80
80
|
import { childEnvironment } from "../core/child-env.js";
|
|
81
|
-
import { classifyCommand, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, protectedPathClass, } from "../core/command-class.js";
|
|
82
|
-
import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, register, request, startHarnessExecution, withdraw, } from "../core/gate.js";
|
|
81
|
+
import { classifyCommand, commandSegmentShapes, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, isCredentialPath, protectedPathClass, } from "../core/command-class.js";
|
|
82
|
+
import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, isProposalTask, register, request, startHarnessExecution, withdraw, } from "../core/gate.js";
|
|
83
83
|
import { openGateWindow, recordGateBypass, } from "../core/gate-window.js";
|
|
84
84
|
import { harnessProvenance, isHarnessKind, } from "../core/harness-version.js";
|
|
85
|
-
import { abandonedAfterMs, HOOK_DEFAULT_WAIT, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
|
|
85
|
+
import { abandonedAfterMs, HARNESS_CAP_MARGIN_MS, harnessCapFitsMargin, harnessCappedTtlMs, HOOK_DEFAULT_WAIT, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
|
|
86
86
|
import { harnessLoopFloor, isLoopEscalated, isSideEffectingClass, loopClearance, UNKNOWN_SESSION, } from "../core/loop.js";
|
|
87
87
|
import { drawSocketPathFor, drawSocketUsable } from "../core/live-draw.js";
|
|
88
|
+
import { appendLockHeld, DEFAULT_LOCK_RETRY_MS, DEFAULT_LOCK_TIMEOUT_MS, } from "../core/log.js";
|
|
89
|
+
import { settleTerminationGuards } from "../core/log-lock.js";
|
|
88
90
|
import { payloadHash } from "../core/payload.js";
|
|
89
91
|
import { classifyApplyPatch, parseApplyPatch } from "../core/apply-patch.js";
|
|
90
92
|
import { loadPolicy, parseDuration } from "../core/policy-load.js";
|
|
91
93
|
import { READ_OUT_OF_SCOPE_CLASS, effectiveReadRoots, isInReadScope, readTargetsOf, renderReadRoots, } from "../core/read-scope.js";
|
|
92
94
|
import { harnessLaunchNeedsRule, harnessLaunchUnruledRefusal, humanOnlyRefusal, resolve as resolvePolicy, } from "../core/policy-match.js";
|
|
93
|
-
import { payloadOf, readVerifiedRecords, requestState, useVerifiedSnapshots, } from "../core/state.js";
|
|
95
|
+
import { payloadOf, readVerifiedRecords, requestExpiresAt, requestState, useVerifiedSnapshots, } from "../core/state.js";
|
|
94
96
|
import { boolFlag, parseFlags, stringFlag } from "./args.js";
|
|
95
97
|
import { EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
|
|
96
98
|
import { primaryRoot as resolvePrimaryRoot } from "./git-scope.js";
|
|
97
|
-
import { HOOK_GROK_HELP, HOOK_HELP, HOOK_MUSE_HELP } from "./help.js";
|
|
99
|
+
import { HOOK_GROK_HELP, HOOK_HELP, HOOK_HERMES_HELP, HOOK_MUSE_HELP } from "./help.js";
|
|
98
100
|
import { DEFAULT_LOG_PATH } from "./paths.js";
|
|
99
101
|
import { refusal as renderRefusal, style, table } from "./style.js";
|
|
100
102
|
import { usageErrorText } from "./usage.js";
|
|
@@ -111,6 +113,22 @@ const PRINCIPAL_ACTOR = /^(human|agent):.+/u;
|
|
|
111
113
|
const DEFAULT_TIMEOUT = HOOK_DEFAULT_WAIT;
|
|
112
114
|
/** Poll interval for the decision wait. */
|
|
113
115
|
const DEFAULT_INTERVAL_MS = 1_000;
|
|
116
|
+
/**
|
|
117
|
+
* How many CONSECUTIVE torn-tail reads the decision wait reads past before it
|
|
118
|
+
* treats the log as unreadable (APRV-427 review).
|
|
119
|
+
*
|
|
120
|
+
* The poll reads the log while other writers append to it: the daemon beside
|
|
121
|
+
* a CLI hook, and every other hook call beside one run by `approval serve`. An
|
|
122
|
+
* append is one write on an O_APPEND handle, but a reader on a filesystem that
|
|
123
|
+
* grows a file a page at a time can see the front of a line before its end, and
|
|
124
|
+
* the verified read calls that a torn tail. It is a moment in someone else's
|
|
125
|
+
* write, and the next tick reads the whole line. Treating it as terminal
|
|
126
|
+
* withdrew a live question and denied `hook-io` because a DIFFERENT call was
|
|
127
|
+
* mid-append. So a torn read is read again next tick, like a view that lags;
|
|
128
|
+
* only a tail that stays torn for this many ticks in a row (a crashed write,
|
|
129
|
+
* which nobody is going to finish) takes the old withdraw-and-deny path.
|
|
130
|
+
*/
|
|
131
|
+
export const TORN_TAIL_RETRIES = 5;
|
|
114
132
|
/**
|
|
115
133
|
* How much of the command line goes in the (claimed) summary field.
|
|
116
134
|
*
|
|
@@ -199,8 +217,36 @@ export const HOOK_DENY_CODES = [
|
|
|
199
217
|
* directory, once. Past the grace the hook withdraws it (reason `timeout`),
|
|
200
218
|
* because a question nothing will adopt is a message on a phone that decides
|
|
201
219
|
* nothing.
|
|
220
|
+
*
|
|
221
|
+
* **Who withdraws, stated because the deny text used to imply a scheduler
|
|
222
|
+
* that does not exist (APRV-410).** This process is over when it denies, so
|
|
223
|
+
* the withdrawal is made by the NEXT gated tool call this actor makes, from
|
|
224
|
+
* the sweep at the top of every gated invocation. It cannot be anyone else: a
|
|
225
|
+
* withdrawal is the requester's own (APRV-106 rule 1, `not-requester`), the
|
|
226
|
+
* gate refuses a `system:` withdrawal outright, and the event schema binds
|
|
227
|
+
* the one `system:` withdrawal that exists to `policy-drift` and to nothing
|
|
228
|
+
* else (APRV-235). So a session that denies here and then makes no further
|
|
229
|
+
* gated call leaves the question pending until the TTL, and a decision on it
|
|
230
|
+
* lands as an ordinary grant. What stops that grant authorizing anything is
|
|
231
|
+
* the other half of APRV-410: a retry past the grace does not carry it
|
|
232
|
+
* (`findHarnessCarry`), so it asks again.
|
|
202
233
|
*/
|
|
203
234
|
"hook-timeout",
|
|
235
|
+
/**
|
|
236
|
+
* The harness ceiling this hook was told it runs under leaves no window a
|
|
237
|
+
* human could answer in (APRV-423).
|
|
238
|
+
*
|
|
239
|
+
* `--harness-cap` (or the harness's own documented maximum) at or below
|
|
240
|
+
* `HARNESS_CAP_MARGIN_MS` means the question would lapse at the instant it
|
|
241
|
+
* was asked: the request must end a margin before the harness kills the
|
|
242
|
+
* process that opened it, and there is no room left for the asking. Nothing
|
|
243
|
+
* is registered, nothing is requested and nobody's phone lights up. The
|
|
244
|
+
* repair is a configuration change — raise the harness entry's timeout, or
|
|
245
|
+
* state the real one — and it is a refusal rather than a silent widening
|
|
246
|
+
* because a hook that quietly ignored its own ceiling would be back to
|
|
247
|
+
* recording grants on calls nobody holds.
|
|
248
|
+
*/
|
|
249
|
+
"hook-harness-cap-too-short",
|
|
204
250
|
/** The gate refused intake; the gate's own code follows a colon. */
|
|
205
251
|
"hook-gate-refused",
|
|
206
252
|
/**
|
|
@@ -283,6 +329,32 @@ export const HOOK_DENY_CODES = [
|
|
|
283
329
|
* and it cannot recall a prompt the model has already been sent.
|
|
284
330
|
*/
|
|
285
331
|
"hook-muse-contributor-model",
|
|
332
|
+
/**
|
|
333
|
+
* A Hermes Agent `execute_code` call, refused before anything else looks at
|
|
334
|
+
* it (APRV-398).
|
|
335
|
+
*
|
|
336
|
+
* The tool takes a `code` string and runs it in-process. There is no path, no
|
|
337
|
+
* argv and no working directory: nothing the classifier reads exists in the
|
|
338
|
+
* call, so no class can be resolved and no payload can bind what will happen.
|
|
339
|
+
* A verdict over `{code}` would authorize a program this runtime never
|
|
340
|
+
* parsed, which is the same hole `hook-opaque` closes for `bash -c` and the
|
|
341
|
+
* same one `hook-unsupported-execution-context` closes for a command whose
|
|
342
|
+
* directory is invisible. SPEC.md §11.1 says ambiguity resolves to the
|
|
343
|
+
* stricter path, and the strictest honest answer to "may I run this arbitrary
|
|
344
|
+
* program" is no.
|
|
345
|
+
*
|
|
346
|
+
* Distinct from `hook-opaque`, and the distinction is the repair. That one
|
|
347
|
+
* says a command line carried a construct the classifier could not read, and
|
|
348
|
+
* the fix is to write the command differently. This one says the TOOL has no
|
|
349
|
+
* readable surface at all on this harness, so there is no spelling of an
|
|
350
|
+
* `execute_code` call that would be answered differently: the repair is to do
|
|
351
|
+
* the work through the shell tool, where the words are visible and the
|
|
352
|
+
* classifier can price them, or through `approval run` with a granted token.
|
|
353
|
+
*
|
|
354
|
+
* Distinct from `hook-unclassified`, which says the classifier had nothing to
|
|
355
|
+
* say about bytes it could see. Here there are no bytes to see.
|
|
356
|
+
*/
|
|
357
|
+
"hook-hermes-execute-code-unbound",
|
|
286
358
|
/** Malformed hook input, or a log/filesystem fact that stopped the check. */
|
|
287
359
|
"hook-io",
|
|
288
360
|
];
|
|
@@ -356,6 +428,22 @@ export function hookScope(flags, cwd) {
|
|
|
356
428
|
function policyRootOf(load, fallback) {
|
|
357
429
|
return load.ok ? dirname(load.source.path) : fallback;
|
|
358
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* The ceiling a harness puts on its hook process, in milliseconds, or `null`
|
|
433
|
+
* (APRV-423): the operator's stated `--harness-cap` clamped to the adapter's
|
|
434
|
+
* contractual maximum, or, with nothing stated, the adapter's documented
|
|
435
|
+
* default, falling back to that maximum.
|
|
436
|
+
*
|
|
437
|
+
* Exported so `approval serve` computes the same number the hook will before
|
|
438
|
+
* it hands the call to a thread (APRV-427 review): the budget a queued call
|
|
439
|
+
* has left is this less the time it has waited.
|
|
440
|
+
*/
|
|
441
|
+
export function effectiveHarnessCapMs(adapter, statedCapMs) {
|
|
442
|
+
const ceilingMs = adapter.capCeilingMs ?? null;
|
|
443
|
+
if (statedCapMs === null)
|
|
444
|
+
return adapter.capDefaultMs ?? ceilingMs;
|
|
445
|
+
return ceilingMs === null ? statedCapMs : Math.min(statedCapMs, ceilingMs);
|
|
446
|
+
}
|
|
359
447
|
const CLAUDE_ADAPTER = {
|
|
360
448
|
kind: "claude-code",
|
|
361
449
|
originApp: "claude-code-hook",
|
|
@@ -491,6 +579,239 @@ const MUSE_ADAPTER = {
|
|
|
491
579
|
shellCwdKey: "workdir",
|
|
492
580
|
contributorModelGuard: true,
|
|
493
581
|
};
|
|
582
|
+
/**
|
|
583
|
+
* Hermes Agent's event names, which are its own and not Claude Code's
|
|
584
|
+
* (APRV-398).
|
|
585
|
+
*
|
|
586
|
+
* snake_case, and the VERBS differ too: `pre_tool_call` where every other
|
|
587
|
+
* harness in this table says `PreToolUse`. That is why they are constants
|
|
588
|
+
* rather than two more entries in {@link POST_TOOL_EVENTS} — a harness whose
|
|
589
|
+
* post-event name appeared in that shared list would also accept
|
|
590
|
+
* `PostToolUse` from an event nobody sent, and the dispatch would answer a
|
|
591
|
+
* name this harness never uses.
|
|
592
|
+
*/
|
|
593
|
+
export const HERMES_PRE_TOOL_EVENT = "pre_tool_call";
|
|
594
|
+
export const HERMES_POST_TOOL_EVENT = "post_tool_call";
|
|
595
|
+
/**
|
|
596
|
+
* Hermes Agent's read tools, read off its own tool registrations (APRV-398).
|
|
597
|
+
*
|
|
598
|
+
* `read_file` names one `path` (with `offset` and `limit`). `search_files` is
|
|
599
|
+
* BOTH of the readers another harness would ship as two: its `target` enum
|
|
600
|
+
* selects `content` (a grep) or `files` (a name search), and either way the
|
|
601
|
+
* directory it works in is one `path`. So there is no `glob`, no `grep` and no
|
|
602
|
+
* `list_files` here — not because they were left out, but because this harness
|
|
603
|
+
* does not have them.
|
|
604
|
+
*
|
|
605
|
+
* NO TOOL ON THIS HARNESS TAKES A LIST OF PATHS, which is the one thing worth
|
|
606
|
+
* saying out loud: Muse's `search` names an ARRAY under `paths` and needed
|
|
607
|
+
* `firstOutOfScope` to answer a list, and `readToolGate`'s single-path arm
|
|
608
|
+
* answers everything here. A `paths` array arriving from a future release would
|
|
609
|
+
* fall to that same list handling with no change, because the gate reads both.
|
|
610
|
+
*
|
|
611
|
+
* OBSERVED (APRV-415, the live probe of APRV-398): a running session sent
|
|
612
|
+
* `read_file` with `path` and `limit`, and the tools it sent in total were
|
|
613
|
+
* `terminal`, `write_file`, `patch`, `read_file` and `execute_code`.
|
|
614
|
+
* `search_files` was not exercised by the five prompts, so it stays on this list
|
|
615
|
+
* as the registration says, in the direction APRV-347 made safe: a listed tool
|
|
616
|
+
* Hermes never sends is INERT, an unlisted tool it does send would be an
|
|
617
|
+
* unscoped read. A read arriving through `terminal` as `cat` is scoped by the
|
|
618
|
+
* classifier regardless, which is the floor under all of it.
|
|
619
|
+
*/
|
|
620
|
+
const HERMES_READ_TOOLS = ["read_file", "search_files"];
|
|
621
|
+
/**
|
|
622
|
+
* The classes Hermes's own side-effecting tools are judged under (APRV-445,
|
|
623
|
+
* from the Agent Village DATA-234 survey of Hermes v2026.9.24).
|
|
624
|
+
*
|
|
625
|
+
* Before this table every one of these reached the not-a-gated-tool allow,
|
|
626
|
+
* because the adapter knew `terminal`, `write_file`, `patch` and the two read
|
|
627
|
+
* tools and nothing else, so a policy line for `cron.manage` or `message.send`
|
|
628
|
+
* was a line no Hermes call could ever reach. They are code, not policy: the
|
|
629
|
+
* classifier decides what a call IS and the policy decides what happens to it,
|
|
630
|
+
* so an operator prices these classes with ordinary `classes:` lines.
|
|
631
|
+
*
|
|
632
|
+
* - `cronjob_manage` (legacy alias `cronjob`): every action that changes the
|
|
633
|
+
* schedule or runs a job is `cron.manage`; `list` is a read. A scheduled job
|
|
634
|
+
* runs later with no `pre_tool_call` at all, so the schedule is the one place
|
|
635
|
+
* a human can see it.
|
|
636
|
+
* - `process_manage` (legacy alias `process`): `write`, `submit`, `kill`,
|
|
637
|
+
* `close` and `handoff` act on a running process and are `process.write`;
|
|
638
|
+
* `list`, `poll`, `log` and `wait` are reads.
|
|
639
|
+
* - `browser_exec`, `browser_cdp` and every other `browser_*`: `browser.exec`.
|
|
640
|
+
* - `skill_manage`: `skill.manage`. `delegate_task`: `agent.delegate`.
|
|
641
|
+
* `send_message`: `message.send` (not agent-callable at v2026.9.24; the rule
|
|
642
|
+
* stands so the day it is, it is already gated).
|
|
643
|
+
*
|
|
644
|
+
* An action this table does not recognise on a tool it does know is judged
|
|
645
|
+
* under the tool's side-effecting class rather than allowed: a new verb on a
|
|
646
|
+
* tool that schedules or drives processes is a new way to do that, and the
|
|
647
|
+
* strict reading is the safe one.
|
|
648
|
+
*/
|
|
649
|
+
const HERMES_CRON_READ_ACTIONS = new Set(["list"]);
|
|
650
|
+
const HERMES_PROCESS_READ_ACTIONS = new Set(["list", "poll", "log", "wait"]);
|
|
651
|
+
function hermesToolRule(toolName, toolInput) {
|
|
652
|
+
const action = readString(toolInput, "action");
|
|
653
|
+
const headline = (cls) => `${toolName}${action === null ? "" : ` ${action}`} (${cls})`;
|
|
654
|
+
if (toolName === "cronjob_manage" || toolName === "cronjob") {
|
|
655
|
+
if (action !== null && HERMES_CRON_READ_ACTIONS.has(action)) {
|
|
656
|
+
return { kind: "read", reason: `${toolName} ${action} reads the schedule and changes nothing` };
|
|
657
|
+
}
|
|
658
|
+
return { kind: "gated", cls: "cron.manage", headline: headline("cron.manage") };
|
|
659
|
+
}
|
|
660
|
+
if (toolName === "process_manage" || toolName === "process") {
|
|
661
|
+
if (action !== null && HERMES_PROCESS_READ_ACTIONS.has(action)) {
|
|
662
|
+
return { kind: "read", reason: `${toolName} ${action} reads a process and changes nothing` };
|
|
663
|
+
}
|
|
664
|
+
return { kind: "gated", cls: "process.write", headline: headline("process.write") };
|
|
665
|
+
}
|
|
666
|
+
if (toolName.startsWith("browser_")) {
|
|
667
|
+
return { kind: "gated", cls: "browser.exec", headline: headline("browser.exec") };
|
|
668
|
+
}
|
|
669
|
+
if (toolName === "skill_manage") {
|
|
670
|
+
return { kind: "gated", cls: "skill.manage", headline: headline("skill.manage") };
|
|
671
|
+
}
|
|
672
|
+
if (toolName === "delegate_task") {
|
|
673
|
+
return { kind: "gated", cls: "agent.delegate", headline: headline("agent.delegate") };
|
|
674
|
+
}
|
|
675
|
+
if (toolName === "send_message") {
|
|
676
|
+
return { kind: "gated", cls: "message.send", headline: headline("message.send") };
|
|
677
|
+
}
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Hermes Agent by Nous Research (APRV-398).
|
|
682
|
+
*
|
|
683
|
+
* The harness Agent Village v2 runs every resident agent on, one per tenant in
|
|
684
|
+
* a Railway sandbox, which is why this adapter exists at all. Its hook is a
|
|
685
|
+
* shell hook like every other entry in this table, configured under `hooks:` in
|
|
686
|
+
* `$HERMES_HOME/config.yaml` rather than in the repository: Hermes documents no
|
|
687
|
+
* project-local configuration directory, so the organ lives in the user home
|
|
688
|
+
* and `core/command-class.ts` matches it there.
|
|
689
|
+
*
|
|
690
|
+
* ## The dialect: exactly one form, and the form is Hermes's own
|
|
691
|
+
*
|
|
692
|
+
* Hermes's shell-hook parser accepts two block dialects, checked in this order:
|
|
693
|
+
* its own `{action:"block", message}` and a Claude-compatible
|
|
694
|
+
* `{decision:"block", reason}`. This adapter emits the NATIVE one and never
|
|
695
|
+
* both.
|
|
696
|
+
*
|
|
697
|
+
* The live probe (APRV-415) measured every dialect one at a time and then all of
|
|
698
|
+
* them at once: `{action:"block"}` at exit 2, `{action:"block"}` at exit 0,
|
|
699
|
+
* `{decision:"block"}`, a bare exit 2, and the mixed payload ALL BLOCKED. So
|
|
700
|
+
* HERMES TOLERATES A SUPERSET, which is exactly where it differs from Muse — a
|
|
701
|
+
* payload carrying several dialects there was unparseable, an unparseable hook
|
|
702
|
+
* was a failed hook, and a failed hook failed OPEN, so being more explicit made
|
|
703
|
+
* the refusal weaker (`docs/muse-hook.md`). One dialect is still what this
|
|
704
|
+
* adapter emits, and the reason is now the smaller one rather than the load-
|
|
705
|
+
* bearing one: the native form is the shape the parser tries first and the one
|
|
706
|
+
* least likely to be dropped by a release that tidies up a compatibility layer,
|
|
707
|
+
* and a verdict whose meaning does not depend on how much of it the harness
|
|
708
|
+
* understood is a verdict a reader can check. The tolerance is a fact about this
|
|
709
|
+
* release, not a licence to hedge in the next one.
|
|
710
|
+
*
|
|
711
|
+
* THE ALLOW IS `{}`, and that is not a placeholder. Hermes has no
|
|
712
|
+
* `{"action":"allow"}`: its parser returns "no directive" for an empty stdout,
|
|
713
|
+
* for `{}`, and for any JSON object naming no directive key, and no directive
|
|
714
|
+
* means the call proceeds. The probe confirmed it: the `allow-empty-object`
|
|
715
|
+
* trial CREATED its file under `fail_closed: true`. `{}` is chosen over an empty
|
|
716
|
+
* stdout because the two are distinguishable to a human reading a log and only
|
|
717
|
+
* one of them says a hook ran and decided; it is chosen over an invented
|
|
718
|
+
* `{"action":"allow"}` because that spelling is an allow only by falling through
|
|
719
|
+
* the parser's unrecognised-directive path — which the probe also measured, and
|
|
720
|
+
* which did fall through on the old build — and a verdict that works by not being
|
|
721
|
+
* understood is a verdict one release could turn into a block. The reason text
|
|
722
|
+
* rides on stderr, where Hermes reads it only as a block message at the blocking
|
|
723
|
+
* exit code, which an allow never uses.
|
|
724
|
+
*
|
|
725
|
+
* ## The first real gate since Claude Code, measured
|
|
726
|
+
*
|
|
727
|
+
* A per-entry `fail_closed: true` turns three hook failures into a BLOCK: a
|
|
728
|
+
* spawn error, a timeout, and stdout that is non-empty and not a JSON object.
|
|
729
|
+
* Grok Build and Muse Code both fail open on all three with no setting to change
|
|
730
|
+
* it, so both adapters are enforcement only while healthy.
|
|
731
|
+
*
|
|
732
|
+
* OBSERVED, 3 OF 3, on Hermes `main` at `118984d7` (APRV-415): the armed crash
|
|
733
|
+
* was refused, the armed garbage was refused, and the armed hang was refused at
|
|
734
|
+
* exactly 300s, the per-entry cap. So this adapter is a gate on a current Hermes
|
|
735
|
+
* rather than a backstop, which no adapter since Claude Code could say.
|
|
736
|
+
*
|
|
737
|
+
* THREE LIMITS RIDE WITH IT, and `docs/hermes-hook.md` states all three rather
|
|
738
|
+
* than selling the headline. The default is `false`, so an entry without the key
|
|
739
|
+
* fails open. The key does not cover a hook that exits non-zero having printed
|
|
740
|
+
* NOTHING, which is left to {@link HERMES_DENY_EXIT}'s unconditional block. And
|
|
741
|
+
* there is a VERSION FLOOR: `v0.21.3` (build `2026.9.14`) does not know the key
|
|
742
|
+
* at all and ignores it silently, with `hermes hooks list` rendering no
|
|
743
|
+
* `fail_closed` flag on either build, so the listing cannot be used to check it.
|
|
744
|
+
* {@link HERMES_FAIL_CLOSED_FLOOR} in `core/harness-version.ts` carries the floor
|
|
745
|
+
* and `approval doctor`'s harness-version row reports a build below it.
|
|
746
|
+
*
|
|
747
|
+
* ## `execute_code` has no readable surface, and may not even be hooked
|
|
748
|
+
*
|
|
749
|
+
* The tool takes a `code` string and no path, no argv and no directory, so
|
|
750
|
+
* nothing a verdict could bind exists in the call. It is refused before the
|
|
751
|
+
* event dispatch with {@link HERMES_EXECUTE_CODE_REFUSAL}.
|
|
752
|
+
*
|
|
753
|
+
* There is a second reason, and it is the stronger one. `execute_code` runs in
|
|
754
|
+
* a persistent kernel whose scripts can call Hermes's other tools IN-PROCESS.
|
|
755
|
+
* Whether those inner calls re-fire `pre_tool_call` is STILL not established and
|
|
756
|
+
* cannot be established from here: the probe refused every `execute_code` call
|
|
757
|
+
* before it ran, twice, which is the refusal working and is also the reason no
|
|
758
|
+
* inner call was ever observed. If they do not re-fire, one `execute_code` call
|
|
759
|
+
* is an unbounded bypass of this entire adapter. Refusing the tool is the only
|
|
760
|
+
* answer available to a hook that cannot see inside it, and it is the
|
|
761
|
+
* fail-closed one.
|
|
762
|
+
*/
|
|
763
|
+
const HERMES_ADAPTER = {
|
|
764
|
+
kind: "hermes",
|
|
765
|
+
originApp: "hermes-hook",
|
|
766
|
+
defaultActor: "agent:hermes",
|
|
767
|
+
// Two numbers, because Hermes bounds the hook twice and the shorter wins.
|
|
768
|
+
//
|
|
769
|
+
// The CEILING is 300 s, OBSERVED rather than read off a page: APRV-415's probe
|
|
770
|
+
// armed a hang and Hermes refused it at exactly 300 s, the per-entry cap. No
|
|
771
|
+
// entry can outlive it however it is written, so a stated `--harness-cap` is
|
|
772
|
+
// clamped to it.
|
|
773
|
+
//
|
|
774
|
+
// The DEFAULT is 30 s: `plugins.hook_callback_timeout`, which bounds the whole
|
|
775
|
+
// hook dispatch, fails closed on `pre_tool_call` by itself, and is what a
|
|
776
|
+
// Hermes install runs with until an operator raises it. The first pass of
|
|
777
|
+
// APRV-423 assumed the 300 s ceiling whenever the flag was absent, which on a
|
|
778
|
+
// default install asserted a T+240 s deadline for a hook Hermes kills at 30 s:
|
|
779
|
+
// APRV-410 again, with a fictional deadline. So with no flag the runtime
|
|
780
|
+
// assumes the stricter number, which does not clear the margin, and refuses
|
|
781
|
+
// `hook-harness-cap-too-short` with the repair below. An operator who has
|
|
782
|
+
// raised `hook_callback_timeout` states the smaller of it and the entry's
|
|
783
|
+
// `timeout` with `--harness-cap`, and that statement replaces the default.
|
|
784
|
+
capCeilingMs: 300_000,
|
|
785
|
+
capDefaultMs: 30_000,
|
|
786
|
+
capRepair: "On Hermes: raise `plugins.hook_callback_timeout` above the per-entry `timeout` in the harness's config, then pass `--harness-cap <the smaller of the two>` on the hook command, or start `approval serve` with `--hook-harness-cap <the smaller of the two>` for a hosted tenant (for the documented `hook_callback_timeout: 600` with `timeout: 300`, that is 300s). Without the flag this runtime assumes Hermes's 30s default callback timeout, which leaves no room for a human.",
|
|
787
|
+
// `terminal`, carrying `command` and a PER-CALL `workdir`. Hermes's contract
|
|
788
|
+
// has the field Codex's withholds (APRV-310), and the live probe found that
|
|
789
|
+
// having it is not the same as being sent it: the model passed no `workdir` at
|
|
790
|
+
// all, and the directory the command would have run in is reported nowhere
|
|
791
|
+
// else. So the field makes enforcement POSSIBLE and
|
|
792
|
+
// {@link hermesUnboundDirectory} is what makes it actual, by refusing the call
|
|
793
|
+
// that leaves the field out instead of guessing a directory for it.
|
|
794
|
+
shellTool: "terminal",
|
|
795
|
+
// `write_file` names `path` and `content`; `patch` names `path`,
|
|
796
|
+
// `old_string` and `new_string`. Both shapes are already what
|
|
797
|
+
// `fileToolGate` reads, so neither needed a new key. Both were observed on the
|
|
798
|
+
// live run, `patch` with `new_string`, `old_string` and `path` exactly.
|
|
799
|
+
fileTools: ["write_file", "patch"],
|
|
800
|
+
readTools: HERMES_READ_TOOLS,
|
|
801
|
+
shellCwdKey: "workdir",
|
|
802
|
+
toolRules: hermesToolRule,
|
|
803
|
+
};
|
|
804
|
+
/** The adapter's rule-table verdict for this call, or `null` (APRV-445). */
|
|
805
|
+
function toolRuleOf(adapter, input) {
|
|
806
|
+
if (adapter.toolRules === undefined)
|
|
807
|
+
return null;
|
|
808
|
+
if (input.toolName === adapter.shellTool)
|
|
809
|
+
return null;
|
|
810
|
+
if (adapter.fileTools.includes(input.toolName) || adapter.readTools.includes(input.toolName)) {
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
return adapter.toolRules(input.toolName, input.toolInput);
|
|
814
|
+
}
|
|
494
815
|
/**
|
|
495
816
|
* Every harness this runtime speaks a hook protocol for, by kind (APRV-358).
|
|
496
817
|
*
|
|
@@ -514,7 +835,26 @@ export const HARNESS_ADAPTERS = {
|
|
|
514
835
|
codex: CODEX_ADAPTER,
|
|
515
836
|
grok: GROK_ADAPTER,
|
|
516
837
|
muse: MUSE_ADAPTER,
|
|
838
|
+
hermes: HERMES_ADAPTER,
|
|
517
839
|
};
|
|
840
|
+
/**
|
|
841
|
+
* Is `name` this adapter's post-execution event?
|
|
842
|
+
*
|
|
843
|
+
* One reading of the question, so the three places that used to spell it
|
|
844
|
+
* themselves cannot come apart (APRV-398). Codex names one event, Hermes names
|
|
845
|
+
* its own snake_case pair, and everything else uses Claude Code's two; a
|
|
846
|
+
* harness whose names appeared in another harness's list would answer an event
|
|
847
|
+
* nobody sent it.
|
|
848
|
+
*/
|
|
849
|
+
function isPostToolEvent(adapter, name) {
|
|
850
|
+
if (name === null)
|
|
851
|
+
return false;
|
|
852
|
+
if (adapter.kind === "codex")
|
|
853
|
+
return name === CODEX_POST_TOOL_EVENT;
|
|
854
|
+
if (adapter.kind === "hermes")
|
|
855
|
+
return name === HERMES_POST_TOOL_EVENT;
|
|
856
|
+
return POST_TOOL_EVENTS.includes(name);
|
|
857
|
+
}
|
|
518
858
|
/**
|
|
519
859
|
* The decision object the harness reads from stdout.
|
|
520
860
|
*
|
|
@@ -533,6 +873,33 @@ function decision(permission, reason, harness, codexCommand) {
|
|
|
533
873
|
if (harness === "grok") {
|
|
534
874
|
return `${JSON.stringify({ decision: permission, reason })}\n`;
|
|
535
875
|
}
|
|
876
|
+
if (harness === "hermes") {
|
|
877
|
+
// APRV-398. Hermes's OWN block form and exactly one form:
|
|
878
|
+
// `{action:"block", message}`. It emits neither the Claude-compatible
|
|
879
|
+
// `{decision:"block", reason}` beside it nor any other key, and the test
|
|
880
|
+
// asserts the top-level keys precisely so a later edit cannot quietly add
|
|
881
|
+
// one — on Muse a verdict carrying an unsupported key was a FAILED hook and
|
|
882
|
+
// a failed hook failed open, which made the more explicit refusal the weaker
|
|
883
|
+
// one. APRV-415 measured Hermes on the same question and it is NOT Muse: the
|
|
884
|
+
// mixed payload blocked, so this harness tolerates a superset. One dialect is
|
|
885
|
+
// kept anyway, because the tolerance is a property of one release and a
|
|
886
|
+
// verdict whose meaning depends on how much of it the harness parsed is not a
|
|
887
|
+
// verdict a reader can check.
|
|
888
|
+
//
|
|
889
|
+
// THE ALLOW IS `{}` AND CARRIES NO REASON, which is the harness's own
|
|
890
|
+
// vocabulary rather than a shrug: Hermes has no allow directive, and a
|
|
891
|
+
// parser that finds no directive lets the call proceed. `{}` over an empty
|
|
892
|
+
// stdout because a human reading a log can tell a hook that decided from a
|
|
893
|
+
// hook that printed nothing; `{}` over an invented `{"action":"allow"}`
|
|
894
|
+
// because that would be an allow only by falling through the parser's
|
|
895
|
+
// unrecognised path, and a verdict that works by not being understood is one
|
|
896
|
+
// release away from being a block. The reason reaches stderr instead (see
|
|
897
|
+
// {@link allow}), where it is read as a block message at the blocking exit
|
|
898
|
+
// code and nowhere else.
|
|
899
|
+
return permission === "deny"
|
|
900
|
+
? `${JSON.stringify({ action: "block", message: reason })}\n`
|
|
901
|
+
: "{}\n";
|
|
902
|
+
}
|
|
536
903
|
const hookSpecificOutput = {
|
|
537
904
|
hookEventName: "PreToolUse",
|
|
538
905
|
permissionDecision: permission,
|
|
@@ -548,6 +915,14 @@ function allow(streams, reason, harness, codexCommand) {
|
|
|
548
915
|
if (harness === "codex" && codexCommand === undefined) {
|
|
549
916
|
return deny(streams, "hook-io", "the Codex allow lost its exact bound tool_input.command", harness);
|
|
550
917
|
}
|
|
918
|
+
// APRV-398. Hermes's allow is `{}` and has nowhere to put a reason, so the
|
|
919
|
+
// reason goes to stderr rather than being dropped: it names the classes the
|
|
920
|
+
// verdict authorized and carries the loop floor's own words when one stood,
|
|
921
|
+
// which is the line an operator reads back off a session. Hermes reads stderr
|
|
922
|
+
// only as a block message at the blocking exit code, and an allow never uses
|
|
923
|
+
// that exit code, so this cannot become part of a verdict.
|
|
924
|
+
if (harness === "hermes")
|
|
925
|
+
streams.err(`approval hook hermes: allow — ${reason}\n`);
|
|
551
926
|
streams.out(decision("allow", reason, harness, codexCommand));
|
|
552
927
|
return EXIT_OK;
|
|
553
928
|
}
|
|
@@ -561,9 +936,230 @@ function allow(streams, reason, harness, codexCommand) {
|
|
|
561
936
|
* of its documented contract and leaves the reason where a person can read it.
|
|
562
937
|
*/
|
|
563
938
|
const GROK_DENY_EXIT = 2;
|
|
939
|
+
/**
|
|
940
|
+
* The exit code a HERMES deny carries, and why it IS 2 (APRV-398).
|
|
941
|
+
*
|
|
942
|
+
* This is the one place the Muse reasoning does not carry over, and the
|
|
943
|
+
* difference is in Hermes's own code rather than in a preference. Hermes defines
|
|
944
|
+
* a blocking exit code, applies it to `pre_tool_call` and to no other event, and
|
|
945
|
+
* treats it as UNCONDITIONAL: the call is blocked whatever stdout said, and the
|
|
946
|
+
* block message is taken from the stdout directive first, from stderr second,
|
|
947
|
+
* and from a default third. So the body and the exit code are not two accounts
|
|
948
|
+
* of one answer that the harness has to reconcile — the harness states the
|
|
949
|
+
* precedence itself, and the two agree by its own rule.
|
|
950
|
+
*
|
|
951
|
+
* That makes 2 strictly stronger than 0 here, and it closes the one gap
|
|
952
|
+
* `fail_closed` leaves open. `fail_closed: true` blocks on a spawn error, a
|
|
953
|
+
* timeout, and stdout that is non-empty and not a JSON object; it does NOT block
|
|
954
|
+
* a hook that exits non-zero having printed nothing, because its condition
|
|
955
|
+
* requires a non-empty stdout. Exiting 2 means every refusal this adapter
|
|
956
|
+
* reaches blocks on the exit code alone, including the ones where the body never
|
|
957
|
+
* made it out.
|
|
958
|
+
*
|
|
959
|
+
* Grok's 2 and this 2 are therefore the same number for opposite reasons, and it
|
|
960
|
+
* is worth not conflating them: there, exit 2 is the ONLY signal Grok reads and
|
|
961
|
+
* the body is decoration. Here the body is the message and the exit code is the
|
|
962
|
+
* floor under it.
|
|
963
|
+
*/
|
|
964
|
+
const HERMES_DENY_EXIT = 2;
|
|
564
965
|
function deny(streams, code, detail, harness) {
|
|
565
966
|
streams.out(decision("deny", `${code}: ${detail}`, harness));
|
|
566
|
-
|
|
967
|
+
if (harness === "grok")
|
|
968
|
+
return GROK_DENY_EXIT;
|
|
969
|
+
if (harness === "hermes")
|
|
970
|
+
return HERMES_DENY_EXIT;
|
|
971
|
+
return EXIT_OK;
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* The block directive a harness reads, as bytes, for a refusal raised OUTSIDE
|
|
975
|
+
* the hook (APRV-421).
|
|
976
|
+
*
|
|
977
|
+
* `approval serve` can refuse a hook request before `commandHook` is reached
|
|
978
|
+
* at all: an oversized body, a credential at the wrong door, a method it does
|
|
979
|
+
* not answer. Each of those is a refusal by the transport, and a transport
|
|
980
|
+
* refusal a harness reads as "no verdict" is a harness that runs the command.
|
|
981
|
+
* So the refusal has to be spoken in the harness's own dialect, and the only
|
|
982
|
+
* correct source of that dialect is the function that prints it here.
|
|
983
|
+
*
|
|
984
|
+
* It renders through {@link deny} for that reason: one construction site per
|
|
985
|
+
* harness, still never `ask`. The exit code returned is the one the verb would
|
|
986
|
+
* have carried; a caller may impose a stricter one, and the transport does.
|
|
987
|
+
*/
|
|
988
|
+
export function harnessBlockDirective(code, detail, harness) {
|
|
989
|
+
const out = [];
|
|
990
|
+
const exitCode = deny({ out: (text) => out.push(text), err: () => undefined }, code, detail, harness);
|
|
991
|
+
return { stdout: out.join(""), exitCode };
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Raised on `globalThis` while the runtime's own Hermes signal guards are
|
|
995
|
+
* registered (APRV-466). The `approval` bin reads the same registry symbol and
|
|
996
|
+
* steps aside while it is `true`, so the bin and the runtime never both print.
|
|
997
|
+
* An explicit flag rather than a listener count: an unrelated listener that
|
|
998
|
+
* does not exit must not switch the bin's guard off.
|
|
999
|
+
*/
|
|
1000
|
+
export const HERMES_SIGNAL_OWNER = Symbol.for("approval-md.hermes-signal-owner");
|
|
1001
|
+
/**
|
|
1002
|
+
* The block directive a Hermes hook prints when a signal arrives before it
|
|
1003
|
+
* reached a verdict (APRV-445, APRV-466), as bytes.
|
|
1004
|
+
*
|
|
1005
|
+
* Two places print it: `hermesFailClosed`'s guard below, and the `approval` bin
|
|
1006
|
+
* (`cli.js`), which has to answer a signal that lands while this module is
|
|
1007
|
+
* still loading and so cannot import it. The bin spells the same bytes by hand,
|
|
1008
|
+
* and `tests/hermes-bin-signal-guard.test.ts` pins the two equal, so the
|
|
1009
|
+
* early-signal answer is one code with one spelling (SPEC.md §11.1 invariant
|
|
1010
|
+
* 6). The wait's own handler words its message differently ("while waiting for
|
|
1011
|
+
* a decision") under the same code.
|
|
1012
|
+
*
|
|
1013
|
+
* On the CLI the runtime's guard prints it for a signal held through the
|
|
1014
|
+
* synchronous stretch before the first spend (the stdin read, the
|
|
1015
|
+
* classification), at the pause `BEFORE_SPEND` (APRV-473); the bin prints it
|
|
1016
|
+
* for a signal during the load (docs/hermes-hook.md, "Two layers against a
|
|
1017
|
+
* signal").
|
|
1018
|
+
*/
|
|
1019
|
+
export function hermesInterruptedDirective(signal) {
|
|
1020
|
+
return harnessBlockDirective("hook-interrupted", `the hook received ${signal} before it reached a verdict; nothing authorizes this call`, "hermes").stdout;
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* The block directive a harness wait answers when a SIGTERM or SIGINT ends it
|
|
1024
|
+
* (APRV-473 for Hermes, APRV-475 for every harness), as bytes and exit code.
|
|
1025
|
+
*
|
|
1026
|
+
* It is the harness's ORDINARY deny, built by {@link harnessBlockDirective}
|
|
1027
|
+
* and so by {@link deny}, under the `hook-interrupted` code: the nested
|
|
1028
|
+
* `hookSpecificOutput` deny at exit 0 for Claude Code, Codex and Muse,
|
|
1029
|
+
* `{permission:"deny"}` at exit 0 for Cursor, `{decision:"deny"}` at exit 2
|
|
1030
|
+
* for Grok, `{action:"block"}` at exit 2 for Hermes. One construction site per
|
|
1031
|
+
* harness, so the answer to an interruption is spelled exactly as every other
|
|
1032
|
+
* refusal that harness receives from this hook, and a harness that blocks on
|
|
1033
|
+
* those blocks on this. `tests/harness-wait-interrupt.test.ts` pins the bytes
|
|
1034
|
+
* per harness.
|
|
1035
|
+
*/
|
|
1036
|
+
export function interruptedWaitDirective(signal, harness) {
|
|
1037
|
+
return harnessBlockDirective("hook-interrupted", `the hook received ${signal} while waiting for a decision; nothing authorizes this call`, harness);
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* The exit code an interrupted wait leaves with when stdout would not take its
|
|
1041
|
+
* directive whole (APRV-475): a closed pipe, or a short write.
|
|
1042
|
+
*
|
|
1043
|
+
* 2, because with an empty or torn stdout the exit code is the whole verdict,
|
|
1044
|
+
* and 2 is the one code these harnesses read as a block on its own: Claude Code
|
|
1045
|
+
* documents it as a blocking error, Grok as its deny, Hermes blocks on it
|
|
1046
|
+
* unconditionally, Muse's live probe blocked on it with an empty stdout, and a
|
|
1047
|
+
* Cursor entry with `failClosed` blocks on any non-zero exit. Exiting 0 there
|
|
1048
|
+
* would be the one wrong answer on the harnesses whose deny is exit 0 with a
|
|
1049
|
+
* body: an exit 0 with no body is no verdict, and those harnesses run the call.
|
|
1050
|
+
*/
|
|
1051
|
+
const INTERRUPTED_BARE_EXIT = 2;
|
|
1052
|
+
/**
|
|
1053
|
+
* Answer an interrupted wait (APRV-475), and return the code to exit with.
|
|
1054
|
+
*
|
|
1055
|
+
* Writes {@link interruptedWaitDirective} synchronously to fd 1, because the
|
|
1056
|
+
* `process.exit` that follows does not wait for a stream to drain. Returns the
|
|
1057
|
+
* directive's own exit code, or {@link INTERRUPTED_BARE_EXIT} when the write
|
|
1058
|
+
* threw or came up short. Called by the wait's signal handler only, BEFORE its
|
|
1059
|
+
* withdrawal, so the block is on stdout however long the withdrawal waits for
|
|
1060
|
+
* the log's lock.
|
|
1061
|
+
*/
|
|
1062
|
+
function writeInterruptedWait(signal, harness) {
|
|
1063
|
+
const directive = interruptedWaitDirective(signal, harness);
|
|
1064
|
+
let whole = false;
|
|
1065
|
+
try {
|
|
1066
|
+
whole = writeSync(1, directive.stdout) === Buffer.byteLength(directive.stdout);
|
|
1067
|
+
}
|
|
1068
|
+
catch {
|
|
1069
|
+
// stdout is gone; the exit code is the whole verdict.
|
|
1070
|
+
}
|
|
1071
|
+
return whole ? directive.exitCode : INTERRUPTED_BARE_EXIT;
|
|
1072
|
+
}
|
|
1073
|
+
/** The machine-readable code a Hermes `execute_code` call is refused with. */
|
|
1074
|
+
export const HERMES_EXECUTE_CODE_REFUSAL = "hook-hermes-execute-code-unbound";
|
|
1075
|
+
/** The tool that carries a program and no path, no argv and no directory. */
|
|
1076
|
+
const HERMES_EXECUTE_CODE_TOOL = "execute_code";
|
|
1077
|
+
/**
|
|
1078
|
+
* The retry a Hermes session makes after one of these refusals, in one place.
|
|
1079
|
+
*
|
|
1080
|
+
* It is the whole point of refusing rather than falling back: a session that is
|
|
1081
|
+
* told what to send next stays usable, and the live probe watched the model
|
|
1082
|
+
* answer a block by trying the SAME effect through another tool and another
|
|
1083
|
+
* directory. A refusal that did not name the repair would get that behaviour
|
|
1084
|
+
* again, one tool along.
|
|
1085
|
+
*/
|
|
1086
|
+
const HERMES_ABSOLUTE_RETRY = "spell the directory in full from `/` and send the call again; the classifier then reads the same path the harness will write";
|
|
1087
|
+
/**
|
|
1088
|
+
* A Hermes call whose effective directory this hook cannot see, as the reason it
|
|
1089
|
+
* is refused, or `null` when the call binds its own directory (APRV-415).
|
|
1090
|
+
*
|
|
1091
|
+
* ## The fact, observed rather than reasoned
|
|
1092
|
+
*
|
|
1093
|
+
* The live probe of APRV-398 (Hermes `main` at `118984d7`, 60 envelopes) settled
|
|
1094
|
+
* three things that together make this refusal necessary:
|
|
1095
|
+
*
|
|
1096
|
+
* 1. the envelope's `cwd` is the Hermes PROCESS directory (`Path.cwd()` in its
|
|
1097
|
+
* payload builder), which is not where the work happens. A session launched in
|
|
1098
|
+
* the scratch project wrote into `$HERMES_HOME/cache/scratch`, and a Telegram
|
|
1099
|
+
* gateway session's `cwd` was the USER HOME;
|
|
1100
|
+
* 2. `terminal` keeps a PER-SESSION recorded working directory that a `cd` in an
|
|
1101
|
+
* earlier call moves, and no field of the event reports it. The model passed
|
|
1102
|
+
* no `workdir` at all on the probe's shell call;
|
|
1103
|
+
* 3. all four file tools resolve a RELATIVE path against that same unreported
|
|
1104
|
+
* directory (`tools/file_tools_paths.py`, `_resolve_path_for_task`).
|
|
1105
|
+
*
|
|
1106
|
+
* So a call that does not state an absolute directory is the APRV-310 shape
|
|
1107
|
+
* across every tool on this harness: the verdict would bind bytes in one
|
|
1108
|
+
* directory and the harness would act in another. That is not a narrower answer
|
|
1109
|
+
* this runtime may reach by guessing, and `describeToolCall`'s fallback to the
|
|
1110
|
+
* event `cwd` — correct on Muse, where `cwd` IS the session root — would bind a
|
|
1111
|
+
* directory that does not even contain the work here.
|
|
1112
|
+
*
|
|
1113
|
+
* ## Why a refusal rather than a fallback, and why it is usable
|
|
1114
|
+
*
|
|
1115
|
+
* SPEC.md §11.1: ambiguity resolves to the stricter path, and a self-reported
|
|
1116
|
+
* field never reduces scrutiny. A relative `workdir` cannot widen anything, and
|
|
1117
|
+
* on this harness it cannot be narrowed into either, because there is no
|
|
1118
|
+
* trustworthy directory to fall back TO. The refusal carries
|
|
1119
|
+
* `hook-unsupported-execution-context`, the same code the Codex `Bash` refusal
|
|
1120
|
+
* carries, because it is the same defect — the harness did not say WHERE — and
|
|
1121
|
+
* the repair differs only in being available: a Hermes session repairs it
|
|
1122
|
+
* itself, in the next call, by naming an absolute path. That is why the reason
|
|
1123
|
+
* names the retry rather than describing the contract.
|
|
1124
|
+
*
|
|
1125
|
+
* The probe also watched what a blocked model does next: it retries the same
|
|
1126
|
+
* effect through another tool or another path. Refusing the shell tool alone
|
|
1127
|
+
* would have moved the work into `write_file`, which is exactly why every gated
|
|
1128
|
+
* tool is covered here and why an ABSENT path is refused beside a relative one.
|
|
1129
|
+
* A missing `path` on this harness does not mean "the workspace" the way Claude
|
|
1130
|
+
* Code's `Glob` does; it means that same unreported recorded directory.
|
|
1131
|
+
*/
|
|
1132
|
+
function hermesUnboundDirectory(input, adapter) {
|
|
1133
|
+
const tool = input.toolName;
|
|
1134
|
+
if (tool === adapter.shellTool) {
|
|
1135
|
+
const key = adapter.shellCwdKey ?? "workdir";
|
|
1136
|
+
const declared = readString(input.toolInput, key);
|
|
1137
|
+
if (declared !== null && isAbsolute(declared))
|
|
1138
|
+
return null;
|
|
1139
|
+
return `Hermes \`${tool}\` is refused because the call ${declared === null
|
|
1140
|
+
? `states no \`${key}\``
|
|
1141
|
+
: `states a relative \`${key}\` (${JSON.stringify(declared)})`}, and this harness reports the effective directory nowhere else: its \`cwd\` is the Hermes process directory, while the command runs in a per-session directory that an earlier \`cd\` moves and no field of this event carries. A verdict over the command alone would bind bytes in a directory the hook cannot see. Set \`${key}\` to an absolute path — ${HERMES_ABSOLUTE_RETRY}. No policy or open window authorizes bytes whose directory the hook cannot bind`;
|
|
1142
|
+
}
|
|
1143
|
+
if (!adapter.fileTools.includes(tool) && !adapter.readTools.includes(tool))
|
|
1144
|
+
return null;
|
|
1145
|
+
// The same keys `fileToolGate` and `readToolGate` read, in their order, plus
|
|
1146
|
+
// the `paths` array neither Hermes tool sends today: a later release that
|
|
1147
|
+
// added one would otherwise arrive as an unbound call that reads as bound.
|
|
1148
|
+
const declared = readString(input.toolInput, "path") ??
|
|
1149
|
+
readString(input.toolInput, "file_path") ??
|
|
1150
|
+
readString(input.toolInput, "notebook_path");
|
|
1151
|
+
const list = input.toolInput["paths"];
|
|
1152
|
+
const entries = Array.isArray(list)
|
|
1153
|
+
? list.filter((entry) => typeof entry === "string" && entry.trim() !== "")
|
|
1154
|
+
: [];
|
|
1155
|
+
const candidates = declared === null ? entries : [declared, ...entries];
|
|
1156
|
+
if (candidates.length === 0) {
|
|
1157
|
+
return `Hermes \`${tool}\` is refused because the call names no path at all. On this harness an unnamed target is not the workspace: it is the per-session recorded directory that an earlier \`cd\` moves and that no field of this event carries, and a live session's was \`$HERMES_HOME/cache/scratch\` while a gateway session's was the user's home. Name the directory or file with an absolute \`path\` — ${HERMES_ABSOLUTE_RETRY}`;
|
|
1158
|
+
}
|
|
1159
|
+
const relative = candidates.find((candidate) => !isAbsolute(candidate));
|
|
1160
|
+
if (relative === undefined)
|
|
1161
|
+
return null;
|
|
1162
|
+
return `Hermes \`${tool}\` is refused because the path it names is relative (${JSON.stringify(relative)}), and Hermes resolves a relative path against the per-session recorded directory that an earlier \`cd\` moves, which this event does not carry: its \`cwd\` is the Hermes process directory. The verdict and the write would name different files. Send the same call with an absolute path — ${HERMES_ABSOLUTE_RETRY}. No policy or open window authorizes a path the hook cannot place`;
|
|
567
1163
|
}
|
|
568
1164
|
/** The machine-readable code a Contributor-tier session is refused with. */
|
|
569
1165
|
export const MUSE_CONTRIBUTOR_REFUSAL = "hook-muse-contributor-model";
|
|
@@ -1276,13 +1872,228 @@ export function refineReadScope(result, roots, cwd) {
|
|
|
1276
1872
|
}
|
|
1277
1873
|
return { result: { ok: true, segments, classes }, notes };
|
|
1278
1874
|
}
|
|
1875
|
+
// ===========================================================================
|
|
1876
|
+
// Write scope, the disk half (APRV-402)
|
|
1877
|
+
// ===========================================================================
|
|
1878
|
+
/**
|
|
1879
|
+
* ## Why writes need a second pass too
|
|
1880
|
+
*
|
|
1881
|
+
* Deletes and reads each had one and writes did not, which left the oldest of
|
|
1882
|
+
* the three rules the loosest. `files.write.workspace` was decided on the text
|
|
1883
|
+
* alone: a relative destination is the workspace, said the classifier, and a
|
|
1884
|
+
* relative destination is the workspace only while nothing on the way to it is
|
|
1885
|
+
* a symlink out. `cp x build/y`, `tee build/y`, `mkdir build/y`, and since
|
|
1886
|
+
* APRV-397 `tar -x -C build` and `npm pack --pack-destination build`, were all
|
|
1887
|
+
* autonomous with `build` pointing anywhere at all.
|
|
1888
|
+
*
|
|
1889
|
+
* Not a regression and not new with the packaging rows (APRV-402): the hole is
|
|
1890
|
+
* as old as the workspace-write row, and before APRV-397 every packaging
|
|
1891
|
+
* command was denied as unclassified, so nothing that was refused became
|
|
1892
|
+
* allowed by a symlink. What changed is that the rows now say `workspace` often
|
|
1893
|
+
* enough for the gap to be worth the disk.
|
|
1894
|
+
*
|
|
1895
|
+
* ## The shape is the other two passes' shape
|
|
1896
|
+
*
|
|
1897
|
+
* Resolve the nearest EXISTING ancestor of each destination, re-append the
|
|
1898
|
+
* unresolved tail, and TIGHTEN when the result is outside every root. It can
|
|
1899
|
+
* only ever move a segment toward the stricter class, so a caller that skipped
|
|
1900
|
+
* it is no more permissive than one that runs it, which is what lets
|
|
1901
|
+
* `hook classify` and `hook <harness>` share it without either becoming the
|
|
1902
|
+
* authority.
|
|
1903
|
+
*
|
|
1904
|
+
* ## What it costs, measured before it was written (APRV-402 AC1)
|
|
1905
|
+
*
|
|
1906
|
+
* The walk is 0.10 ms to 0.13 ms per destination on a warm cache (0.07 ms for
|
|
1907
|
+
* an absolute one), over 20k iterations of each shape. A write segment names
|
|
1908
|
+
* one or two destinations, so a write command pays about a fifth of a
|
|
1909
|
+
* millisecond. APRV-209 measured a cold gated invocation at about 371 ms, of
|
|
1910
|
+
* which 116 ms is the module graph and 51 ms is `hook.js` alone; this is under
|
|
1911
|
+
* half a percent of that last term and it is paid only by segments the
|
|
1912
|
+
* classifier already called a workspace write. The delete and read passes pay
|
|
1913
|
+
* the same price per target and have since APRV-267 and APRV-347.
|
|
1914
|
+
*/
|
|
1915
|
+
/** The class an out-of-scope write tightens to, as `core/command-class.ts` spells it. */
|
|
1916
|
+
const OUT_OF_SCOPE_WRITE_CLASS = "files.delete.out_of_scope";
|
|
1917
|
+
/** The rule a write tightened by this pass reports. */
|
|
1918
|
+
const WRITE_SCOPE_REJECTED_RULE = "write-out-of-scope-resolved";
|
|
1919
|
+
/** The class this pass re-reads, and the only one it will touch. */
|
|
1920
|
+
const WORKSPACE_WRITE = "files.write.workspace";
|
|
1921
|
+
/**
|
|
1922
|
+
* The classifier rules whose destinations this pass re-reads.
|
|
1923
|
+
*
|
|
1924
|
+
* Exactly the rows APRV-402 names: the workspace-write row (`mkdir`, `cp`,
|
|
1925
|
+
* `mv`, `touch`, `tee`, `ln`, `chmod`, `truncate`, `rmdir`) and the six
|
|
1926
|
+
* packaging answers APRV-397 added. `rm-workspace` and `redirect-write` reach
|
|
1927
|
+
* the same class by the same text-only reasoning and are deliberately NOT here:
|
|
1928
|
+
* they are the same hole in two more rules and they are their own decision, not
|
|
1929
|
+
* one to take inside this task's diff.
|
|
1930
|
+
*/
|
|
1931
|
+
const WRITE_SCOPE_RULES = [
|
|
1932
|
+
"workspace-write",
|
|
1933
|
+
"tar-extract",
|
|
1934
|
+
"tar-create",
|
|
1935
|
+
"gunzip-write",
|
|
1936
|
+
"base64-write",
|
|
1937
|
+
"openssl-digest-out",
|
|
1938
|
+
"npm-pack",
|
|
1939
|
+
];
|
|
1940
|
+
/**
|
|
1941
|
+
* The roots a write may land in, resolved.
|
|
1942
|
+
*
|
|
1943
|
+
* The scratch roots are the ones `resolveScratchRoots` already computes and
|
|
1944
|
+
* already guards, so this rule and the delete rule cannot disagree about where
|
|
1945
|
+
* the agent's own scratch is. The working directory is the other one, and it is
|
|
1946
|
+
* the root the classifier's own text rule already implies: "a relative
|
|
1947
|
+
* destination is the workspace" means the workspace is wherever the command
|
|
1948
|
+
* runs.
|
|
1949
|
+
*
|
|
1950
|
+
* `cwd` is harness-supplied, and reading a root out of it does not offend
|
|
1951
|
+
* SPEC.md §11.1 invariant 4, because this pass only ever narrows. The baseline
|
|
1952
|
+
* it narrows from is no check at all, so a poisoned `cwd` buys back exactly
|
|
1953
|
+
* today's answer and never a looser one. Compare the read pass, which checks
|
|
1954
|
+
* containment against the GATE root: that pass decides whether a read is in
|
|
1955
|
+
* scope at all, so the scope has to be one the subject cannot choose. This one
|
|
1956
|
+
* only takes an allowance away.
|
|
1957
|
+
*/
|
|
1958
|
+
export function resolveWriteRoots(cwd) {
|
|
1959
|
+
const roots = resolveScratchRoots(cwd);
|
|
1960
|
+
const here = resolvedPath(cwd);
|
|
1961
|
+
if (here !== null && !roots.includes(here))
|
|
1962
|
+
roots.unshift(here);
|
|
1963
|
+
return roots;
|
|
1964
|
+
}
|
|
1279
1965
|
/**
|
|
1280
|
-
*
|
|
1966
|
+
* Where this destination really is, or `null` when nothing can say.
|
|
1967
|
+
*
|
|
1968
|
+
* The same walk `targetStaysInScratch` and `resolvedReadTarget` do, for the
|
|
1969
|
+
* same reason: the destination usually does NOT exist yet, so the nearest
|
|
1970
|
+
* existing ancestor is resolved and the unresolved tail re-appended. A symlink
|
|
1971
|
+
* anywhere in that chain therefore cannot smuggle a write out of the roots,
|
|
1972
|
+
* which is the escape the pure half cannot see.
|
|
1973
|
+
*/
|
|
1974
|
+
function resolvedWriteTarget(target, cwd) {
|
|
1975
|
+
let existing = isAbsolute(target) ? target : resolvePathSegments(cwd, target);
|
|
1976
|
+
const tail = [];
|
|
1977
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
1978
|
+
if (existsSync(existing))
|
|
1979
|
+
break;
|
|
1980
|
+
const up = dirname(existing);
|
|
1981
|
+
if (up === existing)
|
|
1982
|
+
return null;
|
|
1983
|
+
tail.unshift(basename(existing));
|
|
1984
|
+
existing = up;
|
|
1985
|
+
}
|
|
1986
|
+
const resolved = resolvedPath(existing);
|
|
1987
|
+
if (resolved === null)
|
|
1988
|
+
return null;
|
|
1989
|
+
return tail.length === 0 ? resolved : join(resolved, ...tail);
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
* The words of a write segment that could name a path on disk.
|
|
1993
|
+
*
|
|
1994
|
+
* Every argument that is not spelled as a flag, plus the value half of
|
|
1995
|
+
* `--opt=value`. This is the delete pass's own filter, and it is deliberately
|
|
1996
|
+
* coarser than the classifier's flag table: it reaches `-C dir`, `-o file` and
|
|
1997
|
+
* `--pack-destination dir` without this file holding a second copy of that
|
|
1998
|
+
* table to drift against. It over-includes (a `tar` archive being READ is a
|
|
1999
|
+
* candidate too, and so is `chmod`'s mode), and over-including can only tighten
|
|
2000
|
+
* a segment that would otherwise have been waved through.
|
|
2001
|
+
*
|
|
2002
|
+
* A bare `-` is not a path: it is stdin or stdout, and every binary in these
|
|
2003
|
+
* rows that accepts it means the stream.
|
|
2004
|
+
*/
|
|
2005
|
+
function writeCandidates(args) {
|
|
2006
|
+
const candidates = [];
|
|
2007
|
+
for (const arg of args) {
|
|
2008
|
+
if (arg === "-")
|
|
2009
|
+
continue;
|
|
2010
|
+
if (!arg.startsWith("-")) {
|
|
2011
|
+
candidates.push(arg);
|
|
2012
|
+
continue;
|
|
2013
|
+
}
|
|
2014
|
+
const equals = arg.indexOf("=");
|
|
2015
|
+
if (equals === -1)
|
|
2016
|
+
continue;
|
|
2017
|
+
const value = arg.slice(equals + 1);
|
|
2018
|
+
if (value.length > 0)
|
|
2019
|
+
candidates.push(value);
|
|
2020
|
+
}
|
|
2021
|
+
return candidates;
|
|
2022
|
+
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Tighten a `files.write.workspace` segment to the out-of-scope write class
|
|
2025
|
+
* wherever the disk disagrees with the text.
|
|
2026
|
+
*
|
|
2027
|
+
* IMPURE by design and by contract, exactly as the other two passes are. The
|
|
2028
|
+
* class it tightens to is the one `core/command-class.ts` already answers for
|
|
2029
|
+
* an out-of-scope packaging destination rather than a new name: a class no
|
|
2030
|
+
* policy mentions resolves by `defaults.autonomy` (SPEC.md §7), so minting
|
|
2031
|
+
* `files.write.out_of_scope` here would arrive AUTONOMOUS in every deployment
|
|
2032
|
+
* with permissive defaults, which is the one way a tightening pass could
|
|
2033
|
+
* loosen something.
|
|
2034
|
+
*
|
|
2035
|
+
* `roots` empty means the caller asked for no write scoping, and every segment
|
|
2036
|
+
* comes back untouched: the same "absent yields today's answer" the read pass
|
|
2037
|
+
* promises.
|
|
2038
|
+
*/
|
|
2039
|
+
export function refineWriteScope(result, roots, cwd) {
|
|
2040
|
+
if (!result.ok)
|
|
2041
|
+
return { result, notes: [] };
|
|
2042
|
+
if (roots.length === 0)
|
|
2043
|
+
return { result, notes: [] };
|
|
2044
|
+
if (!result.segments.some((segment) => segment.class === WORKSPACE_WRITE && WRITE_SCOPE_RULES.includes(segment.rule))) {
|
|
2045
|
+
return { result, notes: [] };
|
|
2046
|
+
}
|
|
2047
|
+
const notes = [];
|
|
2048
|
+
const segments = result.segments.map((segment) => {
|
|
2049
|
+
if (segment.class !== WORKSPACE_WRITE)
|
|
2050
|
+
return segment;
|
|
2051
|
+
if (!WRITE_SCOPE_RULES.includes(segment.rule))
|
|
2052
|
+
return segment;
|
|
2053
|
+
const words = commandSegmentWords(segment.text);
|
|
2054
|
+
const parsed = words === null ? undefined : words[0];
|
|
2055
|
+
// The classifier read this segment a moment ago, so a parse that disagrees
|
|
2056
|
+
// here is two reads of the same bytes disagreeing. Fail closed.
|
|
2057
|
+
if (parsed === undefined) {
|
|
2058
|
+
notes.push(`${WRITE_SCOPE_REJECTED_RULE}: \`${segment.text}\` could not be re-read, so it is ${OUT_OF_SCOPE_WRITE_CLASS}`);
|
|
2059
|
+
return { ...segment, class: OUT_OF_SCOPE_WRITE_CLASS, rule: WRITE_SCOPE_REJECTED_RULE };
|
|
2060
|
+
}
|
|
2061
|
+
const reject = (path, detail) => {
|
|
2062
|
+
notes.push(`${WRITE_SCOPE_REJECTED_RULE}: ${detail}`);
|
|
2063
|
+
return {
|
|
2064
|
+
...segment,
|
|
2065
|
+
class: OUT_OF_SCOPE_WRITE_CLASS,
|
|
2066
|
+
rule: WRITE_SCOPE_REJECTED_RULE,
|
|
2067
|
+
path,
|
|
2068
|
+
};
|
|
2069
|
+
};
|
|
2070
|
+
for (const candidate of writeCandidates(parsed.args)) {
|
|
2071
|
+
const resolved = resolvedWriteTarget(candidate, cwd);
|
|
2072
|
+
if (resolved === null) {
|
|
2073
|
+
return reject(candidate, `${candidate} does not resolve to any path this hook can see, so \`${segment.text}\` is ${OUT_OF_SCOPE_WRITE_CLASS}`);
|
|
2074
|
+
}
|
|
2075
|
+
if (!roots.some((root) => resolved === root || isBelow(resolved, root))) {
|
|
2076
|
+
return reject(resolved, `${candidate} resolves to ${resolved}, which is outside the workspace and every scratch root (${roots.join(", ")}), so \`${segment.text}\` is ${OUT_OF_SCOPE_WRITE_CLASS}`);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
return segment;
|
|
2080
|
+
});
|
|
2081
|
+
if (notes.length === 0)
|
|
2082
|
+
return { result, notes };
|
|
2083
|
+
const classes = [];
|
|
2084
|
+
for (const segment of segments) {
|
|
2085
|
+
if (!classes.includes(segment.class))
|
|
2086
|
+
classes.push(segment.class);
|
|
2087
|
+
}
|
|
2088
|
+
return { result: { ok: true, segments, classes }, notes };
|
|
2089
|
+
}
|
|
2090
|
+
/**
|
|
2091
|
+
* The classifier, its context, and all four impure refinements, in the one
|
|
1281
2092
|
* order every caller must use.
|
|
1282
2093
|
*
|
|
1283
2094
|
* `hook classify` printing a different class from the one `hook claude-code`
|
|
1284
2095
|
* decides would make the explainer a different program (APRV-108's note), and
|
|
1285
|
-
* that stays true now there are
|
|
2096
|
+
* that stays true now there are four refinements in the chain.
|
|
1286
2097
|
*
|
|
1287
2098
|
* `readRoots` is the one argument whose ABSENCE is the loose answer rather than
|
|
1288
2099
|
* the strict one (APRV-347), so it is passed explicitly at every call site: an
|
|
@@ -1299,9 +2110,15 @@ export function classifyForHook(command, protectedPaths, cwd, readRoots = []) {
|
|
|
1299
2110
|
const rewritten = refineRewrite(classified, cwd);
|
|
1300
2111
|
const scratched = refineScratchDelete(rewritten.result, roots);
|
|
1301
2112
|
const scoped = refineReadScope(scratched.result, readRoots, cwd);
|
|
2113
|
+
// APRV-402. Its roots are not `readRoots`: a read scope is a read notion and
|
|
2114
|
+
// must not become a write authorization, so this pass asks its own question
|
|
2115
|
+
// of the working directory and the scratch roots. Last in the chain because
|
|
2116
|
+
// it is the narrowest, and because nothing after it would re-read a segment
|
|
2117
|
+
// it has already tightened.
|
|
2118
|
+
const written = refineWriteScope(scoped.result, resolveWriteRoots(cwd), cwd);
|
|
1302
2119
|
return {
|
|
1303
|
-
result:
|
|
1304
|
-
notes: [...rewritten.notes, ...scratched.notes, ...scoped.notes],
|
|
2120
|
+
result: written.result,
|
|
2121
|
+
notes: [...rewritten.notes, ...scratched.notes, ...scoped.notes, ...written.notes],
|
|
1305
2122
|
};
|
|
1306
2123
|
}
|
|
1307
2124
|
// ===========================================================================
|
|
@@ -1376,10 +2193,221 @@ function commandClassify(argv, streams, cwd) {
|
|
|
1376
2193
|
return EXIT_OK;
|
|
1377
2194
|
}
|
|
1378
2195
|
function sleepSync(ms) {
|
|
2196
|
+
// A synchronous wait never runs with termination signals held by the log
|
|
2197
|
+
// lock's guard (APRV-479 round 2, RS2: `approval codex bridge` waits for a
|
|
2198
|
+
// human here through `driveSync`).
|
|
2199
|
+
settleTerminationGuards();
|
|
1379
2200
|
if (ms <= 0)
|
|
1380
2201
|
return;
|
|
1381
2202
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1382
2203
|
}
|
|
2204
|
+
/** The longest delay `setTimeout` honours (2^31 - 1 ms). */
|
|
2205
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
2206
|
+
/** Run `steps` to its verdict, sleeping each pause synchronously. */
|
|
2207
|
+
function driveSync(steps) {
|
|
2208
|
+
for (;;) {
|
|
2209
|
+
const next = steps.next(false);
|
|
2210
|
+
if (next.done === true)
|
|
2211
|
+
return next.value;
|
|
2212
|
+
if (next.value > 0)
|
|
2213
|
+
sleepSync(next.value);
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Run `steps` to its verdict, sleeping each pause on the event loop.
|
|
2218
|
+
*
|
|
2219
|
+
* A zero pause is TWO `setImmediate` hops, and the second is what guarantees a
|
|
2220
|
+
* poll phase in between. libuv hands a signal to its JS listeners only when the
|
|
2221
|
+
* poll phase reads the signal pipe. A single immediate scheduled from a
|
|
2222
|
+
* poll-phase callback runs in the same iteration's check phase, before any
|
|
2223
|
+
* further poll, so a signal that arrived during the synchronous stretch would
|
|
2224
|
+
* still be sitting in the pipe when the spend ran. That is the case whenever
|
|
2225
|
+
* the run is still inside the continuation of the bin's `import()` of `dist/`
|
|
2226
|
+
* on a Node whose module load turns the loop (24, and likely 22; APRV-473
|
|
2227
|
+
* refuter, reproduced on 24). A second immediate scheduled from the check phase
|
|
2228
|
+
* runs in the NEXT iteration's check phase, after that iteration's poll, from
|
|
2229
|
+
* whichever phase the first was scheduled.
|
|
2230
|
+
*/
|
|
2231
|
+
async function driveYielding(steps) {
|
|
2232
|
+
for (;;) {
|
|
2233
|
+
const next = steps.next(true);
|
|
2234
|
+
if (next.done === true)
|
|
2235
|
+
return next.value;
|
|
2236
|
+
// Clamped to the largest delay a timer holds: past it Node fires the timer
|
|
2237
|
+
// at once, and a pause would become a busy poll. The wait re-reads its
|
|
2238
|
+
// deadline after every pause, so a shorter pause changes nothing it judges.
|
|
2239
|
+
const ms = Math.min(next.value, MAX_TIMER_MS);
|
|
2240
|
+
await new Promise((settle) => {
|
|
2241
|
+
if (ms <= 0)
|
|
2242
|
+
setImmediate(() => setImmediate(settle));
|
|
2243
|
+
else
|
|
2244
|
+
setTimeout(settle, ms);
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* The pause before anything appends `execution.started` or `gate.bypassed`
|
|
2250
|
+
* (APRV-473): a poll phase of the event loop under {@link driveYielding},
|
|
2251
|
+
* nothing under {@link driveSync}.
|
|
2252
|
+
*
|
|
2253
|
+
* Everything between two pauses runs synchronously, so a signal that lands
|
|
2254
|
+
* during the stdin read, the classification, or the poll read that found the
|
|
2255
|
+
* grant is held until the next pause. Without this one, the next point it
|
|
2256
|
+
* could be dispatched is after the spend, and a hook interrupted before its
|
|
2257
|
+
* verdict would record the execution of a call its harness had already given
|
|
2258
|
+
* up on. With it, the listener in force runs first: the wait's handler
|
|
2259
|
+
* withdraws the question and blocks, the early guard (`hermesFailClosed`)
|
|
2260
|
+
* blocks, and either one exits the process before the spend is reached.
|
|
2261
|
+
*
|
|
2262
|
+
* Shared by every harness (APRV-475): it is a yield inside the one gate chain,
|
|
2263
|
+
* so there is no per-harness copy to drift. A signal is HELD only while some
|
|
2264
|
+
* JS listener for it is registered. Outside the wait, the harnesses other than
|
|
2265
|
+
* Hermes register none, so there a signal takes its default disposition the
|
|
2266
|
+
* moment it arrives (the process dies with nothing printed and nothing spent),
|
|
2267
|
+
* and this pause matters for them at the post-wait spend, where the wait's
|
|
2268
|
+
* handler is registered.
|
|
2269
|
+
*
|
|
2270
|
+
* Taken inside {@link spendUnderLock} since APRV-478, immediately before the
|
|
2271
|
+
* spend's one attempt and after any wait for the log's lock, so nothing that can
|
|
2272
|
+
* wait sits between the last pause and the append.
|
|
2273
|
+
*/
|
|
2274
|
+
const BEFORE_SPEND = 0;
|
|
2275
|
+
/**
|
|
2276
|
+
* Did this attempt lose a race for the log's lock, writing nothing?
|
|
2277
|
+
*
|
|
2278
|
+
* Two shapes: the spend's own append refused `append-failed` with the writer's
|
|
2279
|
+
* `lock-timeout`, and a budget refusal whose `budget.exceeded` evidence lost the
|
|
2280
|
+
* race (`startHarnessExecution` carries the writer's error on that refusal for
|
|
2281
|
+
* this, APRV-478). In both nothing was appended, so re-running the whole
|
|
2282
|
+
* attempt is safe and gives the record the chance a synchronous lock wait gave
|
|
2283
|
+
* it.
|
|
2284
|
+
*/
|
|
2285
|
+
function lockTimedOut(result) {
|
|
2286
|
+
return (!result.ok &&
|
|
2287
|
+
(result.code === "append-failed" || result.code === "budget-exceeded") &&
|
|
2288
|
+
result.append?.code === "lock-timeout");
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Run one spend (an append that authorizes a harness call) without ever waiting
|
|
2292
|
+
* for the log's lock synchronously (APRV-478).
|
|
2293
|
+
*
|
|
2294
|
+
* ## The hole
|
|
2295
|
+
*
|
|
2296
|
+
* Every writer waits for `<log>.lock` inside `core/log.ts`, retrying with an
|
|
2297
|
+
* `Atomics.wait` sleep for up to {@link DEFAULT_LOCK_TIMEOUT_MS}. On the gate
|
|
2298
|
+
* path that wait came AFTER the last pause ({@link BEFORE_SPEND}), so a signal
|
|
2299
|
+
* that arrived while another writer (the daemon, routinely) held the lock was
|
|
2300
|
+
* held by the wait's listener through the whole stretch, the spend appended
|
|
2301
|
+
* `execution.started` and the allow went out, and the wait's `finally` then
|
|
2302
|
+
* removed the listener and the held signal was discarded with it: a start
|
|
2303
|
+
* recorded after the harness had abandoned the call, on every harness.
|
|
2304
|
+
*
|
|
2305
|
+
* ## What this does instead
|
|
2306
|
+
*
|
|
2307
|
+
* The wait for the lock becomes yields of the same generator chain, under ONE
|
|
2308
|
+
* deadline for the whole spend (the writer's own `lockTimeoutMs`, default
|
|
2309
|
+
* {@link DEFAULT_LOCK_TIMEOUT_MS}, taken once when the spend begins):
|
|
2310
|
+
*
|
|
2311
|
+
* 1. While the lockfile is present ({@link appendLockHeld}, advisory) and the
|
|
2312
|
+
* deadline has not passed, yield the retry interval (`lockRetryMs`, default
|
|
2313
|
+
* {@link DEFAULT_LOCK_RETRY_MS}).
|
|
2314
|
+
* 2. {@link BEFORE_SPEND}: the zero pause, taken when the spend begins and again
|
|
2315
|
+
* after any wait, so it always comes immediately before the attempt (with at
|
|
2316
|
+
* most the probe in between). This
|
|
2317
|
+
* is the pause the guarantee rests on: two `setImmediate` hops cross a poll
|
|
2318
|
+
* phase from any phase, so a signal that arrived during the wait or the
|
|
2319
|
+
* probe is dispatched here at the latest (a retry timer usually dispatches
|
|
2320
|
+
* it sooner, but a timer scheduled outside a timer callback can fire before
|
|
2321
|
+
* the next poll). The listener in force then blocks, withdraws what it can
|
|
2322
|
+
* and exits, and nothing below runs.
|
|
2323
|
+
* 3. One attempt with `lockTimeoutMs: 0`: the whole spend (a fresh verified read,
|
|
2324
|
+
* every check, compare-and-append against the head that read saw), whose
|
|
2325
|
+
* append tries the lock exactly ONCE and never sleeps. The lock protocol is
|
|
2326
|
+
* `core/log.ts`'s, untouched: atomic create-or-fail, never stolen, the head
|
|
2327
|
+
* compared under it, so this changes when a writer tries and never what a
|
|
2328
|
+
* try may do.
|
|
2329
|
+
*
|
|
2330
|
+
* An attempt that lost the race (another writer took the lock between the probe
|
|
2331
|
+
* and the try, a window as long as the attempt's own read) yields the retry
|
|
2332
|
+
* interval and goes back to 1, until the deadline: the same number of tries
|
|
2333
|
+
* inside the same bound as `acquireLock`'s synchronous retry, each one a whole
|
|
2334
|
+
* fresh attempt. An attempt that loses at or after the deadline is final,
|
|
2335
|
+
* exactly as a writer's own `lock-timeout` is: the caller denies, and nothing
|
|
2336
|
+
* was appended (fail closed, §11.1 invariant 8). See {@link lockTimedOut} for
|
|
2337
|
+
* the two refusals that count as a lost race.
|
|
2338
|
+
*
|
|
2339
|
+
* What is left between the last pause and the append is the spend's own read
|
|
2340
|
+
* of the log (milliseconds on a small log, longer on a large one) and its
|
|
2341
|
+
* single try at the lock, never a wait on another process. A signal there is
|
|
2342
|
+
* answered by the verdict the spend reaches.
|
|
2343
|
+
*
|
|
2344
|
+
* **The trade, on the yielding path only.** The writer's synchronous wait read
|
|
2345
|
+
* the log first and then spun on the lock itself, so it caught a gap of a
|
|
2346
|
+
* millisecond. Here the spend's read sits between the probe that saw the lock
|
|
2347
|
+
* free and the try, so a lock that is only ever free for gaps shorter than that
|
|
2348
|
+
* read (a writer holding it at a duty cycle near 100%) can run the spend out of
|
|
2349
|
+
* its bound where the synchronous wait got through. That ends in the deny
|
|
2350
|
+
* above, the grant left standing for a retry: fail closed. Holding the lock
|
|
2351
|
+
* across a yield to close it is not an option, because a handler that exits at
|
|
2352
|
+
* that yield would leave a lockfile that is never stolen.
|
|
2353
|
+
*
|
|
2354
|
+
* **Under {@link driveSync} nothing changes.** `commandHook` in process and in
|
|
2355
|
+
* the serve worker, and `decideHarnessCall` for the Codex bridge, have no event
|
|
2356
|
+
* loop that a signal could be dispatched on, so a yielded wait buys them
|
|
2357
|
+
* nothing. The driver says so at the first pause (see {@link GateSteps}), and
|
|
2358
|
+
* the spend is then exactly what it was: that zero pause (no pause under this
|
|
2359
|
+
* driver) and one call with the caller's own options, whose writer waits for
|
|
2360
|
+
* the lock synchronously, per head-moved attempt, as it always did.
|
|
2361
|
+
*
|
|
2362
|
+
* `attempt` receives the append options to use (the caller's own, with
|
|
2363
|
+
* `lockTimeoutMs: 0` on the yielding path), and the attempt number (1-based),
|
|
2364
|
+
* for a caller whose first attempt may reuse a read it already holds and whose
|
|
2365
|
+
* later ones may not.
|
|
2366
|
+
*/
|
|
2367
|
+
function* spendUnderLock(logPath, append, attempt) {
|
|
2368
|
+
// The shared pause, and the driver's answer to whether it turns the loop.
|
|
2369
|
+
const yielding = (yield BEFORE_SPEND) === true;
|
|
2370
|
+
if (!yielding)
|
|
2371
|
+
return attempt(append ?? {}, 1);
|
|
2372
|
+
const askedBudget = append?.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
|
2373
|
+
const budgetMs = Number.isFinite(askedBudget) ? Math.max(0, askedBudget) : DEFAULT_LOCK_TIMEOUT_MS;
|
|
2374
|
+
// Never a zero (or non-finite) retry: under `driveSync` a zero pause is no
|
|
2375
|
+
// pause, and the probe loop would spin.
|
|
2376
|
+
const askedRetry = append?.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS;
|
|
2377
|
+
const retryMs = Number.isFinite(askedRetry) ? Math.max(1, askedRetry) : DEFAULT_LOCK_RETRY_MS;
|
|
2378
|
+
const once = { ...append, lockTimeoutMs: 0 };
|
|
2379
|
+
const deadline = Date.now() + budgetMs;
|
|
2380
|
+
for (let tries = 1;; tries += 1) {
|
|
2381
|
+
let waited = tries > 1;
|
|
2382
|
+
for (;;) {
|
|
2383
|
+
if (!appendLockHeld(logPath))
|
|
2384
|
+
break;
|
|
2385
|
+
const left = deadline - Date.now();
|
|
2386
|
+
if (left <= 0)
|
|
2387
|
+
break;
|
|
2388
|
+
waited = true;
|
|
2389
|
+
yield Math.min(retryMs, left);
|
|
2390
|
+
}
|
|
2391
|
+
// Uncontended first try: the pause above was taken a probe ago, and the
|
|
2392
|
+
// probe is the only thing that ran since. Otherwise the pause again, now
|
|
2393
|
+
// immediately before the attempt.
|
|
2394
|
+
if (waited)
|
|
2395
|
+
yield BEFORE_SPEND;
|
|
2396
|
+
const result = attempt(once, tries);
|
|
2397
|
+
if (!lockTimedOut(result))
|
|
2398
|
+
return result;
|
|
2399
|
+
const left = deadline - Date.now();
|
|
2400
|
+
if (left > 0) {
|
|
2401
|
+
yield Math.min(retryMs, left);
|
|
2402
|
+
continue;
|
|
2403
|
+
}
|
|
2404
|
+
// A copy, as `core/head-retry.ts` makes its own: only the message changes.
|
|
2405
|
+
return {
|
|
2406
|
+
...result,
|
|
2407
|
+
message: `${result.message ?? "lock-timeout"}; the spend waited for the log's lock on the event loop for its whole ${String(budgetMs)} ms bound, trying every ${String(retryMs)} ms, and another writer held it at each of its ${String(tries)} attempt${tries === 1 ? "" : "s"} (APRV-478). Nothing was appended.`,
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
1383
2411
|
function truncate(text, limit) {
|
|
1384
2412
|
const collapsed = text.replace(/\s+/gu, " ").trim();
|
|
1385
2413
|
return collapsed.length <= limit ? collapsed : `${collapsed.slice(0, limit - 1)}…`;
|
|
@@ -1780,6 +2808,33 @@ function readToolGate(toolName, toolInput, roots, cwd) {
|
|
|
1780
2808
|
summary: `${toolName} ${file} (outside the read scope)`,
|
|
1781
2809
|
};
|
|
1782
2810
|
}
|
|
2811
|
+
/**
|
|
2812
|
+
* A read tool call naming a credential file (APRV-445 refutation, B2): the
|
|
2813
|
+
* Hermes home's `.env` and `auth.json`, and the approval home's vault, keys and
|
|
2814
|
+
* environment map. `null` for anything else.
|
|
2815
|
+
*/
|
|
2816
|
+
function credentialReadGate(toolName, toolInput, cwd) {
|
|
2817
|
+
const declared = readString(toolInput, "file_path") ??
|
|
2818
|
+
readString(toolInput, "notebook_path") ??
|
|
2819
|
+
readString(toolInput, "path");
|
|
2820
|
+
if (declared === null)
|
|
2821
|
+
return null;
|
|
2822
|
+
// APRV-445 recheck SF2(e, f): judged through realpath, so a symlinked
|
|
2823
|
+
// parent cannot launder the path, and a directory of credentials handed to a
|
|
2824
|
+
// read tool (a search, a glob, a grep) is gated as the credentials are.
|
|
2825
|
+
const file = canonicalPath(absolute(declared, cwd));
|
|
2826
|
+
// Recheck 3 (SF1a): only a directory that holds credentials (a home root, an
|
|
2827
|
+
// approval home, a home's `approval/`, or a directory under a home holding a
|
|
2828
|
+
// credential file); `skills/`, `workspace/` and the like are ordinary.
|
|
2829
|
+
if (!isCredentialPath(file) && !holdsCredentials(file))
|
|
2830
|
+
return null;
|
|
2831
|
+
const input = { ...toolInput };
|
|
2832
|
+
delete input["description"];
|
|
2833
|
+
return {
|
|
2834
|
+
payload: { tool: toolName, rule: "credential", file, input },
|
|
2835
|
+
summary: `${toolName} ${file} (credential)`,
|
|
2836
|
+
};
|
|
2837
|
+
}
|
|
1783
2838
|
/** The verdict note for a gated read: what was asked for, and against what. */
|
|
1784
2839
|
function readScopeNote(gated, roots) {
|
|
1785
2840
|
return `read-scope: ${gated.declared} resolves to ${gated.file}, which is outside the read scope (${renderReadRoots(roots)})`;
|
|
@@ -1911,6 +2966,12 @@ function abandonedRequests(run, records, now, keepHash) {
|
|
|
1911
2966
|
continue;
|
|
1912
2967
|
if (record.actor !== run.actor)
|
|
1913
2968
|
continue;
|
|
2969
|
+
// APRV-445. Under `approval serve` the hook and `propose` act as ONE actor,
|
|
2970
|
+
// and a proposal's request is harness-executed too, so without this every
|
|
2971
|
+
// proposal pending past the hook's wait plus grace (minutes) would be swept
|
|
2972
|
+
// as abandoned by the next gated tool call, days before its TTL.
|
|
2973
|
+
if (isProposalTask(record.task))
|
|
2974
|
+
continue;
|
|
1914
2975
|
const key = record.action_key;
|
|
1915
2976
|
if (typeof key !== "string" || key.length === 0)
|
|
1916
2977
|
continue;
|
|
@@ -1974,8 +3035,25 @@ function withdrawAbandoned(run, streams, records, now, keepHash, only = null) {
|
|
|
1974
3035
|
* is about (APRV-146). The gate requires it and compares it against what the
|
|
1975
3036
|
* human answered: the same value keyed the carryover that found these grants, so
|
|
1976
3037
|
* presenting it states, at the spend, the fact the match was made on.
|
|
3038
|
+
*
|
|
3039
|
+
* Steps since APRV-478: each key is spent through {@link spendUnderLock}, so a
|
|
3040
|
+
* wait for the log's lock is a yield and the shared pause before the spend
|
|
3041
|
+
* ({@link BEFORE_SPEND}) is taken there, immediately before each append.
|
|
3042
|
+
*
|
|
3043
|
+
* **A multi-key spend pauses before every key, deliberately.** A signal that
|
|
3044
|
+
* lands after the first key is spent and before the second is dispatched at the
|
|
3045
|
+
* second key's pause: the call blocks with the first key's `execution.started`
|
|
3046
|
+
* already recorded (before the signal) and the rest unspent. That is the same
|
|
3047
|
+
* outcome as a spend that fails on a later key, above: a recorded start for a
|
|
3048
|
+
* call the harness does not run, a grant that costs one more prompt, and
|
|
3049
|
+
* nothing authorized. The alternative, spending the later keys with no pause,
|
|
3050
|
+
* would make each later key's lock wait synchronous again, and a signal there
|
|
3051
|
+
* would be held through starts recorded AFTER it and answered with an allow.
|
|
3052
|
+
* Recording nothing after the signal and blocking is the stricter of the two.
|
|
3053
|
+
* An atomic spend of every key at once needs a gate operation that does not
|
|
3054
|
+
* exist; this is the residue the SPEC hunk names for a multi-class call.
|
|
1977
3055
|
*/
|
|
1978
|
-
function consumeGrants(run, keys, hash,
|
|
3056
|
+
function* consumeGrants(run, keys, hash,
|
|
1979
3057
|
/**
|
|
1980
3058
|
* This invocation's own task id (APRV-200). The gate compares it against the
|
|
1981
3059
|
* task the request record carries and records `grant_origin: "direct"` only
|
|
@@ -1983,11 +3061,12 @@ function consumeGrants(run, keys, hash,
|
|
|
1983
3061
|
*/
|
|
1984
3062
|
task) {
|
|
1985
3063
|
for (const key of keys) {
|
|
1986
|
-
const spent = consumeHarnessGrant(run.logPath, key, run.actor, {
|
|
3064
|
+
const spent = yield* spendUnderLock(run.logPath, run.options.append, (append) => consumeHarnessGrant(run.logPath, key, run.actor, {
|
|
1987
3065
|
...run.options,
|
|
3066
|
+
append,
|
|
1988
3067
|
presentedPayloadHash: hash,
|
|
1989
3068
|
spendingTask: task,
|
|
1990
|
-
});
|
|
3069
|
+
}));
|
|
1991
3070
|
if (!spent.ok)
|
|
1992
3071
|
return { code: spent.code, message: `${key}: ${spent.message}` };
|
|
1993
3072
|
}
|
|
@@ -2146,10 +3225,13 @@ known = null) {
|
|
|
2146
3225
|
* declaration would buy no oversight and would double the volume of exactly the
|
|
2147
3226
|
* traffic this is trying not to drown the log in. The supervised classes are
|
|
2148
3227
|
* registered already, by the caller, which is what makes them sampleable.
|
|
3228
|
+
*
|
|
3229
|
+
* Steps since APRV-478, for the reason {@link consumeGrants} gives: each class
|
|
3230
|
+
* is recorded through {@link spendUnderLock}, which takes the shared pause.
|
|
2149
3231
|
*/
|
|
2150
|
-
function recordUnattended(run, task, classes, hash) {
|
|
3232
|
+
function* recordUnattended(run, task, classes, hash) {
|
|
2151
3233
|
for (const cls of classes) {
|
|
2152
|
-
const started = startHarnessExecution(run.logPath, { task, actionKey: `${task}:${cls}`, cls, payload_hash: hash }, run.actor, run.options);
|
|
3234
|
+
const started = yield* spendUnderLock(run.logPath, run.options.append, (append) => startHarnessExecution(run.logPath, { task, actionKey: `${task}:${cls}`, cls, payload_hash: hash }, run.actor, { ...run.options, append }));
|
|
2153
3235
|
if (!started.ok)
|
|
2154
3236
|
return { code: started.code, message: `${cls}: ${started.message}` };
|
|
2155
3237
|
}
|
|
@@ -2185,7 +3267,7 @@ function recordUnattended(run, task, classes, hash) {
|
|
|
2185
3267
|
* both serves the channels and consumes the taps. That is worth saying: on
|
|
2186
3268
|
* 2026-09-05 taps piled up unconsumed while hooks waited out their windows.
|
|
2187
3269
|
*/
|
|
2188
|
-
function announceWait(streams, run, waiting) {
|
|
3270
|
+
function announceWait(streams, run, waiting, expiryOf) {
|
|
2189
3271
|
const where = run.channels.length === 0
|
|
2190
3272
|
? "no channel (this policy configures none, so nothing is delivering the question)"
|
|
2191
3273
|
: `channel ${run.channels.join(", ")}`;
|
|
@@ -2193,7 +3275,21 @@ function announceWait(streams, run, waiting) {
|
|
|
2193
3275
|
const adopted = action.origin === "adopted"
|
|
2194
3276
|
? " The question was already open for these exact bytes, so this tool call adopts it rather than asking a second time."
|
|
2195
3277
|
: "";
|
|
2196
|
-
|
|
3278
|
+
const at = expiryOf.get(action.actionKey);
|
|
3279
|
+
// Second review pass (F7): which of the two ends first decides the sentence.
|
|
3280
|
+
// "Open for a 5m grace" beside "expires at T+240s" was two claims about one
|
|
3281
|
+
// question that could not both hold; the shorter one is the one stated.
|
|
3282
|
+
const holdsOpen = at !== undefined && windowEndsBeforeGrace(run)
|
|
3283
|
+
? `. The request expires at ${at}, which comes before the ${minutesText(run.graceMs)} retry grace would run out, so that is how long it stays open for a retry; a decision after it is refused rather than granted.`
|
|
3284
|
+
: ` and leaving the request open for a ${minutesText(run.graceMs)} retry grace.${expiryClause(at)}`;
|
|
3285
|
+
streams.err(`approval: ${action.actionKey} (${action.cls}) is waiting for a human on ${where}; a decision on the phone releases it, and this hook blocks for up to ${waitText(run)} before denying with hook-timeout${holdsOpen}${adopted}\n`);
|
|
3286
|
+
}
|
|
3287
|
+
// APRV-423, third review pass. The wait is CLAMPED to what the ceiling
|
|
3288
|
+
// leaves (see `HookRun.waitMs`), so the runtime acts rather than warns: a
|
|
3289
|
+
// wait the harness would have cut short is shortened here, and the operator
|
|
3290
|
+
// is told once so the configured --timeout is not mistaken for the wait.
|
|
3291
|
+
if (run.harnessCapMs !== null && run.waitMs < run.timeoutMs) {
|
|
3292
|
+
streams.err(`approval: this hook's ${String(run.timeoutMs)}ms --timeout is longer than the ${String(run.harnessCapMs)}ms harness ceiling leaves after the ${String(HARNESS_CAP_MARGIN_MS)}ms margin, so it waits ${String(run.waitMs)}ms instead and answers before the harness stops listening. Lower --timeout, or state the real ceiling with --harness-cap.\n`);
|
|
2197
3293
|
}
|
|
2198
3294
|
const socket = drawSocketPathFor(run.logPath);
|
|
2199
3295
|
const listener = drawSocketUsable(socket);
|
|
@@ -2201,6 +3297,55 @@ function announceWait(streams, run, waiting) {
|
|
|
2201
3297
|
return;
|
|
2202
3298
|
streams.err(`approval: no listener is running for this log (${listener.reason}: ${socket}), so the request above may sit undelivered and a decision may go unconsumed. Start the gate's ambient runtime in the checkout that owns this log: \`eval "$(approval env)" && approval up\`, which runs the daemon loop and every configured channel in one process.\n`);
|
|
2203
3299
|
}
|
|
3300
|
+
/**
|
|
3301
|
+
* ` The request expires at <instant> ...`, or `""` when nothing bounds it
|
|
3302
|
+
* (APRV-423).
|
|
3303
|
+
*
|
|
3304
|
+
* One phrasing for the three places that state the deadline — the announce
|
|
3305
|
+
* line, the timeout deny and the expiry deny — so an agent reading two of them
|
|
3306
|
+
* in one session is reading the same sentence about the same moment.
|
|
3307
|
+
*/
|
|
3308
|
+
function expiryClause(at) {
|
|
3309
|
+
if (at === undefined)
|
|
3310
|
+
return "";
|
|
3311
|
+
return ` The request expires at ${at}, after which a decision on it is refused rather than granted.`;
|
|
3312
|
+
}
|
|
3313
|
+
/**
|
|
3314
|
+
* Does the window a capped request is judged by close before the retry grace
|
|
3315
|
+
* this invocation would hold the question open for? (APRV-423, second review
|
|
3316
|
+
* pass.)
|
|
3317
|
+
*
|
|
3318
|
+
* Both are durations measured from the `approval.requested` record's own `ts`
|
|
3319
|
+
* (`harnessCappedTtlMs` for the window, `abandonedAfterMs` for the grace), so
|
|
3320
|
+
* the comparison needs no clock. `false` for an uncapped run whatever the
|
|
3321
|
+
* policy's TTL: the uncapped sentences are left byte-for-byte as APRV-287 wrote
|
|
3322
|
+
* them, which `tests/cli-hook.test.ts`'s snapshot case depends on. Computed from
|
|
3323
|
+
* this invocation's own flags, so for an ADOPTED question opened under other
|
|
3324
|
+
* flags it is the current invocation's reading of the two windows.
|
|
3325
|
+
*/
|
|
3326
|
+
/**
|
|
3327
|
+
* `55000ms wait`, or `0ms wait (a 240000ms --timeout clamped to what the 30000ms
|
|
3328
|
+
* harness ceiling leaves)` when the ceiling shortened it (APRV-423, third
|
|
3329
|
+
* review pass). One spelling for the announce line, the lagging note and the
|
|
3330
|
+
* `hook-timeout` denies, so a reader is never told a wait this process did not
|
|
3331
|
+
* make.
|
|
3332
|
+
*/
|
|
3333
|
+
function waitText(run) {
|
|
3334
|
+
if (run.waitMs === run.timeoutMs || run.harnessCapMs === null) {
|
|
3335
|
+
return `${String(run.waitMs)}ms wait`;
|
|
3336
|
+
}
|
|
3337
|
+
return `${String(run.waitMs)}ms wait (a ${String(run.timeoutMs)}ms --timeout clamped to what the ${String(run.harnessCapMs)}ms harness ceiling leaves)`;
|
|
3338
|
+
}
|
|
3339
|
+
function windowEndsBeforeGrace(run) {
|
|
3340
|
+
if (run.harnessCapMs === null)
|
|
3341
|
+
return false;
|
|
3342
|
+
const window = harnessCappedTtlMs(run.ttlMs, run.harnessCapMs);
|
|
3343
|
+
return window !== null && window <= abandonedAfterMs(run.timeoutMs, run.graceMs);
|
|
3344
|
+
}
|
|
3345
|
+
/** ` (expired at <instant>)`, or `""`. The parenthetical form of the above. */
|
|
3346
|
+
function expiryAt(at) {
|
|
3347
|
+
return at === undefined ? "" : ` (expired at ${at})`;
|
|
3348
|
+
}
|
|
2204
3349
|
/**
|
|
2205
3350
|
* The hook's own rendering of a verdict: one decision object on stdout, in the
|
|
2206
3351
|
* dialect of the harness that asked.
|
|
@@ -2214,7 +3359,11 @@ function renderVerdict(streams, adapter, codexCommand, verdict) {
|
|
|
2214
3359
|
? allow(streams, verdict.reason, adapter.kind, codexCommand)
|
|
2215
3360
|
: deny(streams, verdict.code, verdict.detail, adapter.kind);
|
|
2216
3361
|
}
|
|
2217
|
-
export function gateHarnessCall(
|
|
3362
|
+
export function gateHarnessCall(...args) {
|
|
3363
|
+
return driveSync(gateHarnessSteps(...args));
|
|
3364
|
+
}
|
|
3365
|
+
/** {@link gateHarnessCall} as steps, for a driver that yields (APRV-473). */
|
|
3366
|
+
function* gateHarnessSteps(streams, run, classes,
|
|
2218
3367
|
/**
|
|
2219
3368
|
* The bytes the grant binds to: `{command, cwd}` for a Bash call, the change
|
|
2220
3369
|
* itself for a file tool (APRV-124). Whatever this is, it is what reaches the
|
|
@@ -2299,7 +3448,12 @@ floor = null) {
|
|
|
2299
3448
|
streams.err(`approval: withdrew ${String(swept.length)} abandoned harness request(s) nothing retried (${swept.join(", ")}); a tap on one of them now authorizes nothing and the channel says so\n`);
|
|
2300
3449
|
}
|
|
2301
3450
|
const actions = classes.map((cls) => {
|
|
2302
|
-
|
|
3451
|
+
// APRV-410: the window this invocation is configured for bounds which
|
|
3452
|
+
// GRANT it may carry, so an answer that landed after the asking process
|
|
3453
|
+
// was gone is not adopted by a later one. The sweep above takes back the
|
|
3454
|
+
// pending questions; this refuses the grants that were written on questions
|
|
3455
|
+
// nobody was holding. The two halves are the same fact from both sides.
|
|
3456
|
+
const carry = findHarnessCarry(intake.records, hash, cls, intakeTs, run.ttlMs, abandonedAfterMs(run.timeoutMs, run.graceMs));
|
|
2303
3457
|
if (carry === null)
|
|
2304
3458
|
return { cls, actionKey: `${task}:${cls}`, origin: "new" };
|
|
2305
3459
|
return {
|
|
@@ -2311,6 +3465,62 @@ floor = null) {
|
|
|
2311
3465
|
const fresh = actions.filter((action) => action.origin === "new");
|
|
2312
3466
|
const adopted = actions.filter((action) => action.origin === "adopted");
|
|
2313
3467
|
const carried = actions.filter((action) => action.origin === "carried");
|
|
3468
|
+
/**
|
|
3469
|
+
* The window every request this invocation opens will be judged by
|
|
3470
|
+
* (APRV-423): the policy's TTL, narrowed by the harness ceiling this process
|
|
3471
|
+
* runs under. `null` means nothing bounds it, which is a policy with no
|
|
3472
|
+
* `approval_ttl` under a harness that documents no maximum and an operator
|
|
3473
|
+
* who stated none.
|
|
3474
|
+
*/
|
|
3475
|
+
const ownTtlMs = harnessCappedTtlMs(run.ttlMs, run.harnessCapMs);
|
|
3476
|
+
/**
|
|
3477
|
+
* When each waited-on key lapses, ISO-8601, for the lines this invocation
|
|
3478
|
+
* prints. Filled from the runtime's OWN write-boundary timestamps — the `ts`
|
|
3479
|
+
* on the record `request` returned, and the request record intake already
|
|
3480
|
+
* read for an adopted key — so a deny that names a deadline names the one the
|
|
3481
|
+
* gate will judge by, and no second read of the log pays for the sentence.
|
|
3482
|
+
*
|
|
3483
|
+
* CAPPED REQUESTS ONLY (APRV-423). The sentence exists to state a deadline
|
|
3484
|
+
* its reader cannot work out from the policy: under a harness ceiling the
|
|
3485
|
+
* window is neither `approval_ttl` nor the harness's own timeout, and an
|
|
3486
|
+
* agent told "it lapsed" with no instant has no way to tell a four-minute
|
|
3487
|
+
* window from the hour its policy declares. A request the policy alone bounds
|
|
3488
|
+
* already has its deadline stated where its reader can see it (the approver's
|
|
3489
|
+
* own card, `approval status`, the queue), so the hook's lines are left
|
|
3490
|
+
* exactly as APRV-281 and APRV-287 wrote them — which also keeps every
|
|
3491
|
+
* uncapped deny byte-for-byte reproducible across two invocations, the
|
|
3492
|
+
* property `tests/cli-hook.test.ts` pins when it compares a cold read's
|
|
3493
|
+
* verdict with a snapshot-resumed one.
|
|
3494
|
+
*/
|
|
3495
|
+
const expiryOf = new Map();
|
|
3496
|
+
// APRV-423, and the last thing checked before this invocation opens a
|
|
3497
|
+
// question. A ceiling at or below the margin leaves no window: the request
|
|
3498
|
+
// would have to lapse before the harness kills this process, and there is no
|
|
3499
|
+
// "before" left. Refused rather than widened, because a hook that ignored its
|
|
3500
|
+
// own ceiling is a hook whose question outlives its asker, which is the whole
|
|
3501
|
+
// failure this cap exists to end (APRV-410). Placed here rather than at the
|
|
3502
|
+
// top of the function so it refuses exactly the act it is about: an ADOPTED
|
|
3503
|
+
// question was opened by a process that cleared this check, and a CARRIED
|
|
3504
|
+
// grant is a human's answer that already exists.
|
|
3505
|
+
if (fresh.length > 0 && run.harnessCapMs !== null && !harnessCapFitsMargin(run.harnessCapMs)) {
|
|
3506
|
+
// Second review pass: the deny names the actions it refused for (so a reader
|
|
3507
|
+
// can still see which class routed this call to a human), says whether the
|
|
3508
|
+
// ceiling was stated or assumed, and ends with the harness's own repair
|
|
3509
|
+
// where the adapter has one.
|
|
3510
|
+
const needing = fresh.map((action) => `${action.actionKey} (${action.cls})`).join(", ");
|
|
3511
|
+
const origin = run.harnessCapStated
|
|
3512
|
+
? "stated with --harness-cap"
|
|
3513
|
+
: "assumed from the harness's documented defaults because no --harness-cap was passed";
|
|
3514
|
+
// APRV-427 review: where time spent before this invocation was charged,
|
|
3515
|
+
// the operator's number is named as theirs and the remainder as what is
|
|
3516
|
+
// left of it, rather than reporting the remainder as what they stated.
|
|
3517
|
+
const source = run.harnessCapCeilingMs === null
|
|
3518
|
+
? origin
|
|
3519
|
+
: `~${String(run.harnessCapMs)}ms left of the ${String(run.harnessCapCeilingMs)}ms ceiling ${origin}; the rest was spent before this hook began, waiting in approval serve's queue or for its store lock`;
|
|
3520
|
+
const repair = run.harnessCapRepair ??
|
|
3521
|
+
`Raise the harness entry's timeout past ${String(HARNESS_CAP_MARGIN_MS)}ms and state the real one with --harness-cap, or route this class somewhere a human is not on the critical path.`;
|
|
3522
|
+
return sayDeny("hook-harness-cap-too-short", `${needing} would need a human, but this hook runs under a ${String(run.harnessCapMs)}ms harness ceiling (${source}), and a request must lapse at least ${String(HARNESS_CAP_MARGIN_MS)}ms before that ceiling so the runtime records the expiry while the harness is still listening. That leaves no window in which a human could answer, so nothing was registered, nothing was requested and no question reached a phone. ${repair}`);
|
|
3523
|
+
}
|
|
2314
3524
|
// Only the classes that need a new question are registered. A retry whose
|
|
2315
3525
|
// every class carries or adopts registers no task at all — the envelope it
|
|
2316
3526
|
// would declare already exists, under the key it is about to wait on.
|
|
@@ -2359,6 +3569,12 @@ floor = null) {
|
|
|
2359
3569
|
payload_hash: hash,
|
|
2360
3570
|
payload: { value: payload },
|
|
2361
3571
|
execution: "harness",
|
|
3572
|
+
// APRV-423. The ceiling this process runs under, recorded so the gate,
|
|
3573
|
+
// the daemon's sweep and every channel judge the question by a window
|
|
3574
|
+
// that ends before the harness kills the asker. Omitted entirely when
|
|
3575
|
+
// there is no ceiling to state: an absent field is the pre-APRV-423
|
|
3576
|
+
// shape, and the policy's TTL governs alone.
|
|
3577
|
+
...(run.harnessCapMs === null ? {} : { harnessCapMs: run.harnessCapMs }),
|
|
2362
3578
|
...(floorApplies(action.cls) ? { loopFloor: true } : {}),
|
|
2363
3579
|
}, run.actor, run.options);
|
|
2364
3580
|
if (!result.ok) {
|
|
@@ -2368,8 +3584,31 @@ floor = null) {
|
|
|
2368
3584
|
withdrawPending(run, streams, ownKeys, `intake refused ${action.actionKey}; this command cannot proceed, so the classes already opened for it are questions nobody needs to answer`);
|
|
2369
3585
|
return sayDeny(`hook-gate-refused:${result.code}`, result.message);
|
|
2370
3586
|
}
|
|
2371
|
-
if (result.record !== null)
|
|
3587
|
+
if (result.record !== null) {
|
|
2372
3588
|
ownKeys.push(action.actionKey);
|
|
3589
|
+
// The deadline in words, from the `ts` the runtime stamped on the record
|
|
3590
|
+
// it just wrote. Never from this process's own clock: a gate-typed event
|
|
3591
|
+
// carries the write boundary's instant (SPEC.md §11.1 invariant 2), and a
|
|
3592
|
+
// deny that named a deadline measured from anything else would be naming
|
|
3593
|
+
// a moment the gate does not judge by.
|
|
3594
|
+
if (ownTtlMs !== null && run.harnessCapMs !== null) {
|
|
3595
|
+
const lapses = Date.parse(result.record.ts);
|
|
3596
|
+
if (!Number.isNaN(lapses)) {
|
|
3597
|
+
expiryOf.set(action.actionKey, new Date(lapses + ownTtlMs).toISOString());
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3602
|
+
// An adopted question was opened by an earlier invocation, under whatever
|
|
3603
|
+
// ceiling THAT one ran under, so its deadline is read off the request record
|
|
3604
|
+
// intake already verified rather than recomputed from this process's flags.
|
|
3605
|
+
for (const action of adopted) {
|
|
3606
|
+
const derivation = requestState(intake.records, action.actionKey, intakeTs, run.ttlMs);
|
|
3607
|
+
if (derivation.declared.harness_cap_ms === null)
|
|
3608
|
+
continue;
|
|
3609
|
+
const at = requestExpiresAt(derivation);
|
|
3610
|
+
if (at !== null)
|
|
3611
|
+
expiryOf.set(action.actionKey, at);
|
|
2373
3612
|
}
|
|
2374
3613
|
/** Every key that must be granted before this hook says yes. */
|
|
2375
3614
|
const waitKeys = [...adopted.map((action) => action.actionKey), ...ownKeys];
|
|
@@ -2385,8 +3624,9 @@ floor = null) {
|
|
|
2385
3624
|
// SPEC.md §6.3), so there is nothing to wait for and nothing to spend.
|
|
2386
3625
|
// What there is, since APRV-141, is something to charge: the start event
|
|
2387
3626
|
// is this execution's authorization, and the registration `fresh` just
|
|
2388
|
-
// wrote is what makes it a sampleable one.
|
|
2389
|
-
|
|
3627
|
+
// wrote is what makes it a sampleable one. The shared pause before the
|
|
3628
|
+
// spend is taken inside (APRV-473, APRV-478).
|
|
3629
|
+
const charged = yield* recordUnattended(run, task, classes, hash);
|
|
2390
3630
|
if (charged !== null) {
|
|
2391
3631
|
return sayDeny(`hook-gate-refused:${charged.code}`, charged.message);
|
|
2392
3632
|
}
|
|
@@ -2394,7 +3634,7 @@ floor = null) {
|
|
|
2394
3634
|
}
|
|
2395
3635
|
// Every gated class carried an unspent grant: a human already answered this
|
|
2396
3636
|
// exact question about these exact bytes, and nobody is asked again.
|
|
2397
|
-
const failed = consumeGrants(run, spendKeys, hash, task);
|
|
3637
|
+
const failed = yield* consumeGrants(run, spendKeys, hash, task);
|
|
2398
3638
|
if (failed !== null) {
|
|
2399
3639
|
return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
|
|
2400
3640
|
}
|
|
@@ -2409,21 +3649,68 @@ floor = null) {
|
|
|
2409
3649
|
// an earlier tool call, which wait in the same silence and were the case the
|
|
2410
3650
|
// announce would most easily have missed. A carried grant is not here because
|
|
2411
3651
|
// nothing is waiting on it.
|
|
2412
|
-
announceWait(streams, run, actions.filter((action) => waitKeys.includes(action.actionKey)));
|
|
2413
|
-
const deadline = Date.now() + run.
|
|
3652
|
+
announceWait(streams, run, actions.filter((action) => waitKeys.includes(action.actionKey)), expiryOf);
|
|
3653
|
+
const deadline = Date.now() + run.waitMs;
|
|
3654
|
+
// APRV-427. From the first poll to the last this invocation only READS the
|
|
3655
|
+
// verified log and sleeps, so an embedding caller (`approval serve`) may let
|
|
3656
|
+
// other work on the same store run meanwhile. `leaveWait` hands the stretch
|
|
3657
|
+
// back before anything below appends, and once more whichever way the loop
|
|
3658
|
+
// is left; it is a no-op from the CLI, where there is no seam.
|
|
3659
|
+
let waiting = false;
|
|
3660
|
+
const leaveWait = () => {
|
|
3661
|
+
if (!waiting)
|
|
3662
|
+
return;
|
|
3663
|
+
waiting = false;
|
|
3664
|
+
run.waitSeam?.leaveWait();
|
|
3665
|
+
};
|
|
2414
3666
|
// A signal arriving mid-wait means the session is going away: nothing will
|
|
2415
3667
|
// retry this command, so the question this invocation opened is retracted.
|
|
2416
3668
|
// `process.exit` is deliberate and immediate: the default disposition for
|
|
2417
3669
|
// these signals is to die, and a handler that only withdrew would leave the
|
|
2418
3670
|
// hook wedged in its poll loop with the harness waiting on it.
|
|
3671
|
+
//
|
|
3672
|
+
// APRV-473, APRV-475. On every `approval hook <harness>` CLI route this
|
|
3673
|
+
// handler gets its turn: the wait yields to the event loop between polls
|
|
3674
|
+
// (`driveYielding`), so a signal is dispatched within one pause of its
|
|
3675
|
+
// arrival instead of being held until the wait returns. It exits, so nothing
|
|
3676
|
+
// after it in this function runs: no poll reads the grant a human gives
|
|
3677
|
+
// afterwards, and nothing spends it. The synchronous callers (`commandHook`
|
|
3678
|
+
// in process, the Codex bridge's `decideHarnessCall`, a serve worker thread)
|
|
3679
|
+
// drive these steps with `driveSync`, under which this listener is
|
|
3680
|
+
// registered and removed without the loop ever turning, so it never runs
|
|
3681
|
+
// there and never writes into a stdout that is not a hook's (the bridge's is
|
|
3682
|
+
// a JSON-RPC channel). A serve thread's caller departing is `callerGone`.
|
|
2419
3683
|
const onSignal = (signal) => {
|
|
2420
|
-
|
|
2421
|
-
|
|
3684
|
+
leaveWait();
|
|
3685
|
+
// APRV-445, APRV-475. A harness tearing down may still read the verdict,
|
|
3686
|
+
// and on several harnesses an exit with nothing on stdout is not a block
|
|
3687
|
+
// (Hermes reads it as an ALLOW unless the exit is 2; Claude Code, Codex
|
|
3688
|
+
// and Muse read most such exits as a failed hook and run the call). So the
|
|
3689
|
+
// harness's own block directive goes out FIRST, in its own dialect,
|
|
3690
|
+
// written synchronously, before the withdrawal: the withdrawal is a
|
|
3691
|
+
// compare-and-append that can wait up to the lock timeout behind another
|
|
3692
|
+
// writer, and a harness that escalates SIGTERM to SIGKILL inside that wait
|
|
3693
|
+
// must already be holding the block (APRV-475 refuter). The directive does
|
|
3694
|
+
// not depend on the withdrawal's outcome, and a withdrawal that never lands
|
|
3695
|
+
// leaves the question open as a timed-out one is.
|
|
3696
|
+
const exitCode = writeInterruptedWait(signal, run.harness);
|
|
3697
|
+
try {
|
|
3698
|
+
withdrawPending(run, streams, ownKeys, `the requesting hook process received ${signal} while waiting; the session is ending, so no retry will adopt this request`);
|
|
3699
|
+
}
|
|
3700
|
+
catch (cause) {
|
|
3701
|
+
// The withdrawal failed outright (an I/O fault inside the append). The
|
|
3702
|
+
// block is already on stdout: a block with the question left standing
|
|
3703
|
+
// is the stricter of the two outcomes this handler can still reach.
|
|
3704
|
+
streams.err(`approval: the hook could not withdraw its request after ${signal}: ${cause instanceof Error ? cause.message : String(cause)}\n`);
|
|
3705
|
+
}
|
|
3706
|
+
process.exit(exitCode);
|
|
2422
3707
|
};
|
|
2423
3708
|
const onTerm = () => onSignal("SIGTERM");
|
|
2424
3709
|
const onInt = () => onSignal("SIGINT");
|
|
2425
|
-
|
|
2426
|
-
|
|
3710
|
+
// Prepended: under Hermes an earlier guard (`hermesFailClosed`) listens for
|
|
3711
|
+
// the same signals, and this handler, which also withdraws, must answer first.
|
|
3712
|
+
process.prependListener("SIGTERM", onTerm);
|
|
3713
|
+
process.prependListener("SIGINT", onInt);
|
|
2427
3714
|
/**
|
|
2428
3715
|
* Has this invocation already said, on stderr, that the verified view lags
|
|
2429
3716
|
* the requests it is waiting on (APRV-294)? Said once per invocation: the
|
|
@@ -2431,22 +3718,96 @@ floor = null) {
|
|
|
2431
3718
|
* matters under sixty copies of itself.
|
|
2432
3719
|
*/
|
|
2433
3720
|
let saidLagging = false;
|
|
3721
|
+
/** Consecutive torn-tail reads, for {@link TORN_TAIL_RETRIES}. */
|
|
3722
|
+
let tornReads = 0;
|
|
3723
|
+
/** The last whole verified view this wait read, for a torn read at the deadline. */
|
|
3724
|
+
let lastClean = null;
|
|
3725
|
+
const callerGone = () => run.waitSeam?.cancelled?.() === true;
|
|
3726
|
+
/** The verdict for a caller that went away: nothing spent, nothing withdrawn. */
|
|
3727
|
+
const sayGone = () => sayDeny("hook-timeout", `the caller that asked went away before ${waitKeys.join(", ")} could be answered to it. NOTHING WAS SPENT AND NOTHING WAS WITHDRAWN: the question, or a grant already recorded on it, is left for a retry of this exact command in this exact directory to adopt within the ${minutesText(run.graceMs)} retry grace.`);
|
|
3728
|
+
const pollRead = run.waitSeam?.pollRead ?? ((path) => readVerifiedRecords(path));
|
|
3729
|
+
/**
|
|
3730
|
+
* The deny for a wait that ran out with the question left OPEN, after
|
|
3731
|
+
* `lead` (APRV-427 review: one builder, so every route to it says the same
|
|
3732
|
+
* thing). Two forms, as the second review pass (F7) fixed them: where the
|
|
3733
|
+
* capped window closes before the retry grace would, the sentence names the
|
|
3734
|
+
* expiry as what bounds the question; otherwise it names the grace and adds
|
|
3735
|
+
* the expiry where there is one.
|
|
3736
|
+
*/
|
|
3737
|
+
const keptOpen = (lead, stillLagging) => {
|
|
3738
|
+
// APRV-423. The deadline the still-open question is under, named in the
|
|
3739
|
+
// same breath as the wait that ran out: the two are different numbers and
|
|
3740
|
+
// the second one is the one that decides whether a retry can still adopt
|
|
3741
|
+
// this question. Under a harness ceiling it is also the instant the runtime
|
|
3742
|
+
// will append `approval.expired` at, which is what keeps this deny and that
|
|
3743
|
+
// record from disagreeing about one request.
|
|
3744
|
+
const deadlines = waitKeys
|
|
3745
|
+
.map((key) => expiryOf.get(key))
|
|
3746
|
+
.filter((at) => at !== undefined);
|
|
3747
|
+
const expiresAt = [...new Set(deadlines)].sort().join(", ");
|
|
3748
|
+
if (deadlines.length > 0 && windowEndsBeforeGrace(run)) {
|
|
3749
|
+
return sayDeny("hook-timeout", `${lead} This tool call is denied and NOTHING WAS WITHDRAWN: the harness ceiling this hook runs under bounds the question, and the request(s) expire at ${expiresAt}, which comes before the ${minutesText(run.graceMs)} retry grace would run out, so that is how long they stay open. A decision inside that window authorizes a retry of this exact command in this exact directory, once; retry it after the approver answers, and the retry adopts the same question rather than asking a second one. A decision after ${expiresAt} is refused rather than granted, the runtime records the lapse as approval.expired, and a retry then asks the question fresh.${stillLagging}`);
|
|
3750
|
+
}
|
|
3751
|
+
const expiresText = deadlines.length === 0
|
|
3752
|
+
? ""
|
|
3753
|
+
: ` The harness ceiling this hook runs under bounds the question too: the request(s) expire at ${expiresAt}, and a decision after that is refused rather than granted — the runtime records the lapse as approval.expired.`;
|
|
3754
|
+
return sayDeny("hook-timeout", `${lead} This tool call is denied and NOTHING WAS WITHDRAWN: the request(s) stay open for the ${minutesText(run.graceMs)} retry grace, and a decision inside that window authorizes a retry of this exact command in this exact directory, once.${expiresText} Retry it after the approver answers; the retry adopts the same question rather than asking a second one. Past the grace the question is taken back (approval.withdrawn, reason timeout) BY THE NEXT GATED TOOL CALL THIS ACTOR (${run.actor}) MAKES, which is the only process that may: a withdrawal is the requester's own (APRV-106), so no daemon and no channel can do it for you. Until that next call the request stays pending, and a decision that lands past the grace is recorded as a grant — it authorizes nothing, because a retry past the grace asks again rather than carrying a grant nobody was holding (APRV-410).${stillLagging}`);
|
|
3755
|
+
};
|
|
3756
|
+
run.waitSeam?.enterWait();
|
|
3757
|
+
waiting = true;
|
|
2434
3758
|
try {
|
|
2435
3759
|
for (;;) {
|
|
2436
|
-
|
|
3760
|
+
if (callerGone())
|
|
3761
|
+
return sayGone();
|
|
3762
|
+
let read = pollRead(run.logPath);
|
|
3763
|
+
if (!read.ok && read.code === "log-torn-tail" && tornReads < TORN_TAIL_RETRIES) {
|
|
3764
|
+
if (Date.now() < deadline) {
|
|
3765
|
+
// Another writer's line, caught half-landed. Read again next tick;
|
|
3766
|
+
// see TORN_TAIL_RETRIES. Said once, like the lagging view below.
|
|
3767
|
+
if (tornReads === 0) {
|
|
3768
|
+
streams.err(`approval: the log's last line was incomplete when this hook read it (another writer mid-append); reading again rather than giving up on ${task}\n`);
|
|
3769
|
+
}
|
|
3770
|
+
tornReads += 1;
|
|
3771
|
+
yield Math.min(run.intervalMs, Math.max(0, deadline - Date.now()));
|
|
3772
|
+
continue;
|
|
3773
|
+
}
|
|
3774
|
+
// AT the deadline (APRV-427 review). A clean read at this tick would
|
|
3775
|
+
// take the timeout path and leave the question open for the retry
|
|
3776
|
+
// grace; someone else's half-written line must not turn that into a
|
|
3777
|
+
// withdrawal. So the tick is judged by the last whole view this wait
|
|
3778
|
+
// read, and the timeout path below runs on it. Only a tail that stays
|
|
3779
|
+
// torn past the retries, a write nobody is going to finish, still
|
|
3780
|
+
// withdraws and denies.
|
|
3781
|
+
if (lastClean === null) {
|
|
3782
|
+
return keptOpen(`no decision on ${waitKeys.join(", ")} within the hook's ${waitText(run)}, and the log's last line was incomplete when the wait ended (another writer mid-append).`, "");
|
|
3783
|
+
}
|
|
3784
|
+
read = lastClean;
|
|
3785
|
+
}
|
|
2437
3786
|
if (!read.ok) {
|
|
3787
|
+
leaveWait();
|
|
2438
3788
|
withdrawPending(run, streams, ownKeys, `the hook could not read the log while waiting on ${task}`);
|
|
2439
3789
|
return sayDeny("hook-io", read.message);
|
|
2440
3790
|
}
|
|
3791
|
+
tornReads = 0;
|
|
3792
|
+
lastClean = read;
|
|
2441
3793
|
const ts = new Date().toISOString();
|
|
2442
3794
|
// Only the keys this invocation is waiting on count. Deriving the set
|
|
2443
3795
|
// from the log again would let an empty or foreign result read as
|
|
2444
3796
|
// "nothing pending" and fall through to allow; the verified log must show
|
|
2445
3797
|
// every one of these keys granted before the hook says yes.
|
|
2446
|
-
const derived = waitKeys.map((key) =>
|
|
2447
|
-
key,
|
|
2448
|
-
|
|
2449
|
-
|
|
3798
|
+
const derived = waitKeys.map((key) => {
|
|
3799
|
+
const derivation = requestState(read.records, key, ts, run.ttlMs);
|
|
3800
|
+
// APRV-423: the deadline this read judged by, kept beside the state so
|
|
3801
|
+
// the verdict and the sentence that explains it come from one
|
|
3802
|
+
// derivation. A key whose record this view does not carry yet answers
|
|
3803
|
+
// `null`, and the lagging branch below is what handles that.
|
|
3804
|
+
if (derivation.declared.harness_cap_ms !== null) {
|
|
3805
|
+
const at = requestExpiresAt(derivation);
|
|
3806
|
+
if (at !== null)
|
|
3807
|
+
expiryOf.set(key, at);
|
|
3808
|
+
}
|
|
3809
|
+
return { key, state: derivation.state };
|
|
3810
|
+
});
|
|
2450
3811
|
const states = derived.map((entry) => entry.state);
|
|
2451
3812
|
/**
|
|
2452
3813
|
* Keys this process ESTABLISHED exist, that this read does not carry
|
|
@@ -2480,7 +3841,7 @@ floor = null) {
|
|
|
2480
3841
|
.map((entry) => entry.key);
|
|
2481
3842
|
if (lagging.length > 0 && !saidLagging) {
|
|
2482
3843
|
saidLagging = true;
|
|
2483
|
-
streams.err(`approval: the verified log does not yet carry ${lagging.join(", ")} (verified head: ${read.head === null ? "empty" : `seq ${String(read.head.seq)}`}). The request(s) were appended by this hook, so this is a view that lags rather than a decision; the hook keeps waiting for the verification to catch up, up to its ${
|
|
3844
|
+
streams.err(`approval: the verified log does not yet carry ${lagging.join(", ")} (verified head: ${read.head === null ? "empty" : `seq ${String(read.head.seq)}`}). The request(s) were appended by this hook, so this is a view that lags rather than a decision; the hook keeps waiting for the verification to catch up, up to its ${waitText(run)}. A sync or a daemon restart in the last minute is the usual cause (docs/claude-code-hook.md).\n`);
|
|
2484
3845
|
}
|
|
2485
3846
|
if (!states.includes("requested") && lagging.length === 0) {
|
|
2486
3847
|
// Precedence, as `approval wait` fixes it: a human's "no" outranks a
|
|
@@ -2497,12 +3858,37 @@ floor = null) {
|
|
|
2497
3858
|
return sayDeny("hook-withdrawn", `the request for ${task} was withdrawn before a decision; nothing is pending and nothing was authorized`);
|
|
2498
3859
|
}
|
|
2499
3860
|
if (states.includes("expired")) {
|
|
2500
|
-
|
|
3861
|
+
// APRV-423. WHICH deadline it lapsed at, because under a harness
|
|
3862
|
+
// ceiling that is not the policy's TTL and an agent told only "it
|
|
3863
|
+
// lapsed" would read a four-minute window as an hour-long one that
|
|
3864
|
+
// somehow failed. The runtime, not this hook, appends the
|
|
3865
|
+
// `approval.expired` record: `decide` materialises it the moment a
|
|
3866
|
+
// late tap is refused, and the daemon's sweep materialises it on its
|
|
3867
|
+
// own cadence. A hook never writes one.
|
|
3868
|
+
const lapsed = derived
|
|
3869
|
+
.filter((entry) => entry.state === "expired")
|
|
3870
|
+
.map((entry) => `${entry.key}${expiryAt(expiryOf.get(entry.key))}`);
|
|
3871
|
+
return sayDeny("hook-expired", `the request for ${task} lapsed before a decision (${lapsed.join(", ")}). A decision arriving now is refused rather than granted, and the runtime records the lapse as approval.expired; run the command again to ask the question fresh.`);
|
|
2501
3872
|
}
|
|
2502
3873
|
if (states.every((state) => state === "granted")) {
|
|
2503
3874
|
// The grants are spent before the allow is printed, so this exact
|
|
2504
3875
|
// command cannot ride the same authorization twice.
|
|
2505
|
-
|
|
3876
|
+
//
|
|
3877
|
+
// APRV-473, APRV-478. `consumeGrants` turns the event loop before it
|
|
3878
|
+
// spends: once per retry while another writer holds the log's lock,
|
|
3879
|
+
// and once more (`BEFORE_SPEND`) immediately before each append. A
|
|
3880
|
+
// signal that arrived while this read ran, or while the spend waited
|
|
3881
|
+
// for the lock, is dispatched there, to the handler above, which
|
|
3882
|
+
// blocks, withdraws (a no-op now the request is decided, refused
|
|
3883
|
+
// `already-decided`) and exits. The grant is then left unspent rather
|
|
3884
|
+
// than recorded as the start of a call the harness gave up on.
|
|
3885
|
+
leaveWait();
|
|
3886
|
+
// Looked at again now the lock is held: re-taking it may have waited,
|
|
3887
|
+
// and a caller that left meanwhile must not have its grant spent on a
|
|
3888
|
+
// verdict nobody will receive. The retry is who spends it.
|
|
3889
|
+
if (callerGone())
|
|
3890
|
+
return sayGone();
|
|
3891
|
+
const failed = yield* consumeGrants(run, spendKeys, hash, task);
|
|
2506
3892
|
if (failed !== null) {
|
|
2507
3893
|
return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
|
|
2508
3894
|
}
|
|
@@ -2531,6 +3917,7 @@ floor = null) {
|
|
|
2531
3917
|
// Past the grace nobody is coming back for it, and a question nothing
|
|
2532
3918
|
// will adopt is taken back rather than left for a restarted listener to
|
|
2533
3919
|
// re-deliver.
|
|
3920
|
+
leaveWait();
|
|
2534
3921
|
const withdrawn = withdrawAbandoned(run, streams, read.records, ts, null, ownKeys);
|
|
2535
3922
|
// APRV-294: a wait that ends with the view still short of its own
|
|
2536
3923
|
// requests says so. The deny is the same deny — the wait ran out — and
|
|
@@ -2541,11 +3928,11 @@ floor = null) {
|
|
|
2541
3928
|
? ""
|
|
2542
3929
|
: ` The verified view still does not carry ${lagging.join(", ")}, which this hook appended: the request(s) exist and the view is behind, so check the log that owns them (\`approval log verify\`, \`approval status\`) rather than reading this as an unanswered question.`;
|
|
2543
3930
|
if (withdrawn.length > 0) {
|
|
2544
|
-
return sayDeny("hook-timeout", `no decision on ${waitKeys.join(", ")} within the hook's ${
|
|
3931
|
+
return sayDeny("hook-timeout", `no decision on ${waitKeys.join(", ")} within the hook's ${waitText(run)}, and the ${minutesText(run.graceMs)} retry grace has run out: ${withdrawn.join(", ")} WAS WITHDRAWN (reason timeout). A tap on it now authorizes nothing and the channel says so. Run the command again to ask the question fresh.${stillLagging}`);
|
|
2545
3932
|
}
|
|
2546
|
-
return
|
|
3933
|
+
return keptOpen(`no decision on ${waitKeys.join(", ")} within the hook's ${waitText(run)}.`, stillLagging);
|
|
2547
3934
|
}
|
|
2548
|
-
|
|
3935
|
+
yield Math.min(run.intervalMs, Math.max(0, deadline - Date.now()));
|
|
2549
3936
|
}
|
|
2550
3937
|
}
|
|
2551
3938
|
catch (cause) {
|
|
@@ -2553,10 +3940,12 @@ floor = null) {
|
|
|
2553
3940
|
// deny. Unlike the timeout, this process cannot say what state it left
|
|
2554
3941
|
// behind, so the question it opened is retracted rather than left standing
|
|
2555
3942
|
// on a failure nobody diagnosed.
|
|
3943
|
+
leaveWait();
|
|
2556
3944
|
withdrawPending(run, streams, ownKeys, `the requesting hook process failed while waiting (${cause instanceof Error ? cause.message : String(cause)})`);
|
|
2557
3945
|
throw cause;
|
|
2558
3946
|
}
|
|
2559
3947
|
finally {
|
|
3948
|
+
leaveWait();
|
|
2560
3949
|
process.off("SIGTERM", onTerm);
|
|
2561
3950
|
process.off("SIGINT", onInt);
|
|
2562
3951
|
}
|
|
@@ -2760,9 +4149,81 @@ function readMuseReportedOutcome(input) {
|
|
|
2760
4149
|
}
|
|
2761
4150
|
return { ok: true, outcome: "completed" };
|
|
2762
4151
|
}
|
|
4152
|
+
/**
|
|
4153
|
+
* Hermes's outcome, read off `post_tool_call` where the event carries one
|
|
4154
|
+
* (APRV-398).
|
|
4155
|
+
*
|
|
4156
|
+
* The envelope's result field is read for its SHAPE only, exactly as the generic
|
|
4157
|
+
* reader reads Claude Code's: an `exit_code` decides a shell call, an `error`
|
|
4158
|
+
* string or an `error` flag decides anything, and a result this cannot read is
|
|
4159
|
+
* UNREADABLE rather than assumed complete. Nothing of the tool's output text is
|
|
4160
|
+
* read, and nothing from it reaches the log.
|
|
4161
|
+
*
|
|
4162
|
+
* Unreadable is the safe answer in both directions at once and the reasoning is
|
|
4163
|
+
* the generic reader's own: a failure nobody observed trips an escalation on
|
|
4164
|
+
* noise, and a completion nobody observed clears a streak on nothing. Appending
|
|
4165
|
+
* neither leaves the path as vacuous as it was before this adapter existed, for
|
|
4166
|
+
* that call.
|
|
4167
|
+
*
|
|
4168
|
+
* A POST EVENT ON THIS HARNESS IS NOT EVIDENCE THE TOOL RAN, which APRV-415's
|
|
4169
|
+
* probe established and which changes what the missing-result case may conclude.
|
|
4170
|
+
* `post_tool_call` fired 29ms after an exit-2 deny, and it fired again after the
|
|
4171
|
+
* armed hang was refused at the 300s cap: Hermes reports the end of its own
|
|
4172
|
+
* dispatch, not the end of an execution. So an envelope this reader cannot read
|
|
4173
|
+
* is UNREADABLE rather than complete. The event name is Hermes saying its
|
|
4174
|
+
* `post_tool_call` code path ran and nothing more, and crediting a completion on
|
|
4175
|
+
* that alone would clear a failure streak (amended SPEC.md §10.2) on a call that
|
|
4176
|
+
* may have been blocked. A blocked call usually has no `execution.started` to
|
|
4177
|
+
* close, because the pre half appended nothing; the case this protects is the one
|
|
4178
|
+
* where the pre half ALLOWED and something later in Hermes's own executor —
|
|
4179
|
+
* `approvals.mode`, a guardrail, a tool-scope check — stopped the call anyway.
|
|
4180
|
+
*/
|
|
4181
|
+
function readHermesReportedOutcome(input) {
|
|
4182
|
+
// A shell result arrives as an object on this harness (`tool_input` is an
|
|
4183
|
+
// object throughout its contract), so the generic object reader is the right
|
|
4184
|
+
// shape; a string result says nothing about success and says so below.
|
|
4185
|
+
const response = input.toolResponse;
|
|
4186
|
+
if (response === null) {
|
|
4187
|
+
return {
|
|
4188
|
+
ok: false,
|
|
4189
|
+
detail: "the post_tool_call event carries no readable result, and on this harness a post event is not evidence the tool ran: it fires for a blocked call and after a refused timeout, so nothing here says the execution completed or failed",
|
|
4190
|
+
};
|
|
4191
|
+
}
|
|
4192
|
+
if (response["interrupted"] === true || input.interrupted) {
|
|
4193
|
+
return {
|
|
4194
|
+
ok: false,
|
|
4195
|
+
detail: "the tool call was interrupted, so it neither completed nor failed on its own terms; an interruption is somebody stopping the session rather than a loop to escalate or a recovery to credit",
|
|
4196
|
+
};
|
|
4197
|
+
}
|
|
4198
|
+
const exitCode = response["exit_code"] ?? response["exitCode"] ?? response["returncode"];
|
|
4199
|
+
if (typeof exitCode === "number") {
|
|
4200
|
+
return { ok: true, outcome: exitCode === 0 ? "completed" : "failed" };
|
|
4201
|
+
}
|
|
4202
|
+
const errorText = response["error"];
|
|
4203
|
+
if (response["error"] === true ||
|
|
4204
|
+
response["status"] === "error" ||
|
|
4205
|
+
(typeof errorText === "string" && errorText.length > 0)) {
|
|
4206
|
+
return { ok: true, outcome: "failed" };
|
|
4207
|
+
}
|
|
4208
|
+
// A result object the harness DID attach, naming neither an exit code nor an
|
|
4209
|
+
// error: read as a completion, which is the generic reader's own precedent. The
|
|
4210
|
+
// residual is stated rather than hidden — if a blocked call turns out to carry a
|
|
4211
|
+
// result object of this shape, this line credits a completion that did not
|
|
4212
|
+
// happen, and the repair is one more key here once a capture names it.
|
|
4213
|
+
return { ok: true, outcome: "completed" };
|
|
4214
|
+
}
|
|
2763
4215
|
function readReportedOutcome(input, adapter) {
|
|
2764
4216
|
if (adapter.kind === "codex")
|
|
2765
4217
|
return readCodexReportedOutcome(input);
|
|
4218
|
+
if (adapter.kind === "hermes") {
|
|
4219
|
+
if (input.hookEventName !== HERMES_POST_TOOL_EVENT) {
|
|
4220
|
+
return {
|
|
4221
|
+
ok: false,
|
|
4222
|
+
detail: `hook_event_name is ${input.hookEventName === null ? "absent" : JSON.stringify(input.hookEventName)}, which is not the event this adapter reports an outcome for (${HERMES_POST_TOOL_EVENT})`,
|
|
4223
|
+
};
|
|
4224
|
+
}
|
|
4225
|
+
return readHermesReportedOutcome(input);
|
|
4226
|
+
}
|
|
2766
4227
|
if (adapter.kind === "muse") {
|
|
2767
4228
|
if (input.hookEventName !== "PostToolUse") {
|
|
2768
4229
|
return {
|
|
@@ -2813,7 +4274,9 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
|
|
|
2813
4274
|
// APRV-347: a read tool MAY have had a start written for it (one outside
|
|
2814
4275
|
// the scope), so it belongs in this set. A read inside the scope wrote
|
|
2815
4276
|
// nothing, and the close below finds no start and says so in its own words.
|
|
2816
|
-
!adapter.readTools.includes(input.toolName)
|
|
4277
|
+
!adapter.readTools.includes(input.toolName) &&
|
|
4278
|
+
// APRV-445: a tool the rule table gated wrote a start like any other.
|
|
4279
|
+
toolRuleOf(adapter, input)?.kind !== "gated") {
|
|
2817
4280
|
return report(streams, "post-tool-not-gated", `${input.toolName} is not a gated tool, so no execution.started was ever written for it`);
|
|
2818
4281
|
}
|
|
2819
4282
|
if (input.toolUseId === null) {
|
|
@@ -2964,6 +4427,20 @@ function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
|
|
|
2964
4427
|
};
|
|
2965
4428
|
}
|
|
2966
4429
|
const classes = classified.classes.filter((cls) => cls !== GATE_SELF_CLASS);
|
|
4430
|
+
// APRV-445 refutation (B2, L10). The classifier matches protected paths by
|
|
4431
|
+
// their SEGMENTS, and a relative word carries only the segments it was
|
|
4432
|
+
// written with: `echo X >> .env` run in `$HERMES_HOME` names `.env`, not
|
|
4433
|
+
// `.hermes/.env`, and classified as a workspace write. Where the harness
|
|
4434
|
+
// states the per-call directory the command runs in, every relative word
|
|
4435
|
+
// and every write target is ALSO judged resolved against it: a write that
|
|
4436
|
+
// lands on an organ takes the organ's class, and a read that lands on a
|
|
4437
|
+
// credential takes `account.credential`. It only ever adds a class.
|
|
4438
|
+
if (shellCwd !== null) {
|
|
4439
|
+
for (const cls of resolvedPathClasses(raw, classified.segments, shellCwd, protectedPaths)) {
|
|
4440
|
+
if (!classes.includes(cls))
|
|
4441
|
+
classes.push(cls);
|
|
4442
|
+
}
|
|
4443
|
+
}
|
|
2967
4444
|
if (adapter.kind === "codex") {
|
|
2968
4445
|
// The pure shell classifier sees each segment independently. Preserve
|
|
2969
4446
|
// Codex hook organs when an earlier simple `cd` changes the directory or
|
|
@@ -3045,10 +4522,37 @@ function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
|
|
|
3045
4522
|
segments: classified.segments,
|
|
3046
4523
|
};
|
|
3047
4524
|
}
|
|
4525
|
+
// APRV-445: the harness's own side-effecting tools, by name and arguments.
|
|
4526
|
+
// The payload is the whole call, so a grant binds every argument.
|
|
4527
|
+
const ruled = toolRuleOf(adapter, input);
|
|
4528
|
+
if (ruled !== null) {
|
|
4529
|
+
if (ruled.kind === "read")
|
|
4530
|
+
return { kind: "allow", reason: ruled.reason };
|
|
4531
|
+
return {
|
|
4532
|
+
kind: "gated",
|
|
4533
|
+
classes: [ruled.cls],
|
|
4534
|
+
payload: { tool: input.toolName, input: input.toolInput },
|
|
4535
|
+
headline: ruled.headline,
|
|
4536
|
+
notes: [],
|
|
4537
|
+
};
|
|
4538
|
+
}
|
|
3048
4539
|
// APRV-347, above the file-tool branch because the two lists are disjoint and
|
|
3049
4540
|
// a read is the cheaper question to answer: a read inside the scope, or one
|
|
3050
4541
|
// naming no path at all, returns `null` here and takes the allow below.
|
|
3051
4542
|
if (adapter.readTools.includes(input.toolName)) {
|
|
4543
|
+
// APRV-445 refutation (B2): a read tool naming a credential file is
|
|
4544
|
+
// `account.credential` wherever the file sits, inside the read scope
|
|
4545
|
+
// included, as the same read through the shell already was.
|
|
4546
|
+
const credential = credentialReadGate(input.toolName, input.toolInput, cwd);
|
|
4547
|
+
if (credential !== null) {
|
|
4548
|
+
return {
|
|
4549
|
+
kind: "gated",
|
|
4550
|
+
classes: ["account.credential"],
|
|
4551
|
+
payload: credential.payload,
|
|
4552
|
+
headline: credential.summary,
|
|
4553
|
+
notes: [],
|
|
4554
|
+
};
|
|
4555
|
+
}
|
|
3052
4556
|
const read = readToolGate(input.toolName, input.toolInput, readRoots, cwd);
|
|
3053
4557
|
if (read === null) {
|
|
3054
4558
|
return {
|
|
@@ -3078,6 +4582,465 @@ function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
|
|
|
3078
4582
|
notes: gated.protectedPath ? [fileTierNote(gated)] : [],
|
|
3079
4583
|
};
|
|
3080
4584
|
}
|
|
4585
|
+
/**
|
|
4586
|
+
* The classes a shell command's PATHS take once they are resolved the way the
|
|
4587
|
+
* shell and the filesystem will resolve them (APRV-445 refutation B2 and L10,
|
|
4588
|
+
* recheck SF2 and L-e), for a harness that states the directory each call runs
|
|
4589
|
+
* in.
|
|
4590
|
+
*
|
|
4591
|
+
* The classifier matches organs and credentials by path SEGMENTS of the words
|
|
4592
|
+
* as written. A word written relative (`.env` run in `$HERMES_HOME`), through a
|
|
4593
|
+
* variable (`$HERMES_HOME/.env`), after a `cd`, or through a symlinked parent
|
|
4594
|
+
* (`h/.env` where `h -> .hermes`) carries none of the segments that matter. So
|
|
4595
|
+
* each path a segment touches is also judged here:
|
|
4596
|
+
*
|
|
4597
|
+
* - WRITTEN paths are the redirect targets (heredocs included) and the
|
|
4598
|
+
* write-argument positions of the binaries whose writes are known (L-e):
|
|
4599
|
+
* never the binary, never a word that is merely an argument. A written path
|
|
4600
|
+
* resolving to an organ takes its class; one carrying a glob or a variable
|
|
4601
|
+
* this hook cannot expand is `policy.core` when it lands in one of the gate's
|
|
4602
|
+
* own directories ({@link gateRootKind}) or a pattern in it could name the
|
|
4603
|
+
* home or an organ (recheck 3); an unexpanded `$HERMES_HOME` in a written
|
|
4604
|
+
* path is `policy.core` wherever it is written from. A copy into a directory
|
|
4605
|
+
* writes `<dir>/<source name>`; an extraction writes into its directory.
|
|
4606
|
+
* - READ paths (a read segment's arguments, `<` targets, a copy's sources) that
|
|
4607
|
+
* resolve to a credential take `account.credential`, and so does a directory
|
|
4608
|
+
* that holds credentials ({@link holdsCredentials}) handed to a binary that
|
|
4609
|
+
* reads directories recursively (`grep -r`, `rg`, `find`, `tar`, ...).
|
|
4610
|
+
* - After a `cd` this hook cannot resolve, every later relative write is
|
|
4611
|
+
* `policy.core` and every later relative read `account.credential`.
|
|
4612
|
+
* - `cd` moves the directory for the segments after it. Like the Codex arm, a
|
|
4613
|
+
* `cd` may or may not have run (`||`, a failed `cd`), so the directories
|
|
4614
|
+
* before and after it are both kept and every later path is judged from each.
|
|
4615
|
+
* - Every path is resolved through `realpath` on its deepest existing ancestor,
|
|
4616
|
+
* so a symlinked parent or working directory is judged by where it lands.
|
|
4617
|
+
* - `$HERMES_HOME` / `${HERMES_HOME}` expand to `APPROVAL_HERMES_HOME`, else
|
|
4618
|
+
* `HERMES_HOME`, from this hook process's environment; `~`, `$HOME` and
|
|
4619
|
+
* `${HOME}` to `HOME`. Under `approval serve` the process is the daemon, so a
|
|
4620
|
+
* hosted operator sets `APPROVAL_HERMES_HOME` there; without it, the
|
|
4621
|
+
* conservative rules above apply to the unexpanded spelling.
|
|
4622
|
+
*
|
|
4623
|
+
* What stays out of reach is listed in `docs/hermes-hook.md`: inline programs
|
|
4624
|
+
* (`python -c`, which the classifier already refuses as opaque), `dd`/`install`
|
|
4625
|
+
* (unclassified, so already denied), and a recursive search of an ANCESTOR of
|
|
4626
|
+
* the home with a filename filter.
|
|
4627
|
+
*
|
|
4628
|
+
* It only ever adds classes.
|
|
4629
|
+
*/
|
|
4630
|
+
function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.env) {
|
|
4631
|
+
const found = [];
|
|
4632
|
+
const add = (cls) => {
|
|
4633
|
+
if (cls !== null && !found.includes(cls))
|
|
4634
|
+
found.push(cls);
|
|
4635
|
+
};
|
|
4636
|
+
const shapes = commandSegmentShapes(raw) ?? [];
|
|
4637
|
+
// Trusted as given: the operator sets it (under co-location, the control
|
|
4638
|
+
// plane sets it from the tenant's home), and nothing the agent writes reaches
|
|
4639
|
+
// this process's environment.
|
|
4640
|
+
const hermesHome = env["APPROVAL_HERMES_HOME"] ?? env["HERMES_HOME"] ?? null;
|
|
4641
|
+
const home = env["HOME"] ?? null;
|
|
4642
|
+
/** The word with the variables this hook can know expanded. */
|
|
4643
|
+
const expand = (word) => {
|
|
4644
|
+
let text = word;
|
|
4645
|
+
let hermesUnknown = false;
|
|
4646
|
+
const hermesRef = /^\$(?:\{HERMES_HOME\}|HERMES_HOME)(?=\/|$)/u;
|
|
4647
|
+
if (hermesRef.test(text)) {
|
|
4648
|
+
if (hermesHome !== null && hermesHome.length > 0)
|
|
4649
|
+
text = text.replace(hermesRef, hermesHome);
|
|
4650
|
+
else
|
|
4651
|
+
hermesUnknown = true;
|
|
4652
|
+
}
|
|
4653
|
+
const homeRef = /^(?:~|\$\{HOME\}|\$HOME)(?=\/|$)/u;
|
|
4654
|
+
if (homeRef.test(text) && home !== null && home.length > 0)
|
|
4655
|
+
text = text.replace(homeRef, home);
|
|
4656
|
+
return { text, hermesUnknown, unresolved: hermesUnknown || /[$`]/u.test(text) };
|
|
4657
|
+
};
|
|
4658
|
+
// After a `cd` this hook cannot resolve (`cd $X`, `cd "$D"`, `cd -`), the
|
|
4659
|
+
// directory every later relative path lands in is unknown (recheck 3, SF2).
|
|
4660
|
+
let cwdUnknown = false;
|
|
4661
|
+
let cwds = [canonicalPath(cwd)];
|
|
4662
|
+
for (const [index, segment] of segments.entries()) {
|
|
4663
|
+
const shape = shapes.length === segments.length
|
|
4664
|
+
? shapes[index]
|
|
4665
|
+
: shapes.find((candidate) => candidate.text === segment.text);
|
|
4666
|
+
if (shape === undefined)
|
|
4667
|
+
continue;
|
|
4668
|
+
const bin = shape.bin === null ? null : basename(shape.bin);
|
|
4669
|
+
const operands = shape.args.filter((arg) => !arg.startsWith("-"));
|
|
4670
|
+
if (bin === "cd" || bin === "pushd") {
|
|
4671
|
+
const target = operands[0];
|
|
4672
|
+
if (target === undefined) {
|
|
4673
|
+
// A bare `cd` goes HOME; `cd -` and `pushd` with no operand go to a
|
|
4674
|
+
// directory recorded earlier in a shell this hook never saw.
|
|
4675
|
+
if (bin === "cd" && !shape.args.includes("-") && home !== null) {
|
|
4676
|
+
cwds = [...new Set([...cwds, canonicalPath(home)])];
|
|
4677
|
+
}
|
|
4678
|
+
else {
|
|
4679
|
+
cwdUnknown = true;
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4682
|
+
else {
|
|
4683
|
+
const expanded = expand(target);
|
|
4684
|
+
if (expanded.unresolved || hasGlobChars(expanded.text)) {
|
|
4685
|
+
cwdUnknown = true;
|
|
4686
|
+
}
|
|
4687
|
+
else {
|
|
4688
|
+
const next = cwds.map((from) => canonicalPath(resolvePathSegments(from, expanded.text)));
|
|
4689
|
+
cwds = [...new Set([...cwds, ...next])];
|
|
4690
|
+
}
|
|
4691
|
+
}
|
|
4692
|
+
continue;
|
|
4693
|
+
}
|
|
4694
|
+
if (bin === null && shape.writes.length === 0 && shape.reads.length === 0)
|
|
4695
|
+
continue;
|
|
4696
|
+
const judgeWrite = (word) => {
|
|
4697
|
+
const target = expand(word);
|
|
4698
|
+
if (target.hermesUnknown) {
|
|
4699
|
+
add("policy.core");
|
|
4700
|
+
return;
|
|
4701
|
+
}
|
|
4702
|
+
if (!isAbsolute(target.text) && cwdUnknown) {
|
|
4703
|
+
add("policy.core");
|
|
4704
|
+
return;
|
|
4705
|
+
}
|
|
4706
|
+
const globbed = target.unresolved || hasGlobChars(target.text);
|
|
4707
|
+
for (const from of cwds) {
|
|
4708
|
+
const absoluteTarget = resolvePathSegments(from, target.text);
|
|
4709
|
+
if (globbed) {
|
|
4710
|
+
// SF3a: a pattern in any component that could name the home, the
|
|
4711
|
+
// approval home or an organ is an organ write; a pattern written
|
|
4712
|
+
// into one of the gate's own directories (SF1b) may be any file
|
|
4713
|
+
// there. Anywhere else it is a workspace write.
|
|
4714
|
+
if (globCouldNameOrgan(absoluteTarget) || writesIntoGateRoot(canonicalPath(dirname(absoluteTarget)))) {
|
|
4715
|
+
add("policy.core");
|
|
4716
|
+
}
|
|
4717
|
+
continue;
|
|
4718
|
+
}
|
|
4719
|
+
add(protectedPathClass(canonicalPath(absoluteTarget), protectedPaths));
|
|
4720
|
+
}
|
|
4721
|
+
};
|
|
4722
|
+
/** A copy or move INTO a directory writes `<dir>/<basename of source>`. */
|
|
4723
|
+
const judgeWriteInto = (directory, sources) => {
|
|
4724
|
+
for (const source of sources) {
|
|
4725
|
+
const name = basename(expand(source).text);
|
|
4726
|
+
if (name.length === 0 || name === "." || name === "..") {
|
|
4727
|
+
// `cp -r stuff/. dir`: the contents land in the directory itself.
|
|
4728
|
+
judgeExtractInto(directory);
|
|
4729
|
+
continue;
|
|
4730
|
+
}
|
|
4731
|
+
judgeWrite(join(directory, name));
|
|
4732
|
+
}
|
|
4733
|
+
};
|
|
4734
|
+
/** An extraction or a contents-copy writes unknown names into `directory`. */
|
|
4735
|
+
const judgeExtractInto = (directory) => {
|
|
4736
|
+
const target = expand(directory);
|
|
4737
|
+
if (target.hermesUnknown || (cwdUnknown && !isAbsolute(target.text))) {
|
|
4738
|
+
add("policy.core");
|
|
4739
|
+
return;
|
|
4740
|
+
}
|
|
4741
|
+
if (target.unresolved)
|
|
4742
|
+
return;
|
|
4743
|
+
for (const from of cwds) {
|
|
4744
|
+
if (writesIntoGateRoot(canonicalPath(resolvePathSegments(from, target.text))))
|
|
4745
|
+
add("policy.core");
|
|
4746
|
+
}
|
|
4747
|
+
};
|
|
4748
|
+
const judgeRead = (word, recursive) => {
|
|
4749
|
+
const target = expand(word);
|
|
4750
|
+
if (target.hermesUnknown) {
|
|
4751
|
+
add("account.credential");
|
|
4752
|
+
return;
|
|
4753
|
+
}
|
|
4754
|
+
if (!isAbsolute(target.text) && cwdUnknown) {
|
|
4755
|
+
add("account.credential");
|
|
4756
|
+
return;
|
|
4757
|
+
}
|
|
4758
|
+
if (target.unresolved)
|
|
4759
|
+
return;
|
|
4760
|
+
for (const from of cwds) {
|
|
4761
|
+
const absoluteTarget = resolvePathSegments(from, target.text);
|
|
4762
|
+
if (hasGlobChars(target.text)) {
|
|
4763
|
+
// SF3b: expand against the directory when it is known; otherwise a
|
|
4764
|
+
// pattern that could name a credential is read as one.
|
|
4765
|
+
const candidates = expandGlob(absoluteTarget);
|
|
4766
|
+
if (candidates === null) {
|
|
4767
|
+
if (globCouldNameCredential(absoluteTarget))
|
|
4768
|
+
add("account.credential");
|
|
4769
|
+
}
|
|
4770
|
+
else {
|
|
4771
|
+
for (const candidate of candidates) {
|
|
4772
|
+
if (isCredentialPath(canonicalPath(candidate)))
|
|
4773
|
+
add("account.credential");
|
|
4774
|
+
}
|
|
4775
|
+
}
|
|
4776
|
+
continue;
|
|
4777
|
+
}
|
|
4778
|
+
const resolved = canonicalPath(absoluteTarget);
|
|
4779
|
+
if (isCredentialPath(resolved))
|
|
4780
|
+
add("account.credential");
|
|
4781
|
+
else if (recursive && holdsCredentials(resolved))
|
|
4782
|
+
add("account.credential");
|
|
4783
|
+
}
|
|
4784
|
+
};
|
|
4785
|
+
for (const target of shape.writes)
|
|
4786
|
+
judgeWrite(target);
|
|
4787
|
+
for (const source of shape.reads)
|
|
4788
|
+
judgeRead(source, false);
|
|
4789
|
+
if (bin === null)
|
|
4790
|
+
continue;
|
|
4791
|
+
// `-t DIR` / `--target-directory=DIR` names the destination up front.
|
|
4792
|
+
const targetDirFlag = () => {
|
|
4793
|
+
for (const [at, arg] of shape.args.entries()) {
|
|
4794
|
+
if (arg === "-t")
|
|
4795
|
+
return shape.args[at + 1] ?? null;
|
|
4796
|
+
if (arg.startsWith("--target-directory="))
|
|
4797
|
+
return arg.slice("--target-directory=".length);
|
|
4798
|
+
}
|
|
4799
|
+
return null;
|
|
4800
|
+
};
|
|
4801
|
+
if (WRITE_EVERY_OPERAND.has(bin)) {
|
|
4802
|
+
for (const operand of operands)
|
|
4803
|
+
judgeWrite(operand);
|
|
4804
|
+
}
|
|
4805
|
+
else if (WRITE_LAST_OPERAND.has(bin)) {
|
|
4806
|
+
const targetDir = targetDirFlag();
|
|
4807
|
+
const sources = targetDir === null ? operands.slice(0, -1) : operands.filter((operand) => operand !== targetDir);
|
|
4808
|
+
const destination = targetDir ?? operands[operands.length - 1];
|
|
4809
|
+
for (const source of sources)
|
|
4810
|
+
judgeRead(source, true);
|
|
4811
|
+
if (destination !== undefined) {
|
|
4812
|
+
const expanded = expand(destination);
|
|
4813
|
+
const intoDirectory = targetDir !== null ||
|
|
4814
|
+
destination.endsWith("/") ||
|
|
4815
|
+
(!expanded.unresolved &&
|
|
4816
|
+
cwds.some((from) => isDirectoryPath(canonicalPath(resolvePathSegments(from, expanded.text)))));
|
|
4817
|
+
if (intoDirectory)
|
|
4818
|
+
judgeWriteInto(destination, sources);
|
|
4819
|
+
else
|
|
4820
|
+
judgeWrite(destination);
|
|
4821
|
+
}
|
|
4822
|
+
}
|
|
4823
|
+
else if (WRITE_AFTER_FIRST_OPERAND.has(bin)) {
|
|
4824
|
+
for (const operand of operands.slice(1))
|
|
4825
|
+
judgeWrite(operand);
|
|
4826
|
+
}
|
|
4827
|
+
else if (bin === "sed" && shape.args.some((arg) => arg === "--in-place" || /^-[a-zA-Z]*i/u.test(arg))) {
|
|
4828
|
+
const scripted = shape.args.some((arg) => arg === "-e" || arg === "-f");
|
|
4829
|
+
for (const operand of scripted ? operands : operands.slice(1))
|
|
4830
|
+
judgeWrite(operand);
|
|
4831
|
+
}
|
|
4832
|
+
else if (bin === "dd") {
|
|
4833
|
+
for (const arg of shape.args) {
|
|
4834
|
+
if (arg.startsWith("of="))
|
|
4835
|
+
judgeWrite(arg.slice(3));
|
|
4836
|
+
if (arg.startsWith("if="))
|
|
4837
|
+
judgeRead(arg.slice(3), false);
|
|
4838
|
+
}
|
|
4839
|
+
}
|
|
4840
|
+
else if (bin === "tar" && isTarExtract(shape.args)) {
|
|
4841
|
+
// Lows: an extraction writes the archive's names into `-C DIR` or the
|
|
4842
|
+
// working directory.
|
|
4843
|
+
judgeExtractInto(flagValue(shape.args, "-C", "--directory") ?? ".");
|
|
4844
|
+
}
|
|
4845
|
+
else if (bin === "unzip") {
|
|
4846
|
+
judgeExtractInto(flagValue(shape.args, "-d", null) ?? ".");
|
|
4847
|
+
}
|
|
4848
|
+
if (segment.class.startsWith("read.") || RECURSIVE_READERS.has(bin)) {
|
|
4849
|
+
for (const operand of operands)
|
|
4850
|
+
judgeRead(operand, RECURSIVE_READERS.has(bin));
|
|
4851
|
+
}
|
|
4852
|
+
}
|
|
4853
|
+
return found;
|
|
4854
|
+
}
|
|
4855
|
+
/** `*`, `?` or `[` in a path word. */
|
|
4856
|
+
function hasGlobChars(text) {
|
|
4857
|
+
return /[*?[]/u.test(text);
|
|
4858
|
+
}
|
|
4859
|
+
/** A shell glob component as an anchored regular expression. */
|
|
4860
|
+
function globRegex(component) {
|
|
4861
|
+
let source = "";
|
|
4862
|
+
for (const char of component) {
|
|
4863
|
+
if (char === "*")
|
|
4864
|
+
source += ".*";
|
|
4865
|
+
else if (char === "?")
|
|
4866
|
+
source += ".";
|
|
4867
|
+
else if (char === "[" || char === "]")
|
|
4868
|
+
source += char;
|
|
4869
|
+
else
|
|
4870
|
+
source += char.replace(/[.+^${}()|\\]/gu, "\\$&");
|
|
4871
|
+
}
|
|
4872
|
+
try {
|
|
4873
|
+
return new RegExp(`^${source}$`, "u");
|
|
4874
|
+
}
|
|
4875
|
+
catch {
|
|
4876
|
+
return /^.*$/u;
|
|
4877
|
+
}
|
|
4878
|
+
}
|
|
4879
|
+
/** Names a credential file goes by, for a pattern to be tested against. */
|
|
4880
|
+
const CREDENTIAL_NAMES = [".env", ".env.local", "auth.json", "env", "vault.enc", "keys"];
|
|
4881
|
+
/** Names an organ goes by, beyond the credentials. */
|
|
4882
|
+
const ORGAN_NAMES = [
|
|
4883
|
+
...CREDENTIAL_NAMES,
|
|
4884
|
+
"config.yaml",
|
|
4885
|
+
"config.yml",
|
|
4886
|
+
"shell-hooks-allowlist.json",
|
|
4887
|
+
"shell-hooks-allowlist.json.lock",
|
|
4888
|
+
"agent-hooks",
|
|
4889
|
+
"approval",
|
|
4890
|
+
"scripts",
|
|
4891
|
+
];
|
|
4892
|
+
/** Could some component of `path`'s pattern name the home or the approval home? */
|
|
4893
|
+
function globCouldNameGateHome(path) {
|
|
4894
|
+
return path
|
|
4895
|
+
.split(/[/\\]+/u)
|
|
4896
|
+
.some((component) => hasGlobChars(component) && [".hermes", ".approval"].some((name) => globRegex(component).test(name)));
|
|
4897
|
+
}
|
|
4898
|
+
/** SF3a: a written pattern that could land on an organ. */
|
|
4899
|
+
function globCouldNameOrgan(path) {
|
|
4900
|
+
if (globCouldNameGateHome(path))
|
|
4901
|
+
return true;
|
|
4902
|
+
const last = basename(path);
|
|
4903
|
+
if (!hasGlobChars(last))
|
|
4904
|
+
return false;
|
|
4905
|
+
const pattern = globRegex(last);
|
|
4906
|
+
const parent = dirname(path);
|
|
4907
|
+
const parentIsHome = parent.split(/[/\\]+/u).some((segment) => segment === ".hermes" || segment === ".approval") ||
|
|
4908
|
+
hasGlobChars(parent);
|
|
4909
|
+
return parentIsHome && ORGAN_NAMES.some((name) => pattern.test(name));
|
|
4910
|
+
}
|
|
4911
|
+
/** SF3b: a read pattern whose directory is unknown, that could name a credential. */
|
|
4912
|
+
function globCouldNameCredential(path) {
|
|
4913
|
+
const last = basename(path);
|
|
4914
|
+
if (!hasGlobChars(last))
|
|
4915
|
+
return globCouldNameGateHome(path);
|
|
4916
|
+
const pattern = globRegex(last);
|
|
4917
|
+
return CREDENTIAL_NAMES.some((name) => pattern.test(name)) || globCouldNameGateHome(path);
|
|
4918
|
+
}
|
|
4919
|
+
/** The entries a final-component glob matches, or `null` when the directory is unknown. */
|
|
4920
|
+
function expandGlob(path) {
|
|
4921
|
+
const parent = dirname(path);
|
|
4922
|
+
if (hasGlobChars(parent))
|
|
4923
|
+
return null;
|
|
4924
|
+
try {
|
|
4925
|
+
const pattern = globRegex(basename(path));
|
|
4926
|
+
return readdirSync(canonicalPath(parent))
|
|
4927
|
+
.filter((name) => pattern.test(name))
|
|
4928
|
+
.map((name) => join(parent, name));
|
|
4929
|
+
}
|
|
4930
|
+
catch {
|
|
4931
|
+
return null;
|
|
4932
|
+
}
|
|
4933
|
+
}
|
|
4934
|
+
function isDirectoryPath(path) {
|
|
4935
|
+
try {
|
|
4936
|
+
return statSync(path).isDirectory();
|
|
4937
|
+
}
|
|
4938
|
+
catch {
|
|
4939
|
+
return false;
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
4942
|
+
/**
|
|
4943
|
+
* The gate's own directories (recheck 3, SF1): a Hermes home (or profile home)
|
|
4944
|
+
* root, an approval home, a home's `approval/`, and its `scripts/`. Everything
|
|
4945
|
+
* else under a home (`workspace/`, `skills/`, `memories/`, `sessions/`) is
|
|
4946
|
+
* ordinary work, which matters because a hosted image runs ALL agent work under
|
|
4947
|
+
* `$HERMES_HOME`.
|
|
4948
|
+
*/
|
|
4949
|
+
function gateRootKind(directory) {
|
|
4950
|
+
const segments = directory.split(/[/\\]+/u).filter((segment) => segment.length > 0);
|
|
4951
|
+
const last = segments[segments.length - 1];
|
|
4952
|
+
const isHome = (at) => segments[at] === ".hermes" ||
|
|
4953
|
+
(segments[at - 2] === ".hermes" && segments[at - 1] === "profiles" && segments[at] !== undefined);
|
|
4954
|
+
if (last === undefined)
|
|
4955
|
+
return null;
|
|
4956
|
+
if (isHome(segments.length - 1))
|
|
4957
|
+
return "home";
|
|
4958
|
+
if (last === ".approval")
|
|
4959
|
+
return "approval-home";
|
|
4960
|
+
if (last === "approval" && isHome(segments.length - 2))
|
|
4961
|
+
return "home-approval";
|
|
4962
|
+
if (last === "scripts" && isHome(segments.length - 2))
|
|
4963
|
+
return "scripts";
|
|
4964
|
+
return null;
|
|
4965
|
+
}
|
|
4966
|
+
/** A glob or an unknown name written here may be an organ (SF1b). */
|
|
4967
|
+
function writesIntoGateRoot(directory) {
|
|
4968
|
+
return gateRootKind(directory) !== null;
|
|
4969
|
+
}
|
|
4970
|
+
/**
|
|
4971
|
+
* A directory a recursive read of which reads credentials (SF1a): the home
|
|
4972
|
+
* root, the approval home, a home's `approval/`, and any directory under a home
|
|
4973
|
+
* that directly holds a credential file.
|
|
4974
|
+
*/
|
|
4975
|
+
function holdsCredentials(directory) {
|
|
4976
|
+
if (!isDirectoryPath(directory))
|
|
4977
|
+
return false;
|
|
4978
|
+
const kind = gateRootKind(directory);
|
|
4979
|
+
if (kind === "home" || kind === "approval-home" || kind === "home-approval")
|
|
4980
|
+
return true;
|
|
4981
|
+
if (!directory.split(/[/\\]+/u).some((segment) => segment === ".hermes" || segment === ".approval"))
|
|
4982
|
+
return false;
|
|
4983
|
+
return CREDENTIAL_FILE_NAMES.some((name) => existsSync(join(directory, name)));
|
|
4984
|
+
}
|
|
4985
|
+
const CREDENTIAL_FILE_NAMES = [
|
|
4986
|
+
".env",
|
|
4987
|
+
"auth.json",
|
|
4988
|
+
"config.yaml",
|
|
4989
|
+
"config.yml",
|
|
4990
|
+
"shell-hooks-allowlist.json",
|
|
4991
|
+
"shell-hooks-allowlist.json.lock",
|
|
4992
|
+
];
|
|
4993
|
+
function isTarExtract(args) {
|
|
4994
|
+
const first = args[0] ?? "";
|
|
4995
|
+
return args.some((arg) => arg === "--extract" || arg === "--get" || /^-[a-zA-Z]*x/u.test(arg)) ||
|
|
4996
|
+
(!first.startsWith("-") && first.includes("x"));
|
|
4997
|
+
}
|
|
4998
|
+
function flagValue(args, short, long) {
|
|
4999
|
+
for (const [at, arg] of args.entries()) {
|
|
5000
|
+
if (arg === short)
|
|
5001
|
+
return args[at + 1] ?? null;
|
|
5002
|
+
if (arg.startsWith(short) && arg.length > short.length && !arg.startsWith("--"))
|
|
5003
|
+
return arg.slice(short.length);
|
|
5004
|
+
if (long !== null && arg.startsWith(`${long}=`))
|
|
5005
|
+
return arg.slice(long.length + 1);
|
|
5006
|
+
if (long !== null && arg === long)
|
|
5007
|
+
return args[at + 1] ?? null;
|
|
5008
|
+
}
|
|
5009
|
+
return null;
|
|
5010
|
+
}
|
|
5011
|
+
/** Binaries every operand of which is a path they create, change or remove. */
|
|
5012
|
+
const WRITE_EVERY_OPERAND = new Set([
|
|
5013
|
+
"touch", "truncate", "mkdir", "rm", "rmdir", "shred", "tee", "unlink",
|
|
5014
|
+
]);
|
|
5015
|
+
/** Binaries whose LAST operand is the destination and the rest are sources. */
|
|
5016
|
+
const WRITE_LAST_OPERAND = new Set(["cp", "mv", "ln", "install", "rsync"]);
|
|
5017
|
+
/** Binaries whose first operand is a mode or owner and the rest are paths. */
|
|
5018
|
+
const WRITE_AFTER_FIRST_OPERAND = new Set(["chmod", "chown", "chgrp"]);
|
|
5019
|
+
/** Readers that walk a directory they are handed. */
|
|
5020
|
+
const RECURSIVE_READERS = new Set([
|
|
5021
|
+
"grep", "egrep", "fgrep", "rg", "ag", "ack", "find", "tar", "zip", "du", "tree",
|
|
5022
|
+
]);
|
|
5023
|
+
/**
|
|
5024
|
+
* `path` resolved through the realpath of its deepest existing ancestor, so a
|
|
5025
|
+
* symlinked parent is judged by where it lands and a path that does not exist
|
|
5026
|
+
* yet is judged by where it would be created (APRV-445 recheck SF2(e)).
|
|
5027
|
+
*/
|
|
5028
|
+
function canonicalPath(path) {
|
|
5029
|
+
let current = path;
|
|
5030
|
+
const tail = [];
|
|
5031
|
+
for (;;) {
|
|
5032
|
+
try {
|
|
5033
|
+
return resolvePathSegments(realpathSync(current), ...tail.reverse());
|
|
5034
|
+
}
|
|
5035
|
+
catch {
|
|
5036
|
+
const parent = dirname(current);
|
|
5037
|
+
if (parent === current)
|
|
5038
|
+
return resolvePathSegments(path);
|
|
5039
|
+
tail.push(basename(current));
|
|
5040
|
+
current = parent;
|
|
5041
|
+
}
|
|
5042
|
+
}
|
|
5043
|
+
}
|
|
3081
5044
|
/** The environment variable that turns the sandbox requirement on (APRV-193). */
|
|
3082
5045
|
export const REQUIRE_SANDBOX_ENV = "APPROVAL_HOOK_REQUIRE_SANDBOX";
|
|
3083
5046
|
/**
|
|
@@ -3192,7 +5155,7 @@ function bypassBanner(window, classes, seq) {
|
|
|
3192
5155
|
* command that ran and left no record is the one state this feature must not be
|
|
3193
5156
|
* able to reach, so an append failure is a deny.
|
|
3194
5157
|
*/
|
|
3195
|
-
function runBypass(streams, input, adapter, cwd, logPath, flags, actor, window,
|
|
5158
|
+
function* runBypass(streams, input, adapter, cwd, logPath, flags, actor, window,
|
|
3196
5159
|
/**
|
|
3197
5160
|
* The verified read `window` was derived from (APRV-294), handed on to the
|
|
3198
5161
|
* append so the same records answer "is a window open" and "which head does
|
|
@@ -3252,7 +5215,12 @@ decidedOn) {
|
|
|
3252
5215
|
// window path is the one a human comes back to read, so the binary that
|
|
3253
5216
|
// printed the allow is named on it.
|
|
3254
5217
|
const provenance = harnessProvenance(adapter.kind, input.harnessVersion);
|
|
3255
|
-
|
|
5218
|
+
// APRV-473 refuter: the bypass record is this path's authorization, exactly
|
|
5219
|
+
// as `execution.started` is the gated path's, so a signal held through the
|
|
5220
|
+
// stdin read is dispatched before it is written (`BEFORE_SPEND`, taken
|
|
5221
|
+
// inside `spendUnderLock`, after any wait for the log's lock, which yields:
|
|
5222
|
+
// APRV-478).
|
|
5223
|
+
const recorded = yield* spendUnderLock(logPath, undefined, (append, round) => recordGateBypass(logPath, {
|
|
3256
5224
|
tool: input.toolName,
|
|
3257
5225
|
summary: truncate(described.headline, SUMMARY_LIMIT),
|
|
3258
5226
|
classes,
|
|
@@ -3261,14 +5229,17 @@ decidedOn) {
|
|
|
3261
5229
|
...(input.toolUseId === null ? {} : { toolUseId: input.toolUseId }),
|
|
3262
5230
|
...(input.cwd.length === 0 ? {} : { cwd: input.cwd }),
|
|
3263
5231
|
...(provenance === null ? {} : { harness: provenance }),
|
|
3264
|
-
}, actor, {},
|
|
3265
|
-
// APRV-294: the window this verdict was decided under, and the read it
|
|
3266
|
-
// decided on. The append uses both, so a window that ended in
|
|
3267
|
-
// reported as the thing that happened rather than as "no
|
|
5232
|
+
}, actor, { append },
|
|
5233
|
+
// APRV-294: the window this verdict was decided under, and the read it
|
|
5234
|
+
// was decided on. The append uses both, so a window that ended in
|
|
5235
|
+
// between is reported as the thing that happened rather than as "no
|
|
5236
|
+
// window is open". The read seeds the FIRST round only: a later round
|
|
5237
|
+
// exists because another writer took the lock, so that read's head is
|
|
5238
|
+
// already known to be stale, and `recordGateBypass` re-reads.
|
|
3268
5239
|
{
|
|
3269
5240
|
openedSeq: window.seq,
|
|
3270
|
-
...(decidedOn === null ? {} : { read: decidedOn }),
|
|
3271
|
-
});
|
|
5241
|
+
...(decidedOn === null || round > 1 ? {} : { read: decidedOn }),
|
|
5242
|
+
}));
|
|
3272
5243
|
if (!recorded.ok) {
|
|
3273
5244
|
// Invariant 8: the record lands before the allow, so a refusal here is a
|
|
3274
5245
|
// deny even though a window is open. `append-failed` reaches the caller
|
|
@@ -3281,8 +5252,13 @@ decidedOn) {
|
|
|
3281
5252
|
const notes = [...described.notes, ...(policyNote === null ? [] : [policyNote])];
|
|
3282
5253
|
return allow(streams, `gate-open: ${classes.join(", ")} bypassed by the window opened at seq ${String(window.seq)} by ${window.openedBy} (expires ${window.expiresAt}); recorded as gate.bypassed seq ${String(recorded.record.seq)}${notes.length === 0 ? "" : ` (${notes.join("; ")})`}`, adapter.kind, codexCommand);
|
|
3283
5254
|
}
|
|
3284
|
-
function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
3285
|
-
|
|
5255
|
+
function* runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
5256
|
+
// Codex and (APRV-445) Hermes answer a misconfigured entry with a verdict:
|
|
5257
|
+
// on both a usage error's empty stdout is a harness that runs the call, and
|
|
5258
|
+
// on Hermes a non-zero exit with an empty stdout is an allow outright.
|
|
5259
|
+
const configurationError = (message) => adapter.kind === "codex" || adapter.kind === "hermes"
|
|
5260
|
+
? deny(streams, "hook-io", message, adapter.kind)
|
|
5261
|
+
: usageError(streams, message);
|
|
3286
5262
|
const parsed = parseFlags(argv, {
|
|
3287
5263
|
...COMMON_FLAGS,
|
|
3288
5264
|
...POLICY_FLAGS,
|
|
@@ -3291,6 +5267,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
3291
5267
|
"--timeout": "string",
|
|
3292
5268
|
"--interval": "string",
|
|
3293
5269
|
"--retry-grace": "string",
|
|
5270
|
+
"--harness-cap": "string",
|
|
3294
5271
|
});
|
|
3295
5272
|
if (!parsed.ok)
|
|
3296
5273
|
return configurationError(parsed.message);
|
|
@@ -3301,11 +5278,19 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
3301
5278
|
// Muse gets its own for the same reason and one more: its `.muse/hooks.json`
|
|
3302
5279
|
// shape is not Claude's file under a different name, and an operator who
|
|
3303
5280
|
// guessed would get "Hooks: 0 runnable" and a session that looks gated.
|
|
5281
|
+
// Hermes gets its own for a third reason on top of those two (APRV-398):
|
|
5282
|
+
// the file the human commits is YAML, it is not in the repository at all
|
|
5283
|
+
// (`$HERMES_HOME/config.yaml`), and the entry carries the one key that
|
|
5284
|
+
// decides whether this adapter is a gate or a backstop — `fail_closed`. A
|
|
5285
|
+
// reader who copied a JSON block from another harness's help would install
|
|
5286
|
+
// nothing.
|
|
3304
5287
|
const harnessHelp = adapter.kind === "grok"
|
|
3305
5288
|
? HOOK_GROK_HELP
|
|
3306
5289
|
: adapter.kind === "muse"
|
|
3307
5290
|
? HOOK_MUSE_HELP
|
|
3308
|
-
:
|
|
5291
|
+
: adapter.kind === "hermes"
|
|
5292
|
+
? HOOK_HERMES_HELP
|
|
5293
|
+
: HOOK_HELP;
|
|
3309
5294
|
streams.out(`${harnessHelp}\n`);
|
|
3310
5295
|
return EXIT_OK;
|
|
3311
5296
|
}
|
|
@@ -3336,6 +5321,35 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
3336
5321
|
if (graceMs === null) {
|
|
3337
5322
|
return configurationError(`--retry-grace expects a duration like 5m, 30s, 1ms, got ${JSON.stringify(graceText)}`);
|
|
3338
5323
|
}
|
|
5324
|
+
// APRV-423. The ceiling this process runs under, which the hook cannot
|
|
5325
|
+
// discover for itself: the harness spawns it and tells it nothing. The
|
|
5326
|
+
// OPERATOR states the entry timeout they configured, and the adapter
|
|
5327
|
+
// contributes the harness's own documented maximum where its contract has
|
|
5328
|
+
// one. The smaller wins, because a stated cap above a contractual ceiling is
|
|
5329
|
+
// a statement the harness will not honour, and a cap may only ever shorten
|
|
5330
|
+
// the window (SPEC.md §11.1 invariant 4).
|
|
5331
|
+
const capText = stringFlag(parsed.flags, "--harness-cap");
|
|
5332
|
+
const statedCapMs = capText === null ? null : parseDuration(capText);
|
|
5333
|
+
if (capText !== null && statedCapMs === null) {
|
|
5334
|
+
return configurationError(`--harness-cap expects a duration like 300s, 4m, got ${JSON.stringify(capText)}`);
|
|
5335
|
+
}
|
|
5336
|
+
// Second review pass. A stated cap is clamped to the contractual ceiling
|
|
5337
|
+
// (Hermes will not honour an entry above 300 s however it is written); an
|
|
5338
|
+
// absent flag takes the harness's DEFAULT bound where the adapter documents
|
|
5339
|
+
// one, and only then falls back to the ceiling. The default is the stricter
|
|
5340
|
+
// number on purpose: an operator who has said nothing is assumed to run the
|
|
5341
|
+
// harness as shipped, and a runtime that assumed the ceiling instead would
|
|
5342
|
+
// assert a deadline the harness will not keep (APRV-410 with a fictional
|
|
5343
|
+
// T+240 s, which is exactly what the first pass did on a default Hermes).
|
|
5344
|
+
const capMs = effectiveHarnessCapMs(adapter, statedCapMs);
|
|
5345
|
+
// APRV-427 review: charged for the time the call spent before this line,
|
|
5346
|
+
// using the remainder the embedding caller admitted it on, unchanged. See
|
|
5347
|
+
// HookWaitSeam.remainingCapMs.
|
|
5348
|
+
const remainingCapMs = waitSeam?.remainingCapMs;
|
|
5349
|
+
const harnessCapMs = capMs === null || remainingCapMs === undefined
|
|
5350
|
+
? capMs
|
|
5351
|
+
: Math.max(0, Math.min(capMs, remainingCapMs));
|
|
5352
|
+
const harnessCapStated = statedCapMs !== null;
|
|
3339
5353
|
const parsedInput = parseHookInput(readStdin(), adapter.camelCaseEnvelope === true);
|
|
3340
5354
|
if (!parsedInput.ok)
|
|
3341
5355
|
return deny(streams, "hook-io", parsedInput.detail, adapter.kind);
|
|
@@ -3376,7 +5390,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
3376
5390
|
if (adapter.contributorModelGuard === true) {
|
|
3377
5391
|
const refusal = contributorModelRefusal(input.model);
|
|
3378
5392
|
if (refusal !== null) {
|
|
3379
|
-
const postEvent =
|
|
5393
|
+
const postEvent = isPostToolEvent(adapter, input.hookEventName);
|
|
3380
5394
|
return postEvent
|
|
3381
5395
|
? report(streams, MUSE_CONTRIBUTOR_REFUSAL, `${refusal} Nothing was appended.`)
|
|
3382
5396
|
: deny(streams, MUSE_CONTRIBUTOR_REFUSAL, refusal, adapter.kind);
|
|
@@ -3389,7 +5403,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
3389
5403
|
// fall through, so the verdict is one dialect and nothing else, which is the
|
|
3390
5404
|
// only shape this harness parses.
|
|
3391
5405
|
if (adapter.passThroughTools?.includes(input.toolName) === true) {
|
|
3392
|
-
const passPostEvent =
|
|
5406
|
+
const passPostEvent = isPostToolEvent(adapter, input.hookEventName);
|
|
3393
5407
|
if (passPostEvent) {
|
|
3394
5408
|
return report(streams, "post-tool-not-gated", `${input.toolName} is the harness's own bookkeeping tool, so no execution.started was ever written for it`);
|
|
3395
5409
|
}
|
|
@@ -3405,9 +5419,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
3405
5419
|
// harness whose event this runtime does not recognize is a harness about to
|
|
3406
5420
|
// run a command, and treating an unknown name as a no-op would be an ungated
|
|
3407
5421
|
// one.
|
|
3408
|
-
const postToolEvent = adapter.
|
|
3409
|
-
? input.hookEventName === CODEX_POST_TOOL_EVENT
|
|
3410
|
-
: input.hookEventName !== null && POST_TOOL_EVENTS.includes(input.hookEventName);
|
|
5422
|
+
const postToolEvent = isPostToolEvent(adapter, input.hookEventName);
|
|
3411
5423
|
if (postToolEvent) {
|
|
3412
5424
|
// APRV-303. `commandHarnessHook`'s catch turns a throw into a DENY, which is
|
|
3413
5425
|
// the right answer for a call that has not run yet and exactly the wrong one
|
|
@@ -3422,7 +5434,15 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
3422
5434
|
// path therefore always exits 0. The machine-readable line still goes to
|
|
3423
5435
|
// stderr; whether Grok shows it is Grok's business, and losing a debug line
|
|
3424
5436
|
// is a smaller harm than emitting a verdict the protocol will act on.
|
|
3425
|
-
|
|
5437
|
+
//
|
|
5438
|
+
// APRV-398. Hermes joins Grok on that rule, and for the same reason read off
|
|
5439
|
+
// its own contract: Hermes documents exit 2 as BLOCKING, so a visibility
|
|
5440
|
+
// exit here would be a block aimed at a tool call that has already run.
|
|
5441
|
+
// This adapter's whole stance on this harness is that the exit status
|
|
5442
|
+
// carries no verdict (see `HERMES_DENY_EXIT`), and that has to hold on both
|
|
5443
|
+
// halves or it holds nowhere. The machine-readable line still goes to
|
|
5444
|
+
// stderr, and whether Hermes shows it is Hermes's business.
|
|
5445
|
+
const settle = (code) => adapter.kind === "grok" || adapter.kind === "hermes" ? EXIT_OK : code;
|
|
3426
5446
|
try {
|
|
3427
5447
|
return settle(runPostToolUse(parsed.flags, streams, cwd, input, actor, adapter));
|
|
3428
5448
|
}
|
|
@@ -3439,7 +5459,46 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
3439
5459
|
if (adapter.kind === "codex" && input.toolName === "Bash") {
|
|
3440
5460
|
return deny(streams, "hook-unsupported-execution-context", "Codex Bash is disabled because the native hook contract does not expose the effective per-call working directory; no policy or open window can authorize bytes the hook cannot bind", adapter.kind);
|
|
3441
5461
|
}
|
|
3442
|
-
|
|
5462
|
+
// APRV-398, and placed exactly where the Codex refusal above is placed, for
|
|
5463
|
+
// exactly its reason: before the open window, the gate-self path, the carry
|
|
5464
|
+
// and the registration, because none of those can supply a fact the call does
|
|
5465
|
+
// not carry. Hermes's `execute_code` takes a `code` string and nothing else —
|
|
5466
|
+
// no path, no argv, no working directory — so there is nothing for the
|
|
5467
|
+
// classifier to read and nothing a payload could bind. A verdict over
|
|
5468
|
+
// `{code}` would authorize a program this runtime never parsed.
|
|
5469
|
+
//
|
|
5470
|
+
// It is refused rather than classified `hook-opaque` because the repair
|
|
5471
|
+
// differs: an opaque command line can be rewritten, and there is no spelling
|
|
5472
|
+
// of an `execute_code` call that this hook could answer differently. Nothing
|
|
5473
|
+
// appends on this path and no gate lifecycle opens. An OPEN WINDOW does not
|
|
5474
|
+
// reach it either, which is the strict reading a window deserves here: a
|
|
5475
|
+
// window suspends the policy, and this refusal is not a policy question.
|
|
5476
|
+
if (adapter.kind === "hermes" && input.toolName === HERMES_EXECUTE_CODE_TOOL) {
|
|
5477
|
+
return deny(streams, HERMES_EXECUTE_CODE_REFUSAL, `Hermes ${HERMES_EXECUTE_CODE_TOOL} is refused because the call carries a program and nothing else: no path, no argv and no working directory, so no class can be resolved and no payload can bind what it would do. Run the work through the \`${adapter.shellTool}\` tool, where the words are visible to the classifier, or through \`approval run\` with a granted token. No policy or open window authorizes bytes the hook cannot read`, adapter.kind);
|
|
5478
|
+
}
|
|
5479
|
+
// APRV-415, and placed here for the reason the two refusals above are: before
|
|
5480
|
+
// the open window, the gate-self path, the carry and the registration, because
|
|
5481
|
+
// none of those can supply the directory a Hermes call leaves out. The live
|
|
5482
|
+
// probe established that this harness reports NO effective directory for a
|
|
5483
|
+
// call that does not state one — see {@link hermesUnboundDirectory} — so the
|
|
5484
|
+
// refusal is the Codex `Bash` refusal one harness along, narrowed to the calls
|
|
5485
|
+
// that actually withhold the fact.
|
|
5486
|
+
if (adapter.kind === "hermes") {
|
|
5487
|
+
const unbound = hermesUnboundDirectory(input, adapter);
|
|
5488
|
+
if (unbound !== null) {
|
|
5489
|
+
return deny(streams, "hook-unsupported-execution-context", unbound, adapter.kind);
|
|
5490
|
+
}
|
|
5491
|
+
}
|
|
5492
|
+
// APRV-445: a tool the adapter's own rule table gates goes on to the gated
|
|
5493
|
+
// path below; one it reads is allowed here, with no policy load, exactly as
|
|
5494
|
+
// an in-scope read is.
|
|
5495
|
+
const ruledEarly = toolRuleOf(adapter, input);
|
|
5496
|
+
if (ruledEarly?.kind === "read") {
|
|
5497
|
+
return allow(streams, ruledEarly.reason, adapter.kind, codexCommand);
|
|
5498
|
+
}
|
|
5499
|
+
if (ruledEarly === null &&
|
|
5500
|
+
input.toolName !== adapter.shellTool &&
|
|
5501
|
+
!adapter.fileTools.includes(input.toolName)) {
|
|
3443
5502
|
if (!adapter.readTools.includes(input.toolName)) {
|
|
3444
5503
|
return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
|
|
3445
5504
|
}
|
|
@@ -3460,7 +5519,8 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
3460
5519
|
// spawn, provided the hook is configured with `--dir` as the docs show
|
|
3461
5520
|
// (otherwise `hookScope` runs `git rev-parse` to find the primary).
|
|
3462
5521
|
const early = hookScope(parsed.flags, cwd);
|
|
3463
|
-
if (
|
|
5522
|
+
if (credentialReadGate(input.toolName, input.toolInput, cwd) === null &&
|
|
5523
|
+
readToolGate(input.toolName, input.toolInput, resolveReadRoots(cwd, early.root), cwd) === null) {
|
|
3464
5524
|
return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
|
|
3465
5525
|
}
|
|
3466
5526
|
}
|
|
@@ -3493,13 +5553,13 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
3493
5553
|
// words. The window suspends the POLICY; it never suspends the log.
|
|
3494
5554
|
const looked = lookupWindow(logPath);
|
|
3495
5555
|
if (looked.window !== null) {
|
|
3496
|
-
return runBypass(streams, input, adapter, cwd, logPath, parsed.flags, actor, looked.window,
|
|
5556
|
+
return yield* runBypass(streams, input, adapter, cwd, logPath, parsed.flags, actor, looked.window,
|
|
3497
5557
|
// APRV-294. The records this window was derived from travel with it: the
|
|
3498
5558
|
// bypass record is appended against the head they ended at, so the
|
|
3499
5559
|
// verdict and the record are one read of the log.
|
|
3500
5560
|
looked.records === null ? null : { records: looked.records, head: looked.head });
|
|
3501
5561
|
}
|
|
3502
|
-
return renderVerdict(streams, adapter, codexCommand,
|
|
5562
|
+
return renderVerdict(streams, adapter, codexCommand, yield* decideHarnessSteps({
|
|
3503
5563
|
streams,
|
|
3504
5564
|
input,
|
|
3505
5565
|
adapter,
|
|
@@ -3511,8 +5571,14 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
3511
5571
|
timeoutMs,
|
|
3512
5572
|
intervalMs,
|
|
3513
5573
|
graceMs,
|
|
5574
|
+
harnessCapMs,
|
|
5575
|
+
harnessCapStated,
|
|
5576
|
+
// The ceiling before the time in line was charged, for the refusal's
|
|
5577
|
+
// wording only: the charged number is the one everything is judged by.
|
|
5578
|
+
...(harnessCapMs !== capMs && capMs !== null ? { harnessCapCeilingMs: capMs } : {}),
|
|
3514
5579
|
codexCommand,
|
|
3515
5580
|
windowRecords: looked.records,
|
|
5581
|
+
waitSeam,
|
|
3516
5582
|
}));
|
|
3517
5583
|
}
|
|
3518
5584
|
/**
|
|
@@ -3532,7 +5598,14 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
3532
5598
|
* progress and withdrawal lines, which are a report rather than a decision.
|
|
3533
5599
|
*/
|
|
3534
5600
|
export function decideHarnessCall(decide) {
|
|
5601
|
+
return driveSync(decideHarnessSteps(decide));
|
|
5602
|
+
}
|
|
5603
|
+
/** {@link decideHarnessCall} as steps, for a driver that yields (APRV-473). */
|
|
5604
|
+
function* decideHarnessSteps(decide) {
|
|
3535
5605
|
const { streams, input, adapter, cwd, logPath, root, options, actor, timeoutMs, intervalMs, graceMs, codexCommand, windowRecords, } = decide;
|
|
5606
|
+
const harnessCapMs = decide.harnessCapMs ?? null;
|
|
5607
|
+
const harnessCapStated = decide.harnessCapStated ?? false;
|
|
5608
|
+
const harnessCapCeilingMs = decide.harnessCapCeilingMs ?? null;
|
|
3536
5609
|
// The policy is read BEFORE the command is classified (APRV-107): the
|
|
3537
5610
|
// protected-path set is built-ins plus `policy.protected_paths`, so what
|
|
3538
5611
|
// counts as a protected path is a policy question and the classifier cannot be
|
|
@@ -3595,6 +5668,7 @@ export function decideHarnessCall(decide) {
|
|
|
3595
5668
|
? codexBinding(input, cwd).task
|
|
3596
5669
|
: `hook:${input.sessionId}:${input.toolUseId ?? randomBytes(8).toString("hex")}`;
|
|
3597
5670
|
const run = {
|
|
5671
|
+
waitSeam: decide.waitSeam ?? null,
|
|
3598
5672
|
logPath,
|
|
3599
5673
|
options,
|
|
3600
5674
|
actor,
|
|
@@ -3602,6 +5676,13 @@ export function decideHarnessCall(decide) {
|
|
|
3602
5676
|
intervalMs,
|
|
3603
5677
|
graceMs,
|
|
3604
5678
|
ttlMs: load.durations.approvalTtlMs,
|
|
5679
|
+
harnessCapMs,
|
|
5680
|
+
harnessCapStated,
|
|
5681
|
+
harnessCapCeilingMs,
|
|
5682
|
+
harnessCapRepair: adapter.capRepair ?? null,
|
|
5683
|
+
waitMs: harnessCapMs === null
|
|
5684
|
+
? timeoutMs
|
|
5685
|
+
: Math.max(0, Math.min(timeoutMs, harnessCapMs - HARNESS_CAP_MARGIN_MS)),
|
|
3605
5686
|
harness: adapter.kind,
|
|
3606
5687
|
originApp: adapter.originApp,
|
|
3607
5688
|
...(codexCommand === undefined ? {} : { codexCommand }),
|
|
@@ -3732,12 +5813,41 @@ export function decideHarnessCall(decide) {
|
|
|
3732
5813
|
}
|
|
3733
5814
|
}
|
|
3734
5815
|
if (floor === null && autonomies.every((autonomy) => autonomy === "autonomous")) {
|
|
5816
|
+
// APRV-410. The sweep that takes back this actor's abandoned questions used
|
|
5817
|
+
// to live only in `gateHarnessCall`, which an autonomous command never
|
|
5818
|
+
// reaches — so a session whose retry classified autonomously left its
|
|
5819
|
+
// expired question standing until the TTL, and a human's tap on it six
|
|
5820
|
+
// minutes past the grace was recorded as a grant nobody held (log seq
|
|
5821
|
+
// 64473, 2026-09-20). The withdrawal has to be made by the REQUESTER, which
|
|
5822
|
+
// is this actor: `withdraw` refuses a `system:` actor in as many words, and
|
|
5823
|
+
// the event schema's cross-rule binds a `system:` withdrawal to
|
|
5824
|
+
// `policy-drift` alone (APRV-235), so the daemon is not a candidate and the
|
|
5825
|
+
// next invocation of the asking actor is the only one there is.
|
|
5826
|
+
//
|
|
5827
|
+
// `windowRecords` is the verified read `lookupWindow` already performed on
|
|
5828
|
+
// this invocation, so the sweep costs one append when there is something to
|
|
5829
|
+
// take back and nothing at all when there is not. `withdraw` re-reads and
|
|
5830
|
+
// compare-and-appends against its own head (SPEC.md §11.1 invariant 5), so
|
|
5831
|
+
// a question decided between that read and this call refuses
|
|
5832
|
+
// `already-decided` and is passed over rather than overwritten.
|
|
5833
|
+
//
|
|
5834
|
+
// `keepHash` is this invocation's own bytes, exactly as the intake sweep
|
|
5835
|
+
// passes it: a sibling hook process waiting on a question about the same
|
|
5836
|
+
// payload is never taken back from under it.
|
|
5837
|
+
if (windowRecords !== null) {
|
|
5838
|
+
const swept = withdrawAbandoned(run, streams, windowRecords, new Date().toISOString(), payloadHash(payload));
|
|
5839
|
+
if (swept.length > 0) {
|
|
5840
|
+
streams.err(`approval: withdrew ${String(swept.length)} abandoned harness request(s) nothing retried (${swept.join(", ")}); a tap on one of them now authorizes nothing\n`);
|
|
5841
|
+
}
|
|
5842
|
+
}
|
|
3735
5843
|
// No approval lifecycle: an autonomous action has none (amended SPEC.md
|
|
3736
5844
|
// §6.3), so nothing is requested, decided or granted here. What IS appended
|
|
3737
5845
|
// since APRV-141 is the execution record itself — the moment the policy
|
|
3738
5846
|
// authorized this command — because a budget the busiest path does not
|
|
3739
|
-
// charge is not a budget. See `recordUnattended`.
|
|
3740
|
-
|
|
5847
|
+
// charge is not a budget. See `recordUnattended`. A held signal is
|
|
5848
|
+
// dispatched first, at the pause it takes before the append (APRV-473,
|
|
5849
|
+
// `BEFORE_SPEND`), and a wait for the log's lock yields (APRV-478).
|
|
5850
|
+
const charged = yield* recordUnattended(run, task, classes, payloadHash(payload));
|
|
3741
5851
|
if (charged !== null) {
|
|
3742
5852
|
return {
|
|
3743
5853
|
permission: "deny",
|
|
@@ -3753,11 +5863,14 @@ export function decideHarnessCall(decide) {
|
|
|
3753
5863
|
// do not survive a merge. An initialized-but-empty `.approval/log/` counts as
|
|
3754
5864
|
// reachable — an audit trail that has recorded nothing is an empty log, not a
|
|
3755
5865
|
// missing one (see `preflightLog`) — and `register` appends the first line.
|
|
3756
|
-
return
|
|
5866
|
+
return yield* gateHarnessSteps(streams, run, classes, payload, headline, task, note, floor);
|
|
3757
5867
|
}
|
|
3758
|
-
function commandHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
5868
|
+
function* commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
|
|
5869
|
+
if (adapter.kind === "hermes") {
|
|
5870
|
+
return yield* hermesFailClosed(argv, streams, cwd, readStdin, adapter, waitSeam);
|
|
5871
|
+
}
|
|
3759
5872
|
try {
|
|
3760
|
-
return runHarnessHook(argv, streams, cwd, readStdin, adapter);
|
|
5873
|
+
return yield* runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam);
|
|
3761
5874
|
}
|
|
3762
5875
|
catch (cause) {
|
|
3763
5876
|
// A hook that throws is a hook the harness treats as a non-blocking error,
|
|
@@ -3767,6 +5880,145 @@ function commandHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
3767
5880
|
return deny(streams, "hook-io", `the hook failed: ${cause instanceof Error ? cause.message : String(cause)}`, adapter.kind);
|
|
3768
5881
|
}
|
|
3769
5882
|
}
|
|
5883
|
+
/**
|
|
5884
|
+
* The Hermes hook's last line of defence (APRV-445): whatever happened inside,
|
|
5885
|
+
* a `pre_tool_call` answer leaves this function as a verdict Hermes blocks on or
|
|
5886
|
+
* as an explicit allow, and never as anything Hermes would read as an allow by
|
|
5887
|
+
* default.
|
|
5888
|
+
*
|
|
5889
|
+
* Hermes (v2026.9.24, `agent/shell_hooks.py`) blocks on exit 2 whatever stdout
|
|
5890
|
+
* says, and blocks on any other non-zero exit only when stdout carries a block
|
|
5891
|
+
* directive; ANY OTHER NON-ZERO EXIT WITH AN EMPTY STDOUT IS AN ALLOW, with a
|
|
5892
|
+
* warning in a log nobody reads. So this enforces two rules on every path:
|
|
5893
|
+
*
|
|
5894
|
+
* - a non-zero exit always carries the `{action:"block"}` directive on stdout
|
|
5895
|
+
* and is always exactly 2;
|
|
5896
|
+
* - exit 0 on a pre-event is only ever the adapter's own `{}` allow. A path that
|
|
5897
|
+
* returned 0 having printed nothing is a path nobody wrote a verdict for, and
|
|
5898
|
+
* it blocks.
|
|
5899
|
+
*
|
|
5900
|
+
* A post-event is untouched: it never blocks (see the post path), and it prints
|
|
5901
|
+
* nothing on stdout by design. A throw becomes the same block the other
|
|
5902
|
+
* adapters' catch produces.
|
|
5903
|
+
*/
|
|
5904
|
+
function* hermesFailClosed(argv, streams, cwd, readStdin, adapter,
|
|
5905
|
+
/** APRV-427: serve's wait seam, passed through untouched (null on the CLI). */
|
|
5906
|
+
waitSeam) {
|
|
5907
|
+
let stdout = "";
|
|
5908
|
+
const tracked = {
|
|
5909
|
+
out: (text) => {
|
|
5910
|
+
stdout += text;
|
|
5911
|
+
streams.out(text);
|
|
5912
|
+
},
|
|
5913
|
+
err: (text) => streams.err(text),
|
|
5914
|
+
};
|
|
5915
|
+
// The post-event question is answered from the raw input, once, before the
|
|
5916
|
+
// run: the run itself may be what fails to read it.
|
|
5917
|
+
let raw = null;
|
|
5918
|
+
const reading = () => {
|
|
5919
|
+
raw ??= readStdin();
|
|
5920
|
+
return raw;
|
|
5921
|
+
};
|
|
5922
|
+
// A signal before the wait installs its own handler. The default disposition
|
|
5923
|
+
// of SIGTERM and SIGINT is to die with nothing on stdout, which Hermes reads
|
|
5924
|
+
// as an allow, and the wait's handler (which withdraws and prints
|
|
5925
|
+
// `hook-interrupted`) exists only from the moment the hook starts waiting.
|
|
5926
|
+
// This guard covers the whole run before and after that stretch; the wait's
|
|
5927
|
+
// handler is prepended, so inside the wait it answers first and exits. CLI
|
|
5928
|
+
// only: under `approval serve` (a wait seam) the process's signals belong to
|
|
5929
|
+
// the server.
|
|
5930
|
+
//
|
|
5931
|
+
// A JS listener runs only when the event loop turns (APRV-466). On the CLI
|
|
5932
|
+
// this run is driven by `driveYielding` (APRV-473), so the loop turns at
|
|
5933
|
+
// every pause: between the wait's polls, where the wait's handler answers,
|
|
5934
|
+
// and once before anything appends `execution.started` (`BEFORE_SPEND`),
|
|
5935
|
+
// where whichever of the two is registered answers a signal held through
|
|
5936
|
+
// the synchronous stretch before it (the stdin read, the classification, the
|
|
5937
|
+
// registration). A signal that lands after the last pause is dispatched
|
|
5938
|
+
// once the run has returned, to the `approval` bin's own guard, which exits
|
|
5939
|
+
// with the answer this run gave. While these guards are registered,
|
|
5940
|
+
// HERMES_SIGNAL_OWNER tells the bin's guard to step aside, so the two never
|
|
5941
|
+
// both print.
|
|
5942
|
+
const onEarlySignal = (signal) => {
|
|
5943
|
+
if (raw !== null && isHermesPostEvent(raw))
|
|
5944
|
+
process.exit(EXIT_OK);
|
|
5945
|
+
if (stdout.length === 0) {
|
|
5946
|
+
try {
|
|
5947
|
+
writeSync(1, hermesInterruptedDirective(signal));
|
|
5948
|
+
}
|
|
5949
|
+
catch {
|
|
5950
|
+
// stdout is gone; the exit code below is the whole verdict.
|
|
5951
|
+
}
|
|
5952
|
+
}
|
|
5953
|
+
process.exit(HERMES_DENY_EXIT);
|
|
5954
|
+
};
|
|
5955
|
+
const onEarlyTerm = () => onEarlySignal("SIGTERM");
|
|
5956
|
+
const onEarlyInt = () => onEarlySignal("SIGINT");
|
|
5957
|
+
const owner = globalThis;
|
|
5958
|
+
const ownerBefore = owner[HERMES_SIGNAL_OWNER];
|
|
5959
|
+
if (waitSeam === null) {
|
|
5960
|
+
process.on("SIGTERM", onEarlyTerm);
|
|
5961
|
+
process.on("SIGINT", onEarlyInt);
|
|
5962
|
+
owner[HERMES_SIGNAL_OWNER] = true;
|
|
5963
|
+
}
|
|
5964
|
+
try {
|
|
5965
|
+
return yield* hermesVerdict();
|
|
5966
|
+
}
|
|
5967
|
+
finally {
|
|
5968
|
+
if (waitSeam === null) {
|
|
5969
|
+
owner[HERMES_SIGNAL_OWNER] = ownerBefore;
|
|
5970
|
+
process.off("SIGTERM", onEarlyTerm);
|
|
5971
|
+
process.off("SIGINT", onEarlyInt);
|
|
5972
|
+
}
|
|
5973
|
+
}
|
|
5974
|
+
function* hermesVerdict() {
|
|
5975
|
+
let code;
|
|
5976
|
+
try {
|
|
5977
|
+
code = yield* runHarnessHook(argv, tracked, cwd, reading, adapter, waitSeam);
|
|
5978
|
+
}
|
|
5979
|
+
catch (cause) {
|
|
5980
|
+
// A post-event never blocks: the call already ran (see the post path).
|
|
5981
|
+
if (raw !== null && isHermesPostEvent(raw))
|
|
5982
|
+
return EXIT_OK;
|
|
5983
|
+
if (stdout.length > 0) {
|
|
5984
|
+
// A verdict was already printed; one more object would be unparseable.
|
|
5985
|
+
return HERMES_DENY_EXIT;
|
|
5986
|
+
}
|
|
5987
|
+
return deny(tracked, "hook-io", `the hook failed: ${cause instanceof Error ? cause.message : String(cause)}`, adapter.kind);
|
|
5988
|
+
}
|
|
5989
|
+
const post = raw !== null && isHermesPostEvent(raw);
|
|
5990
|
+
if (post)
|
|
5991
|
+
return code;
|
|
5992
|
+
const blocked = /"action"\s*:\s*"block"/u.test(stdout);
|
|
5993
|
+
if (code === EXIT_OK && stdout.length > 0)
|
|
5994
|
+
return code;
|
|
5995
|
+
if (code === EXIT_OK || !blocked) {
|
|
5996
|
+
if (stdout.length > 0) {
|
|
5997
|
+
// Something that is not a block is already on stdout, and a second
|
|
5998
|
+
// object after it is unparseable stdout, which `fail_closed` blocks and a
|
|
5999
|
+
// build without it allows. The exit code is the verdict that cannot be
|
|
6000
|
+
// misread, so it carries the block.
|
|
6001
|
+
return HERMES_DENY_EXIT;
|
|
6002
|
+
}
|
|
6003
|
+
return deny(tracked, "hook-io", code === EXIT_OK
|
|
6004
|
+
? "the hook reached no verdict for this call; nothing authorizes it"
|
|
6005
|
+
: `the hook exited ${String(code)} without a verdict; nothing authorizes this call`, adapter.kind);
|
|
6006
|
+
}
|
|
6007
|
+
return HERMES_DENY_EXIT;
|
|
6008
|
+
}
|
|
6009
|
+
}
|
|
6010
|
+
/** Is this raw Hermes event a `post_tool_call`? Unreadable input is not. */
|
|
6011
|
+
function isHermesPostEvent(raw) {
|
|
6012
|
+
try {
|
|
6013
|
+
const parsed = JSON.parse(raw);
|
|
6014
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
6015
|
+
return false;
|
|
6016
|
+
return parsed["hook_event_name"] === HERMES_POST_TOOL_EVENT;
|
|
6017
|
+
}
|
|
6018
|
+
catch {
|
|
6019
|
+
return false;
|
|
6020
|
+
}
|
|
6021
|
+
}
|
|
3770
6022
|
// ===========================================================================
|
|
3771
6023
|
// Dispatch
|
|
3772
6024
|
// ===========================================================================
|
|
@@ -3774,7 +6026,55 @@ function commandHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
3774
6026
|
function defaultStdin() {
|
|
3775
6027
|
return readFileSync(0, "utf8");
|
|
3776
6028
|
}
|
|
3777
|
-
export function commandHook(argv, streams, cwd, readStdin = defaultStdin
|
|
6029
|
+
export function commandHook(argv, streams, cwd, readStdin = defaultStdin,
|
|
6030
|
+
/**
|
|
6031
|
+
* APRV-427: where a harness hook's wait begins and ends, for `approval serve`,
|
|
6032
|
+
* which runs the call off its main thread and releases its store lock while
|
|
6033
|
+
* the call polls. Every CLI route omits it. See {@link HookWaitSeam}.
|
|
6034
|
+
*/
|
|
6035
|
+
waitSeam = null) {
|
|
6036
|
+
return driveSync(hookSteps(argv, streams, cwd, readStdin, waitSeam));
|
|
6037
|
+
}
|
|
6038
|
+
/**
|
|
6039
|
+
* {@link commandHook}, with its pauses on the event loop (APRV-473).
|
|
6040
|
+
*
|
|
6041
|
+
* Every `approval hook` CLI route, from `main`: Hermes since APRV-473, the
|
|
6042
|
+
* other five harnesses (and `classify`, which never pauses) since APRV-475.
|
|
6043
|
+
* The same steps as the synchronous form; the difference is that a SIGTERM or
|
|
6044
|
+
* SIGINT reaches the listener in force within one pause of arriving, instead
|
|
6045
|
+
* of after the run returns. In the wait that listener withdraws the question
|
|
6046
|
+
* this invocation opened, prints the harness's own `hook-interrupted` block
|
|
6047
|
+
* directive ({@link interruptedWaitDirective}) and exits with that directive's
|
|
6048
|
+
* exit code, so a grant that lands after the harness abandoned the call is
|
|
6049
|
+
* never spent on it. See {@link GateSteps} and {@link BEFORE_SPEND}.
|
|
6050
|
+
*/
|
|
6051
|
+
export function commandHookYielding(argv, streams, cwd, readStdin = defaultStdin) {
|
|
6052
|
+
// The loop now turns mid-wait, so an asynchronous stderr failure (EPIPE on a
|
|
6053
|
+
// closed pipe after `announceWait`, where pipes are asynchronous) would
|
|
6054
|
+
// surface as an uncaught exception in the middle of the wait and end it
|
|
6055
|
+
// without withdrawing. Stderr carries progress lines and never the verdict,
|
|
6056
|
+
// so its failure is swallowed (APRV-473 refuter).
|
|
6057
|
+
process.stderr.on("error", () => undefined);
|
|
6058
|
+
return driveYielding(hookSteps(argv, streams, cwd, readStdin, null));
|
|
6059
|
+
}
|
|
6060
|
+
/**
|
|
6061
|
+
* The steps {@link commandHook} and {@link commandHookYielding} drive, for a
|
|
6062
|
+
* test that drives them itself (APRV-475 refuter).
|
|
6063
|
+
*
|
|
6064
|
+
* A test seam and nothing more: it exists so a test can stop at each pause and
|
|
6065
|
+
* look at the log, which is the only deterministic way to pin that the pause
|
|
6066
|
+
* before a spend ({@link BEFORE_SPEND}) sits between the read that found a
|
|
6067
|
+
* grant and the append that spends it, on every harness. No CLI route and no
|
|
6068
|
+
* embedding caller uses it; the one entry for those is the two drivers above.
|
|
6069
|
+
*
|
|
6070
|
+
* Pass `true` to each `next()` to step as {@link commandHookYielding} does
|
|
6071
|
+
* (the spend then waits for a held log lock in yields, APRV-478); `false` or
|
|
6072
|
+
* nothing steps as {@link commandHook} does.
|
|
6073
|
+
*/
|
|
6074
|
+
export function commandHookSteps(argv, streams, cwd, readStdin) {
|
|
6075
|
+
return hookSteps(argv, streams, cwd, readStdin, null);
|
|
6076
|
+
}
|
|
6077
|
+
function* hookSteps(argv, streams, cwd, readStdin, waitSeam) {
|
|
3778
6078
|
const sub = argv[0];
|
|
3779
6079
|
const rest = argv.slice(1);
|
|
3780
6080
|
if (sub === undefined) {
|
|
@@ -3788,7 +6088,7 @@ export function commandHook(argv, streams, cwd, readStdin = defaultStdin) {
|
|
|
3788
6088
|
// boundary and doctor use, so a kind that can be recorded is a kind that can
|
|
3789
6089
|
// be invoked, and neither can be added without the other.
|
|
3790
6090
|
if (isHarnessKind(sub)) {
|
|
3791
|
-
return commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub]);
|
|
6091
|
+
return yield* commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub], waitSeam);
|
|
3792
6092
|
}
|
|
3793
6093
|
if (sub === "classify")
|
|
3794
6094
|
return commandClassify(rest, streams, cwd);
|