approval-md 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -24
- package/SPEC.md +57 -11
- package/dist/src/channels/contract.d.ts +34 -1
- package/dist/src/channels/contract.js +200 -7
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/telegram.d.ts +123 -11
- package/dist/src/channels/telegram.js +218 -23
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/channels/web.d.ts +9 -0
- package/dist/src/channels/web.js +17 -0
- package/dist/src/channels/web.js.map +1 -1
- package/dist/src/cli/amend.js +214 -30
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.d.ts +9 -0
- package/dist/src/cli/attest.js +134 -7
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-telegram.d.ts +99 -26
- package/dist/src/cli/channel-telegram.js +311 -13
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +9 -0
- package/dist/src/cli/channel.js +9 -0
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/codex-bridge.d.ts +819 -0
- package/dist/src/cli/codex-bridge.js +1607 -0
- package/dist/src/cli/codex-bridge.js.map +1 -0
- package/dist/src/cli/codex.d.ts +1 -1
- package/dist/src/cli/codex.js +304 -7
- package/dist/src/cli/codex.js.map +1 -1
- package/dist/src/cli/daemon.js +4 -1
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.js +467 -12
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.js +25 -2
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/help.d.ts +6 -2
- package/dist/src/cli/help.js +165 -60
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook-codex.d.ts +49 -1
- package/dist/src/cli/hook-codex.js +60 -1
- package/dist/src/cli/hook-codex.js.map +1 -1
- package/dist/src/cli/hook.d.ts +459 -3
- package/dist/src/cli/hook.js +1062 -114
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/import.js +1 -1
- package/dist/src/cli/import.js.map +1 -1
- package/dist/src/cli/main.js +5 -3
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/policy-apply.d.ts +195 -0
- package/dist/src/cli/policy-apply.js +573 -0
- package/dist/src/cli/policy-apply.js.map +1 -0
- package/dist/src/cli/policy.js +14 -1
- package/dist/src/cli/policy.js.map +1 -1
- package/dist/src/cli/preflight.d.ts +151 -13
- package/dist/src/cli/preflight.js +398 -41
- package/dist/src/cli/preflight.js.map +1 -1
- package/dist/src/cli/sandbox.js +17 -1
- package/dist/src/cli/sandbox.js.map +1 -1
- package/dist/src/cli/scaffold.d.ts +1 -1
- package/dist/src/cli/scaffold.js +1 -1
- package/dist/src/cli/setup-channel.d.ts +9 -0
- package/dist/src/cli/setup-channel.js +28 -1
- package/dist/src/cli/setup-channel.js.map +1 -1
- package/dist/src/cli/setup-common.d.ts +3 -1
- package/dist/src/cli/setup-common.js +3 -2
- package/dist/src/cli/setup-common.js.map +1 -1
- package/dist/src/cli/setup.d.ts +2 -0
- package/dist/src/cli/setup.js +94 -2
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/up.js +115 -51
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/values.js +3 -4
- package/dist/src/cli/values.js.map +1 -1
- package/dist/src/cli/verb-registry.js +174 -9
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/codex/broker.d.ts +229 -0
- package/dist/src/codex/broker.js +548 -0
- package/dist/src/codex/broker.js.map +1 -0
- package/dist/src/codex/runner.d.ts +178 -0
- package/dist/src/codex/runner.js +231 -0
- package/dist/src/codex/runner.js.map +1 -0
- package/dist/src/codex/serve.d.ts +56 -0
- package/dist/src/codex/serve.js +98 -0
- package/dist/src/codex/serve.js.map +1 -0
- package/dist/src/codex/workspace-commit.d.ts +219 -0
- package/dist/src/codex/workspace-commit.js +549 -0
- package/dist/src/codex/workspace-commit.js.map +1 -0
- package/dist/src/core/advance-cycle.d.ts +51 -0
- package/dist/src/core/advance-cycle.js +66 -2
- package/dist/src/core/advance-cycle.js.map +1 -1
- package/dist/src/core/agents-md.d.ts +20 -18
- package/dist/src/core/agents-md.js +33 -31
- package/dist/src/core/agents-md.js.map +1 -1
- package/dist/src/core/attest.d.ts +215 -0
- package/dist/src/core/attest.js +317 -7
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +18 -0
- package/dist/src/core/audit.js +13 -0
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-owner.d.ts +213 -0
- package/dist/src/core/channel-owner.js +358 -0
- package/dist/src/core/channel-owner.js.map +1 -0
- package/dist/src/core/command-class.d.ts +154 -0
- package/dist/src/core/command-class.js +673 -20
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/commit-guard.d.ts +272 -0
- package/dist/src/core/commit-guard.js +424 -0
- package/dist/src/core/commit-guard.js.map +1 -0
- package/dist/src/core/daemon-actor.d.ts +45 -0
- package/dist/src/core/daemon-actor.js +54 -0
- package/dist/src/core/daemon-actor.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +109 -8
- package/dist/src/core/dark-session.js +266 -82
- package/dist/src/core/dark-session.js.map +1 -1
- package/dist/src/core/decision-refusal.d.ts +23 -2
- package/dist/src/core/decision-refusal.js +24 -2
- package/dist/src/core/decision-refusal.js.map +1 -1
- package/dist/src/core/env-file.d.ts +5 -0
- package/dist/src/core/env-file.js +60 -1
- package/dist/src/core/env-file.js.map +1 -1
- package/dist/src/core/execute.d.ts +15 -2
- package/dist/src/core/execute.js +15 -2
- package/dist/src/core/execute.js.map +1 -1
- package/dist/src/core/gate.d.ts +86 -1
- package/dist/src/core/gate.js +81 -1
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/gesture-refusal.d.ts +166 -0
- package/dist/src/core/gesture-refusal.js +188 -0
- package/dist/src/core/gesture-refusal.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +1 -1
- package/dist/src/core/harness-version.js +3 -1
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/instance.d.ts +59 -2
- package/dist/src/core/instance.js +113 -0
- package/dist/src/core/instance.js.map +1 -1
- package/dist/src/core/log.d.ts +39 -1
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +10 -0
- package/dist/src/core/policy-explain.js +32 -0
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +41 -1
- package/dist/src/core/policy-load.js +21 -3
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +43 -0
- package/dist/src/core/policy-match.js +52 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +52 -0
- package/dist/src/core/policy-proposal.js +102 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/protected-path-guard.d.ts +117 -4
- package/dist/src/core/protected-path-guard.js +362 -48
- package/dist/src/core/protected-path-guard.js.map +1 -1
- package/dist/src/core/question-preempted.d.ts +141 -0
- package/dist/src/core/question-preempted.js +152 -0
- package/dist/src/core/question-preempted.js.map +1 -0
- package/dist/src/core/read-scope.d.ts +172 -0
- package/dist/src/core/read-scope.js +252 -0
- package/dist/src/core/read-scope.js.map +1 -0
- package/dist/src/core/sandbox.d.ts +81 -0
- package/dist/src/core/sandbox.js +190 -1
- package/dist/src/core/sandbox.js.map +1 -1
- package/dist/src/core/sender-identity.d.ts +476 -0
- package/dist/src/core/sender-identity.js +572 -0
- package/dist/src/core/sender-identity.js.map +1 -0
- package/dist/src/core/shlex.d.ts +102 -0
- package/dist/src/core/shlex.js +159 -0
- package/dist/src/core/shlex.js.map +1 -0
- package/dist/src/core/values.d.ts +18 -8
- package/dist/src/core/values.js +36 -1
- package/dist/src/core/values.js.map +1 -1
- package/dist/src/daemon/advance.d.ts +10 -0
- package/dist/src/daemon/advance.js +25 -4
- package/dist/src/daemon/advance.js.map +1 -1
- package/dist/src/daemon/daemon.js +9 -0
- 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/mcp/server.js +8 -0
- package/dist/src/mcp/server.js.map +1 -1
- package/docs/cli-reference.md +932 -32
- package/docs/codex-enforced-session.md +75 -2
- package/docs/codex-workspace-broker.md +118 -0
- package/package.json +3 -1
- package/schema/event.schema.json +538 -9
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
- package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
- package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
- package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
- package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
- package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
- package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
- package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
- package/schema/fixtures/policy/valid/canonical.json +1 -1
- package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
- package/schema/fixtures/policy-md/valid/canonical.md +1 -1
- package/schema/fixtures/policy-md/valid/with-values.md +5 -7
- package/schema/fixtures/values/invalid/class-shaped.json +1 -1
- package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
- package/schema/fixtures/values/invalid/non-string-item.json +1 -1
- package/schema/fixtures/values/invalid/over-cap.json +1 -1
- package/schema/fixtures/values/invalid/unknown-key.json +1 -1
- package/schema/fixtures/values/invalid/version-float.json +1 -0
- package/schema/fixtures/values/invalid/version-integer.json +1 -0
- package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +2 -3
- package/schema/fixtures/values/valid/full.json +5 -7
- package/schema/fixtures/values/valid/minimal.json +1 -1
- package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
- package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
- package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
- package/schema/fixtures/values-md/invalid/version-1.md +69 -0
- package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
- package/schema/fixtures/values-md/valid/absent.md +1 -1
- package/schema/fixtures/values-md/valid/with-values.md +5 -7
- package/schema/policy.schema.json +54 -2
- package/schema/values.schema.json +7 -11
- package/schema/fixtures/values/invalid/version-string.json +0 -1
package/dist/src/cli/hook.js
CHANGED
|
@@ -78,26 +78,27 @@ import { tmpdir } from "node:os";
|
|
|
78
78
|
import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments, sep, } from "node:path";
|
|
79
79
|
import { attestationRefusal, checkAttestation } from "../core/attest.js";
|
|
80
80
|
import { childEnvironment } from "../core/child-env.js";
|
|
81
|
-
import { classifyCommand, commandSegmentWords, CODE_EXECUTING_RULES, GATE_SELF_CLASS, protectedPathClass, } from "../core/command-class.js";
|
|
81
|
+
import { classifyCommand, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, protectedPathClass, } from "../core/command-class.js";
|
|
82
82
|
import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, register, request, startHarnessExecution, withdraw, } from "../core/gate.js";
|
|
83
83
|
import { openGateWindow, recordGateBypass, } from "../core/gate-window.js";
|
|
84
|
-
import { harnessProvenance, } from "../core/harness-version.js";
|
|
84
|
+
import { harnessProvenance, isHarnessKind, } from "../core/harness-version.js";
|
|
85
85
|
import { abandonedAfterMs, HOOK_DEFAULT_WAIT, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
|
|
86
86
|
import { harnessLoopFloor, isLoopEscalated, isSideEffectingClass, loopClearance, UNKNOWN_SESSION, } from "../core/loop.js";
|
|
87
87
|
import { drawSocketPathFor, drawSocketUsable } from "../core/live-draw.js";
|
|
88
88
|
import { payloadHash } from "../core/payload.js";
|
|
89
89
|
import { classifyApplyPatch, parseApplyPatch } from "../core/apply-patch.js";
|
|
90
90
|
import { loadPolicy, parseDuration } from "../core/policy-load.js";
|
|
91
|
-
import {
|
|
91
|
+
import { READ_OUT_OF_SCOPE_CLASS, effectiveReadRoots, isInReadScope, readTargetsOf, renderReadRoots, } from "../core/read-scope.js";
|
|
92
|
+
import { harnessLaunchNeedsRule, harnessLaunchUnruledRefusal, humanOnlyRefusal, resolve as resolvePolicy, } from "../core/policy-match.js";
|
|
92
93
|
import { payloadOf, readVerifiedRecords, requestState, useVerifiedSnapshots, } from "../core/state.js";
|
|
93
94
|
import { boolFlag, parseFlags, stringFlag } from "./args.js";
|
|
94
95
|
import { EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
|
|
95
96
|
import { primaryRoot as resolvePrimaryRoot } from "./git-scope.js";
|
|
96
|
-
import { HOOK_HELP } from "./help.js";
|
|
97
|
+
import { HOOK_GROK_HELP, HOOK_HELP, HOOK_MUSE_HELP } from "./help.js";
|
|
97
98
|
import { DEFAULT_LOG_PATH } from "./paths.js";
|
|
98
99
|
import { refusal as renderRefusal, style, table } from "./style.js";
|
|
99
100
|
import { usageErrorText } from "./usage.js";
|
|
100
|
-
import { checkCodexHookInput, codexBinding, CODEX_POST_TOOL_EVENT, readCodexReportedOutcome, } from "./hook-codex.js";
|
|
101
|
+
import { checkCodexHookInput, codexArgvDisagrees, codexBinding, CODEX_POST_TOOL_EVENT, readCodexReportedOutcome, } from "./hook-codex.js";
|
|
101
102
|
/** Identity accepted for the proposing side: a person or an agent. */
|
|
102
103
|
const PRINCIPAL_ACTOR = /^(human|agent):.+/u;
|
|
103
104
|
/**
|
|
@@ -151,6 +152,29 @@ export const HOOK_DENY_CODES = [
|
|
|
151
152
|
* rejection: nobody decided anything, so there is nothing to ask again.
|
|
152
153
|
*/
|
|
153
154
|
"hook-class-human-only",
|
|
155
|
+
/**
|
|
156
|
+
* A `harness.launch.*` class that no rule of this policy names (APRV-354).
|
|
157
|
+
*
|
|
158
|
+
* SPEC.md §7 says the family is never inferred autonomous; this is the
|
|
159
|
+
* stronger reading the family needs, which is that it is never inferred at
|
|
160
|
+
* all. A launch resolves only under a rule an operator wrote, and a policy
|
|
161
|
+
* that names neither `harness.launch.*` nor the specific member refuses.
|
|
162
|
+
*
|
|
163
|
+
* It exists because of the window the softer reading opens. Before the family
|
|
164
|
+
* existed, `codex …` and `muse …` were `hook-unclassified`: refused outright.
|
|
165
|
+
* Letting the new class fall to `defaults.autonomy` would have made every
|
|
166
|
+
* harness launch grantable by one approval in every project whose defaults
|
|
167
|
+
* are manual, the moment they upgraded — a capability arriving by upgrade
|
|
168
|
+
* rather than by decision. What that approval would cover is a whole second
|
|
169
|
+
* agent whose own actions this gate never sees.
|
|
170
|
+
*
|
|
171
|
+
* Distinct from `hook-unclassified`, which says the CLASSIFIER has nothing to
|
|
172
|
+
* say about the command; here the classifier was clear and the POLICY is
|
|
173
|
+
* silent. Distinct from `hook-class-human-only`, which is a policy that has
|
|
174
|
+
* spoken and reserved the class: the repair there is for a person to run the
|
|
175
|
+
* command, and the repair here is to write a line.
|
|
176
|
+
*/
|
|
177
|
+
"hook-harness-launch-unruled",
|
|
154
178
|
/** A construct whose effect cannot be read off the text (`bash -c`, `eval`). */
|
|
155
179
|
"hook-opaque",
|
|
156
180
|
/** The command line could not be tokenized at all. */
|
|
@@ -219,6 +243,46 @@ export const HOOK_DENY_CODES = [
|
|
|
219
243
|
* merges do not reconcile hash chains (APRV-101).
|
|
220
244
|
*/
|
|
221
245
|
"hook-log-unreachable",
|
|
246
|
+
/**
|
|
247
|
+
* The harness does not tell this hook where the call will run, so no verdict
|
|
248
|
+
* over the visible bytes can bind the action (APRV-311, native evidence in
|
|
249
|
+
* APRV-310 v6/v7).
|
|
250
|
+
*
|
|
251
|
+
* Native Codex 0.152.1 honours a per-call Bash working directory that appears
|
|
252
|
+
* in no field of the event: `tool_input` carries `command` alone, and the
|
|
253
|
+
* event cwd and the hook process cwd both stay at the session root. A
|
|
254
|
+
* decision over `{command, session root}` would therefore authorize different
|
|
255
|
+
* bytes from the `{command, effective directory}` the harness executes, and a
|
|
256
|
+
* relative path in an approved command can name a protected organ in a
|
|
257
|
+
* directory the classifier never saw.
|
|
258
|
+
*
|
|
259
|
+
* Distinct from `hook-io`, which this used to borrow, and the distinction is
|
|
260
|
+
* the repair. `hook-io` says THIS event was malformed and a well-formed one
|
|
261
|
+
* would be answered; this says every event of this shape is refused on this
|
|
262
|
+
* harness version, and the fix is a harness contract that exposes the
|
|
263
|
+
* effective execution directory, not a retry, a policy edit, or an open
|
|
264
|
+
* window. Nothing appends on this path and no gate lifecycle opens.
|
|
265
|
+
*/
|
|
266
|
+
"hook-unsupported-execution-context",
|
|
267
|
+
/**
|
|
268
|
+
* The session names a Contributor-tier model, so every tool call is refused
|
|
269
|
+
* (APRV-350).
|
|
270
|
+
*
|
|
271
|
+
* Meta sells a Contributor variant of the Muse Spark family that "trades a
|
|
272
|
+
* lower price for permission to train on your prompts and completions". A
|
|
273
|
+
* session on one discloses every byte it reads, so the refusal is above the
|
|
274
|
+
* policy: no class resolution and no grant widens it, and an absent or
|
|
275
|
+
* unrecognised `model` is refused for the same reason an unparseable event is.
|
|
276
|
+
*
|
|
277
|
+
* Distinct from `hook-class-human-only`, which says a HUMAN must do this
|
|
278
|
+
* action; this says nothing may do it in this session, and the repair is to
|
|
279
|
+
* change the model in Muse's picker rather than to ask anybody. Distinct from
|
|
280
|
+
* `hook-io` because the event was perfectly well formed.
|
|
281
|
+
*
|
|
282
|
+
* What it cannot do is stated wherever it is documented: it stops tool calls,
|
|
283
|
+
* and it cannot recall a prompt the model has already been sent.
|
|
284
|
+
*/
|
|
285
|
+
"hook-muse-contributor-model",
|
|
222
286
|
/** Malformed hook input, or a log/filesystem fact that stopped the check. */
|
|
223
287
|
"hook-io",
|
|
224
288
|
];
|
|
@@ -267,7 +331,7 @@ const primaryRoot = resolvePrimaryRoot;
|
|
|
267
331
|
* (`--policy` for the policy, `--log` for the log); otherwise both follow
|
|
268
332
|
* `--dir`, and with no flags at all both follow the primary checkout.
|
|
269
333
|
*/
|
|
270
|
-
function hookScope(flags, cwd) {
|
|
334
|
+
export function hookScope(flags, cwd) {
|
|
271
335
|
const policyFlag = stringFlag(flags, "--policy");
|
|
272
336
|
const logFlag = stringFlag(flags, "--log");
|
|
273
337
|
const dirFlag = stringFlag(flags, "--dir");
|
|
@@ -278,12 +342,30 @@ function hookScope(flags, cwd) {
|
|
|
278
342
|
const logPath = logFlag === null ? join(root, DEFAULT_LOG_PATH) : absolute(logFlag, cwd);
|
|
279
343
|
return { logPath, root, options };
|
|
280
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* The GATE ROOT: the directory holding the policy file that was actually read
|
|
347
|
+
* (APRV-347).
|
|
348
|
+
*
|
|
349
|
+
* This is the anchor of the read scope, and it is deliberately the loaded
|
|
350
|
+
* policy's own directory rather than the hook's cwd or the harness's `cwd`
|
|
351
|
+
* field. A muse session started in `~/dev/muse` with its own `APPROVAL.md` gets
|
|
352
|
+
* `~/dev/muse`, and every sibling under `~/dev` is outside the scope with no
|
|
353
|
+
* extra grammar anywhere. A policy that did not load falls back to the scope
|
|
354
|
+
* root, which is the same directory the loader was pointed at.
|
|
355
|
+
*/
|
|
356
|
+
function policyRootOf(load, fallback) {
|
|
357
|
+
return load.ok ? dirname(load.source.path) : fallback;
|
|
358
|
+
}
|
|
281
359
|
const CLAUDE_ADAPTER = {
|
|
282
360
|
kind: "claude-code",
|
|
283
361
|
originApp: "claude-code-hook",
|
|
284
362
|
defaultActor: "agent:claude-code",
|
|
285
363
|
shellTool: "Bash",
|
|
286
364
|
fileTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
|
|
365
|
+
// Claude Code's three path-carrying readers. `Read` names the file in
|
|
366
|
+
// `file_path` (or `notebook_path`), `Glob` and `Grep` name the directory they
|
|
367
|
+
// search in `path` and default to the workspace when it is absent.
|
|
368
|
+
readTools: ["Read", "Glob", "Grep"],
|
|
287
369
|
};
|
|
288
370
|
const CURSOR_ADAPTER = {
|
|
289
371
|
kind: "cursor",
|
|
@@ -291,6 +373,14 @@ const CURSOR_ADAPTER = {
|
|
|
291
373
|
defaultActor: "agent:cursor",
|
|
292
374
|
shellTool: "Shell",
|
|
293
375
|
fileTools: ["Write", "Delete"],
|
|
376
|
+
// Cursor's own hook documentation names `Shell`, `Write` and `Delete` as the
|
|
377
|
+
// matchable tools, and no read tool among them; `Read` is the name its agent
|
|
378
|
+
// surface uses. It is listed here because an entry that Cursor never sends is
|
|
379
|
+
// INERT — an unmatched tool takes the path it took before this task — while
|
|
380
|
+
// an entry missing for a tool Cursor does send would be an unscoped read. If
|
|
381
|
+
// Cursor names it otherwise, `cat` through `Shell` is still scoped by the
|
|
382
|
+
// classifier, which is the floor. See docs/cursor-hook.md.
|
|
383
|
+
readTools: ["Read"],
|
|
294
384
|
};
|
|
295
385
|
const CODEX_ADAPTER = {
|
|
296
386
|
kind: "codex",
|
|
@@ -298,14 +388,139 @@ const CODEX_ADAPTER = {
|
|
|
298
388
|
defaultActor: "agent:codex",
|
|
299
389
|
shellTool: "Bash",
|
|
300
390
|
fileTools: ["apply_patch"],
|
|
391
|
+
// Empty, and not an oversight. The Codex native hook contract exposes exactly
|
|
392
|
+
// two tools, `Bash` and `apply_patch` (docs/codex-hook.md); it has no
|
|
393
|
+
// separate read tool to gate. Codex `Bash` is refused outright today because
|
|
394
|
+
// the contract does not expose the per-call working directory (APRV-310), so
|
|
395
|
+
// a Codex read arrives as a shell command and is scoped by the classifier or
|
|
396
|
+
// it does not arrive at all.
|
|
397
|
+
readTools: [],
|
|
301
398
|
bindToolName: true,
|
|
302
399
|
};
|
|
400
|
+
/**
|
|
401
|
+
* Grok Build (APRV-243).
|
|
402
|
+
*
|
|
403
|
+
* Its PreToolUse hook is modelled on Claude Code's, with three differences
|
|
404
|
+
* that matter here: the envelope keys are camelCase, the verdict is
|
|
405
|
+
* `{decision, reason}` rather than the nested `hookSpecificOutput`, and a deny
|
|
406
|
+
* is EXIT 2 rather than exit 0 with a JSON body. The tool names are Claude
|
|
407
|
+
* Code's, which is what its documentation says and what the compatibility read
|
|
408
|
+
* of `.claude/settings.json` implies; `docs/grok-hook.md` records that this
|
|
409
|
+
* half is unverified until the live probe runs.
|
|
410
|
+
*
|
|
411
|
+
* The reason this adapter exists at all is a hazard rather than a feature.
|
|
412
|
+
* Grok Build states that it reads `.claude/settings.json` for compatibility.
|
|
413
|
+
* If that read fires this repository's committed claude-code entry under a
|
|
414
|
+
* Grok session, the claude-code adapter answers a deny by printing the nested
|
|
415
|
+
* Claude envelope and exiting 0, and Grok reads exit 0 as ALLOW. Every command
|
|
416
|
+
* would look gated and none would be.
|
|
417
|
+
*/
|
|
418
|
+
const GROK_ADAPTER = {
|
|
419
|
+
kind: "grok",
|
|
420
|
+
originApp: "grok-hook",
|
|
421
|
+
defaultActor: "agent:grok",
|
|
422
|
+
shellTool: "Bash",
|
|
423
|
+
fileTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
|
|
424
|
+
// Claude Code's three path-carrying readers, for the same reason the file
|
|
425
|
+
// tools are Claude Code's: Grok Build's tool vocabulary follows Claude
|
|
426
|
+
// Code's, and it READS `.claude/settings.json` for compatibility, so the
|
|
427
|
+
// names a Grok session sends are the names that file matches on. The
|
|
428
|
+
// asymmetry with Cursor is deliberate — Cursor documents its own smaller set,
|
|
429
|
+
// Grok documents Claude's.
|
|
430
|
+
//
|
|
431
|
+
// Both directions of the guess are safe in the way APRV-347 makes them safe.
|
|
432
|
+
// A read tool listed here that Grok never sends is INERT: an unmatched tool
|
|
433
|
+
// takes the path it took before. A read tool Grok sends that is NOT listed
|
|
434
|
+
// would be an unscoped read, which is the direction that matters, so the
|
|
435
|
+
// wider Claude set is the fail-closed guess. And a Grok read that arrives as
|
|
436
|
+
// a shell command through `Bash` is scoped by the classifier regardless,
|
|
437
|
+
// which is the floor under all of this.
|
|
438
|
+
//
|
|
439
|
+
// UNVERIFIED, like the file-tool list beside it, and `docs/grok-hook.md`
|
|
440
|
+
// says so: the live probe of APRV-243 AC1 records the tool names an actual
|
|
441
|
+
// session sends, and both lists are corrected to match before the register
|
|
442
|
+
// entry moves from parked.
|
|
443
|
+
readTools: ["Read", "Glob", "Grep"],
|
|
444
|
+
camelCaseEnvelope: true,
|
|
445
|
+
};
|
|
446
|
+
/**
|
|
447
|
+
* Meta Muse Code (APRV-350).
|
|
448
|
+
*
|
|
449
|
+
* Every field here is OBSERVED, from a live run on `muse-bin-1.3.0-R3233.1`
|
|
450
|
+
* whose 139 captured envelopes are the evidence. Nothing in this entry is a
|
|
451
|
+
* guess, which is the difference between it and the Grok entry above.
|
|
452
|
+
*
|
|
453
|
+
* The envelope is snake_case, so no `camelCaseEnvelope`. It carries
|
|
454
|
+
* `hook_event_name`, `tool_name`, `tool_input`, `tool_use_id`, `session_id`,
|
|
455
|
+
* `turn_id`, `cwd`, `transcript_path`, `model`, `permission_mode` and
|
|
456
|
+
* `model_provider`; `PostToolUse` adds `tool_response`. So Muse sends BOTH
|
|
457
|
+
* facts Codex lacks: a per-call working directory and a real outcome.
|
|
458
|
+
*
|
|
459
|
+
* ## What it cannot do, and why this adapter still ships
|
|
460
|
+
*
|
|
461
|
+
* Muse FAILS OPEN. A hook that crashes, hangs past its timeout, or prints
|
|
462
|
+
* anything Muse cannot parse is a hook Muse ignores, and the tool call
|
|
463
|
+
* proceeds. Worse, an output mixing verdict dialects is itself unparseable, so
|
|
464
|
+
* the belt-and-braces answer that satisfies every harness at once satisfies
|
|
465
|
+
* this one not at all: the probe's `deny` trial printed every dialect AND
|
|
466
|
+
* exited 2, and the write completed in under 80 ms.
|
|
467
|
+
*
|
|
468
|
+
* Therefore this adapter emits EXACTLY ONE dialect and nothing else: the nested
|
|
469
|
+
* `hookSpecificOutput` form at exit 0, which the default arm of
|
|
470
|
+
* {@link decision} already produces. Three forms were measured to block
|
|
471
|
+
* (nested at exit 0; `{decision:"block"}` at exit 0; empty stdout at exit 2)
|
|
472
|
+
* and the nested one is chosen because it is the only one that carries a REASON
|
|
473
|
+
* the model is shown, so a refused agent learns why instead of retrying blind.
|
|
474
|
+
* The probe watched an agent respond to an unexplained deny by shelling out to
|
|
475
|
+
* inspect the hook's own state file, which is the behaviour a silent refusal
|
|
476
|
+
* buys. `docs/muse-hook.md` records the choice and the two rejected forms.
|
|
477
|
+
*/
|
|
478
|
+
const MUSE_ADAPTER = {
|
|
479
|
+
kind: "muse",
|
|
480
|
+
originApp: "muse-hook",
|
|
481
|
+
defaultActor: "agent:muse",
|
|
482
|
+
shellTool: "bash",
|
|
483
|
+
fileTools: ["write_file"],
|
|
484
|
+
// `read_file` names one path (relative to the session cwd, or absolute);
|
|
485
|
+
// `search` names an ARRAY under `paths`, and the live capture caught it
|
|
486
|
+
// reaching outside the workspace entirely — Muse applies no workspace
|
|
487
|
+
// confinement in `permission_mode: "default"`, so this is the read jail's
|
|
488
|
+
// whole reason for existing here.
|
|
489
|
+
readTools: ["read_file", "search"],
|
|
490
|
+
passThroughTools: ["submit_reminder_decision"],
|
|
491
|
+
shellCwdKey: "workdir",
|
|
492
|
+
contributorModelGuard: true,
|
|
493
|
+
};
|
|
494
|
+
/**
|
|
495
|
+
* Every harness this runtime speaks a hook protocol for, by kind (APRV-358).
|
|
496
|
+
*
|
|
497
|
+
* The table is `Record<HarnessKind, HarnessAdapter>` rather than a list of
|
|
498
|
+
* consts and a switch, and the type is the point: a kind added to
|
|
499
|
+
* `HARNESS_KINDS` with no adapter beside it fails to compile, so the two lists
|
|
500
|
+
* cannot drift by forgetting. The subcommand dispatch below reads this map, so
|
|
501
|
+
* `approval hook <kind>` is answerable for exactly the kinds named here.
|
|
502
|
+
*
|
|
503
|
+
* The kinds that are enumerated OUTSIDE this module — the schema's
|
|
504
|
+
* `payload.harness` enum, the verb registry's `hook` subcommands, the MCP
|
|
505
|
+
* exclusions, the help — are pinned set-equal to `HARNESS_KINDS` by
|
|
506
|
+
* `tests/harness-enum.test.ts`, which exists because `grok` shipped an adapter
|
|
507
|
+
* in APRV-243 and reached none of them. A Grok session's manual-class
|
|
508
|
+
* registration was refused at the write boundary for eleven days and nothing
|
|
509
|
+
* failed.
|
|
510
|
+
*/
|
|
511
|
+
export const HARNESS_ADAPTERS = {
|
|
512
|
+
"claude-code": CLAUDE_ADAPTER,
|
|
513
|
+
cursor: CURSOR_ADAPTER,
|
|
514
|
+
codex: CODEX_ADAPTER,
|
|
515
|
+
grok: GROK_ADAPTER,
|
|
516
|
+
muse: MUSE_ADAPTER,
|
|
517
|
+
};
|
|
303
518
|
/**
|
|
304
519
|
* The decision object the harness reads from stdout.
|
|
305
520
|
*
|
|
306
521
|
* Claude Code wants the nested PreToolUse envelope. Cursor native hooks want
|
|
307
|
-
* `{permission, user_message, agent_message}`.
|
|
308
|
-
* harness, still never `ask`.
|
|
522
|
+
* `{permission, user_message, agent_message}`. Grok Build wants
|
|
523
|
+
* `{decision, reason}`. One construction site per harness, still never `ask`.
|
|
309
524
|
*/
|
|
310
525
|
function decision(permission, reason, harness, codexCommand) {
|
|
311
526
|
if (harness === "cursor") {
|
|
@@ -315,6 +530,9 @@ function decision(permission, reason, harness, codexCommand) {
|
|
|
315
530
|
agent_message: reason,
|
|
316
531
|
})}\n`;
|
|
317
532
|
}
|
|
533
|
+
if (harness === "grok") {
|
|
534
|
+
return `${JSON.stringify({ decision: permission, reason })}\n`;
|
|
535
|
+
}
|
|
318
536
|
const hookSpecificOutput = {
|
|
319
537
|
hookEventName: "PreToolUse",
|
|
320
538
|
permissionDecision: permission,
|
|
@@ -333,14 +551,88 @@ function allow(streams, reason, harness, codexCommand) {
|
|
|
333
551
|
streams.out(decision("allow", reason, harness, codexCommand));
|
|
334
552
|
return EXIT_OK;
|
|
335
553
|
}
|
|
554
|
+
/**
|
|
555
|
+
* The exit code a DENY carries.
|
|
556
|
+
*
|
|
557
|
+
* Claude Code, Cursor and Codex read the verdict out of stdout and treat a
|
|
558
|
+
* non-zero exit as a broken hook, so a deny there exits 0 with a JSON body.
|
|
559
|
+
* Grok Build reads exit 2 as the deny (APRV-243), and reads exit 0 as ALLOW
|
|
560
|
+
* whatever stdout said. Printing the body AND exiting 2 satisfies both halves
|
|
561
|
+
* of its documented contract and leaves the reason where a person can read it.
|
|
562
|
+
*/
|
|
563
|
+
const GROK_DENY_EXIT = 2;
|
|
336
564
|
function deny(streams, code, detail, harness) {
|
|
337
565
|
streams.out(decision("deny", `${code}: ${detail}`, harness));
|
|
338
|
-
return EXIT_OK;
|
|
566
|
+
return harness === "grok" ? GROK_DENY_EXIT : EXIT_OK;
|
|
567
|
+
}
|
|
568
|
+
/** The machine-readable code a Contributor-tier session is refused with. */
|
|
569
|
+
export const MUSE_CONTRIBUTOR_REFUSAL = "hook-muse-contributor-model";
|
|
570
|
+
/**
|
|
571
|
+
* Refuse every tool call on a Contributor-tier model, whatever the policy says.
|
|
572
|
+
*
|
|
573
|
+
* Meta sells two tiers of the Muse Spark family and marks the difference in the
|
|
574
|
+
* model id: a Contributor variant "trades a lower price for permission to train
|
|
575
|
+
* on your prompts and completions", against a Standard variant documented as
|
|
576
|
+
* never trained on. So a Contributor session discloses every file it reads, and
|
|
577
|
+
* a policy that would autonomously allow a read of the workspace was not
|
|
578
|
+
* written with that in mind.
|
|
579
|
+
*
|
|
580
|
+
* ## Why this is not a policy line
|
|
581
|
+
*
|
|
582
|
+
* A policy grants and withholds authority over CLASSES. This is not about the
|
|
583
|
+
* class of the action; the same `read.file` is fine on one model and a
|
|
584
|
+
* disclosure on another. Encoding it as policy would mean every operator's
|
|
585
|
+
* `APPROVAL.md` had to name Meta's tiering to be safe, and one that did not
|
|
586
|
+
* would be silently unsafe. So the adapter refuses, above the policy, and no
|
|
587
|
+
* policy can widen it. That is a strictness increase only, which is the one
|
|
588
|
+
* direction a hook may move a verdict on its own (SPEC §11.1 invariant 4).
|
|
589
|
+
*
|
|
590
|
+
* ## The self-reported field
|
|
591
|
+
*
|
|
592
|
+
* `model` is the harness's claim about itself, and SPEC §11.1 says a
|
|
593
|
+
* self-reported field never REDUCES scrutiny. This only ever raises it: the
|
|
594
|
+
* string can refuse a call, never authorize one. An absent, empty or
|
|
595
|
+
* unparseable `model` is refused too, because a session that will not say what
|
|
596
|
+
* it is running is exactly the one not to trust with a read.
|
|
597
|
+
*
|
|
598
|
+
* ## What it cannot do
|
|
599
|
+
*
|
|
600
|
+
* It stops tool calls. It cannot recall the prompt that was already sent — a
|
|
601
|
+
* hook fires after the model has seen it — and it cannot know what the harness
|
|
602
|
+
* attached as context before the first tool call. `docs/muse-hook.md` opens
|
|
603
|
+
* with this, because a guard people over-trust is worse than no guard.
|
|
604
|
+
*/
|
|
605
|
+
function contributorModelRefusal(model) {
|
|
606
|
+
if (model === null || model.trim() === "") {
|
|
607
|
+
return "the envelope names no model, and a session that will not say what it is running cannot be recognised as non-contributor";
|
|
608
|
+
}
|
|
609
|
+
if (model.trim().toLowerCase().includes(CONTRIBUTOR_SUFFIX.replace(/^-/u, ""))) {
|
|
610
|
+
return `${model.trim()} is a Contributor-tier model: Meta trains on this tier's prompts and completions, so every file this session reads is disclosed. Refused regardless of policy; no grant widens it.`;
|
|
611
|
+
}
|
|
612
|
+
return null;
|
|
339
613
|
}
|
|
340
614
|
function readString(source, key) {
|
|
341
615
|
const value = source[key];
|
|
342
616
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
343
617
|
}
|
|
618
|
+
/**
|
|
619
|
+
* The snake_case key, or the camelCase one when the adapter speaks that
|
|
620
|
+
* dialect (APRV-243).
|
|
621
|
+
*
|
|
622
|
+
* snake_case is read FIRST in both dialects, so an envelope carrying both
|
|
623
|
+
* spellings resolves the same way for every harness and cannot be used to show
|
|
624
|
+
* one command to the classifier and another to the harness.
|
|
625
|
+
*/
|
|
626
|
+
function readDialect(source, snake, camel, camelCase) {
|
|
627
|
+
const value = source[snake];
|
|
628
|
+
if (value !== undefined)
|
|
629
|
+
return value;
|
|
630
|
+
return camelCase ? source[camel] : undefined;
|
|
631
|
+
}
|
|
632
|
+
function readDialectString(source, snake, camel, camelCase) {
|
|
633
|
+
const value = readDialect(source, snake, camel, camelCase);
|
|
634
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
635
|
+
}
|
|
344
636
|
/**
|
|
345
637
|
* Parse the PreToolUse JSON.
|
|
346
638
|
*
|
|
@@ -350,7 +642,7 @@ function readString(source, key) {
|
|
|
350
642
|
* and a gate that read the subject's own account of its intent would be letting
|
|
351
643
|
* a self-reported field reduce scrutiny (SPEC.md §11.1).
|
|
352
644
|
*/
|
|
353
|
-
function parseHookInput(raw) {
|
|
645
|
+
function parseHookInput(raw, camelCase = false) {
|
|
354
646
|
if (raw.trim().length === 0)
|
|
355
647
|
return { ok: false, detail: "hook stdin was empty" };
|
|
356
648
|
let parsed;
|
|
@@ -367,15 +659,19 @@ function parseHookInput(raw) {
|
|
|
367
659
|
return { ok: false, detail: "hook stdin is not a JSON object" };
|
|
368
660
|
}
|
|
369
661
|
const fields = parsed;
|
|
370
|
-
const toolName =
|
|
371
|
-
if (toolName === null)
|
|
372
|
-
return {
|
|
373
|
-
|
|
662
|
+
const toolName = readDialectString(fields, "tool_name", "toolName", camelCase);
|
|
663
|
+
if (toolName === null) {
|
|
664
|
+
return {
|
|
665
|
+
ok: false,
|
|
666
|
+
detail: camelCase ? "hook input has no tool_name or toolName" : "hook input has no tool_name",
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
const toolInputValue = readDialect(fields, "tool_input", "toolInput", camelCase);
|
|
374
670
|
const toolInput = typeof toolInputValue === "object" && toolInputValue !== null && !Array.isArray(toolInputValue)
|
|
375
671
|
? toolInputValue
|
|
376
672
|
: {};
|
|
377
|
-
const responseValue = fields
|
|
378
|
-
const sessionId =
|
|
673
|
+
const responseValue = readDialect(fields, "tool_response", "toolResponse", camelCase);
|
|
674
|
+
const sessionId = readDialectString(fields, "session_id", "sessionId", camelCase);
|
|
379
675
|
return {
|
|
380
676
|
ok: true,
|
|
381
677
|
input: {
|
|
@@ -384,13 +680,17 @@ function parseHookInput(raw) {
|
|
|
384
680
|
// slower, which is the fail-closed direction.
|
|
385
681
|
sessionId: sessionId ?? UNKNOWN_SESSION,
|
|
386
682
|
sessionIdPresent: sessionId !== null,
|
|
683
|
+
// Grok Build sends `workspaceRoot` beside `cwd`. Only `cwd` is read:
|
|
684
|
+
// the classifier resolves paths against the directory the command will
|
|
685
|
+
// actually run in, and a workspace root is a different fact.
|
|
387
686
|
cwd: readString(fields, "cwd") ?? "",
|
|
388
687
|
toolName,
|
|
389
688
|
toolInput,
|
|
390
|
-
toolUseId:
|
|
391
|
-
hookEventName:
|
|
689
|
+
toolUseId: readDialectString(fields, "tool_use_id", "toolUseId", camelCase),
|
|
690
|
+
hookEventName: readDialectString(fields, "hook_event_name", "hookEventName", camelCase),
|
|
691
|
+
model: readDialectString(fields, "model", "model", camelCase),
|
|
392
692
|
harnessVersion: readString(fields, "version"),
|
|
393
|
-
interrupted: fields["is_interrupt"] === true,
|
|
693
|
+
interrupted: fields["is_interrupt"] === true || (camelCase && fields["isInterrupt"] === true),
|
|
394
694
|
toolResponse: typeof responseValue === "object" && responseValue !== null && !Array.isArray(responseValue)
|
|
395
695
|
? responseValue
|
|
396
696
|
: null,
|
|
@@ -842,22 +1142,166 @@ export function refineScratchDelete(result, roots) {
|
|
|
842
1142
|
}
|
|
843
1143
|
return { result: { ok: true, segments, classes }, notes };
|
|
844
1144
|
}
|
|
1145
|
+
// ===========================================================================
|
|
1146
|
+
// Read scope, the disk half (APRV-347)
|
|
1147
|
+
// ===========================================================================
|
|
1148
|
+
/**
|
|
1149
|
+
* ## Why reads need a second pass too
|
|
1150
|
+
*
|
|
1151
|
+
* The pure classifier can settle an ABSOLUTE read target against the roots and
|
|
1152
|
+
* nothing else. `cat ../other/secrets`, `grep -r needle ./link-to-elsewhere`
|
|
1153
|
+
* and a bare `ls` all mean something only once a working directory and the
|
|
1154
|
+
* filesystem's own links are in hand, and those are exactly the spellings an
|
|
1155
|
+
* agent reaches for. So the read rule has the same two-part shape the delete
|
|
1156
|
+
* rule has: the text decides what text can decide, and this pass — which
|
|
1157
|
+
* resolves against the hook's OWN directory, follows links, and answers `null`
|
|
1158
|
+
* for anything it cannot resolve — tightens `read.shell` back to
|
|
1159
|
+
* `read.file.out_of_scope`.
|
|
1160
|
+
*
|
|
1161
|
+
* It only ever moves a segment toward the stricter class. A caller that skipped
|
|
1162
|
+
* it would be no more permissive than one that runs it, which is what lets
|
|
1163
|
+
* `hook classify` and `hook <harness>` share it without either becoming the
|
|
1164
|
+
* authority.
|
|
1165
|
+
*/
|
|
1166
|
+
/** The rule a read tightened by this pass reports. */
|
|
1167
|
+
const READ_SCOPE_REJECTED_RULE = "read-out-of-scope-resolved";
|
|
1168
|
+
/**
|
|
1169
|
+
* The read roots this process may vouch for, resolved.
|
|
1170
|
+
*
|
|
1171
|
+
* The gate root is the directory the hook resolved its POLICY from, never the
|
|
1172
|
+
* harness-supplied `cwd`: a scope the subject of the gate could choose is not a
|
|
1173
|
+
* scope (SPEC.md §11.1, self-reported fields never reduce scrutiny). The
|
|
1174
|
+
* scratchpad and temp roots are the ones `resolveScratchRoots` already computes
|
|
1175
|
+
* and already guards, so the two rules cannot disagree about where the agent's
|
|
1176
|
+
* own scratch is. `declared` is `read_scope.roots` out of the loaded policy,
|
|
1177
|
+
* which may only widen this set.
|
|
1178
|
+
*/
|
|
1179
|
+
export function resolveReadRoots(cwd, gateRoot, declared) {
|
|
1180
|
+
const roots = effectiveReadRoots({
|
|
1181
|
+
gateRoot: resolvedPath(gateRoot) ?? gateRoot,
|
|
1182
|
+
systemRoots: resolveScratchRoots(cwd),
|
|
1183
|
+
...(declared === undefined ? {} : { declared }),
|
|
1184
|
+
});
|
|
1185
|
+
const out = [];
|
|
1186
|
+
for (const root of roots) {
|
|
1187
|
+
const resolved = resolvedPath(root) ?? root;
|
|
1188
|
+
if (!out.includes(resolved))
|
|
1189
|
+
out.push(resolved);
|
|
1190
|
+
}
|
|
1191
|
+
return out;
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Where this target really is, or `null` when nothing can say.
|
|
1195
|
+
*
|
|
1196
|
+
* The same walk `targetStaysInScratch` does, and for the same reason: the file
|
|
1197
|
+
* may not exist yet (or at all), so the nearest EXISTING ancestor is resolved
|
|
1198
|
+
* and the unresolved tail re-appended. A symlink anywhere in that chain
|
|
1199
|
+
* therefore cannot smuggle a read out of the root, which is the escape the pure
|
|
1200
|
+
* half cannot see.
|
|
1201
|
+
*/
|
|
1202
|
+
function resolvedReadTarget(target, cwd) {
|
|
1203
|
+
let existing = isAbsolute(target) ? target : resolvePathSegments(cwd, target);
|
|
1204
|
+
const tail = [];
|
|
1205
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
1206
|
+
if (existsSync(existing))
|
|
1207
|
+
break;
|
|
1208
|
+
const up = dirname(existing);
|
|
1209
|
+
if (up === existing)
|
|
1210
|
+
return null;
|
|
1211
|
+
tail.unshift(basename(existing));
|
|
1212
|
+
existing = up;
|
|
1213
|
+
}
|
|
1214
|
+
const resolved = resolvedPath(existing);
|
|
1215
|
+
if (resolved === null)
|
|
1216
|
+
return null;
|
|
1217
|
+
return tail.length === 0 ? resolved : join(resolved, ...tail);
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Tighten a `read.shell` segment to `read.file.out_of_scope` wherever the disk
|
|
1221
|
+
* disagrees with the text.
|
|
1222
|
+
*
|
|
1223
|
+
* IMPURE by design and by contract. `roots` empty means the caller asked for no
|
|
1224
|
+
* read scoping at all, and every segment is returned untouched — the same
|
|
1225
|
+
* "absent yields today's answer" the classifier context promises.
|
|
1226
|
+
*/
|
|
1227
|
+
export function refineReadScope(result, roots, cwd) {
|
|
1228
|
+
if (!result.ok)
|
|
1229
|
+
return { result, notes: [] };
|
|
1230
|
+
if (roots.length === 0)
|
|
1231
|
+
return { result, notes: [] };
|
|
1232
|
+
if (!result.segments.some((segment) => segment.class === "read.shell")) {
|
|
1233
|
+
return { result, notes: [] };
|
|
1234
|
+
}
|
|
1235
|
+
const notes = [];
|
|
1236
|
+
const segments = result.segments.map((segment) => {
|
|
1237
|
+
if (segment.class !== "read.shell")
|
|
1238
|
+
return segment;
|
|
1239
|
+
const words = commandSegmentWords(segment.text);
|
|
1240
|
+
const parsed = words === null ? undefined : words[0];
|
|
1241
|
+
if (parsed === undefined)
|
|
1242
|
+
return segment;
|
|
1243
|
+
const positionals = parsed.args.filter((arg) => !arg.startsWith("-") || arg === "-");
|
|
1244
|
+
const declaredTargets = readTargetsOf(parsed.bin, positionals, parsed.args);
|
|
1245
|
+
if (declaredTargets === null)
|
|
1246
|
+
return segment;
|
|
1247
|
+
// No operand is not "no read": `ls`, `find` and a piped `grep needle` all
|
|
1248
|
+
// read the working directory, so the working directory is what is checked.
|
|
1249
|
+
const targets = declaredTargets.length === 0 ? ["."] : declaredTargets;
|
|
1250
|
+
const reject = (path, detail) => {
|
|
1251
|
+
notes.push(`${READ_SCOPE_REJECTED_RULE}: ${detail}`);
|
|
1252
|
+
return {
|
|
1253
|
+
...segment,
|
|
1254
|
+
class: READ_OUT_OF_SCOPE_CLASS,
|
|
1255
|
+
rule: READ_SCOPE_REJECTED_RULE,
|
|
1256
|
+
path,
|
|
1257
|
+
};
|
|
1258
|
+
};
|
|
1259
|
+
for (const target of targets) {
|
|
1260
|
+
const resolved = resolvedReadTarget(target, cwd);
|
|
1261
|
+
if (resolved === null) {
|
|
1262
|
+
return reject(target, `${target} does not resolve to any path this hook can see, so \`${segment.text}\` is ${READ_OUT_OF_SCOPE_CLASS}`);
|
|
1263
|
+
}
|
|
1264
|
+
if (!isInReadScope(resolved, roots)) {
|
|
1265
|
+
return reject(resolved, `${target} resolves to ${resolved}, which is outside the read scope (${renderReadRoots(roots)}), so \`${segment.text}\` is ${READ_OUT_OF_SCOPE_CLASS}`);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return segment;
|
|
1269
|
+
});
|
|
1270
|
+
if (notes.length === 0)
|
|
1271
|
+
return { result, notes };
|
|
1272
|
+
const classes = [];
|
|
1273
|
+
for (const segment of segments) {
|
|
1274
|
+
if (!classes.includes(segment.class))
|
|
1275
|
+
classes.push(segment.class);
|
|
1276
|
+
}
|
|
1277
|
+
return { result: { ok: true, segments, classes }, notes };
|
|
1278
|
+
}
|
|
845
1279
|
/**
|
|
846
|
-
* The classifier, its
|
|
1280
|
+
* The classifier, its context, and all three impure refinements, in the one
|
|
847
1281
|
* order every caller must use.
|
|
848
1282
|
*
|
|
849
1283
|
* `hook classify` printing a different class from the one `hook claude-code`
|
|
850
1284
|
* decides would make the explainer a different program (APRV-108's note), and
|
|
851
|
-
* that stays true now there are
|
|
1285
|
+
* that stays true now there are three refinements in the chain.
|
|
1286
|
+
*
|
|
1287
|
+
* `readRoots` is the one argument whose ABSENCE is the loose answer rather than
|
|
1288
|
+
* the strict one (APRV-347), so it is passed explicitly at every call site: an
|
|
1289
|
+
* empty list means "do not scope reads", which is what every caller outside a
|
|
1290
|
+
* resolved gate scope wants and what this classifier did before the field
|
|
1291
|
+
* existed.
|
|
852
1292
|
*/
|
|
853
|
-
export function classifyForHook(command, protectedPaths, cwd) {
|
|
1293
|
+
export function classifyForHook(command, protectedPaths, cwd, readRoots = []) {
|
|
854
1294
|
const roots = resolveScratchRoots(cwd);
|
|
855
|
-
const classified = classifyCommand(command, protectedPaths, {
|
|
1295
|
+
const classified = classifyCommand(command, protectedPaths, {
|
|
1296
|
+
scratchRoots: roots,
|
|
1297
|
+
...(readRoots.length === 0 ? {} : { readRoots }),
|
|
1298
|
+
});
|
|
856
1299
|
const rewritten = refineRewrite(classified, cwd);
|
|
857
1300
|
const scratched = refineScratchDelete(rewritten.result, roots);
|
|
1301
|
+
const scoped = refineReadScope(scratched.result, readRoots, cwd);
|
|
858
1302
|
return {
|
|
859
|
-
result:
|
|
860
|
-
notes: [...rewritten.notes, ...scratched.notes],
|
|
1303
|
+
result: scoped.result,
|
|
1304
|
+
notes: [...rewritten.notes, ...scratched.notes, ...scoped.notes],
|
|
861
1305
|
};
|
|
862
1306
|
}
|
|
863
1307
|
// ===========================================================================
|
|
@@ -913,7 +1357,7 @@ function commandClassify(argv, streams, cwd) {
|
|
|
913
1357
|
if (command.length === 0) {
|
|
914
1358
|
return usageError(streams, "missing <command> argument for `approval hook classify`");
|
|
915
1359
|
}
|
|
916
|
-
const { options } = hookScope(parsed.flags, cwd);
|
|
1360
|
+
const { root, options } = hookScope(parsed.flags, cwd);
|
|
917
1361
|
const load = loadPolicy(options.policy?.file === undefined
|
|
918
1362
|
? { dir: options.policy?.dir ?? cwd }
|
|
919
1363
|
: { file: options.policy.file });
|
|
@@ -922,10 +1366,13 @@ function commandClassify(argv, streams, cwd) {
|
|
|
922
1366
|
}
|
|
923
1367
|
const protectedPaths = load.ok ? (load.policy.protected_paths ?? []) : [];
|
|
924
1368
|
// The same impure refinements `hook claude-code` applies (APRV-108,
|
|
925
|
-
// APRV-267), run against the same directory
|
|
926
|
-
// pure class where the hook decides a refined one
|
|
927
|
-
// different program.
|
|
928
|
-
|
|
1369
|
+
// APRV-267, APRV-347), run against the same directory and the same roots: an
|
|
1370
|
+
// explainer that printed the pure class where the hook decides a refined one
|
|
1371
|
+
// would be explaining a different program. A policy that did not load
|
|
1372
|
+
// contributes no `read_scope`, so the scope is the built-in roots alone,
|
|
1373
|
+
// which is the narrower answer and is what the hook itself would refuse on.
|
|
1374
|
+
const readRoots = resolveReadRoots(cwd, policyRootOf(load, root), load.ok ? load.policy.read_scope?.roots : undefined);
|
|
1375
|
+
streams.out(renderClassification(classifyForHook(command, protectedPaths, cwd, readRoots).result, boolFlag(parsed.flags, "--json")));
|
|
929
1376
|
return EXIT_OK;
|
|
930
1377
|
}
|
|
931
1378
|
function sleepSync(ms) {
|
|
@@ -1053,6 +1500,132 @@ function tierOf(target, cwd) {
|
|
|
1053
1500
|
* non-protected target, which is why the loop floor could not see an Edit.
|
|
1054
1501
|
*/
|
|
1055
1502
|
const WORKSPACE_WRITE_CLASS = "files.write.workspace";
|
|
1503
|
+
/**
|
|
1504
|
+
* The Codex app-server's change set, when a call carries one (APRV-363,
|
|
1505
|
+
* APRV-379).
|
|
1506
|
+
*
|
|
1507
|
+
* TWO SHAPES, because the protocol has two. The LEGACY `applyPatchApproval`
|
|
1508
|
+
* carries `fileChanges`, a map of path to change, inline on the request. The
|
|
1509
|
+
* ITEM-BASED API puts the same material on an earlier `item/started` frame as
|
|
1510
|
+
* an ARRAY of `{path, kind, diff}`, and the bridge correlates that frame to the
|
|
1511
|
+
* approval request by item id before handing it here (APRV-379). Both arrive as
|
|
1512
|
+
* the server sent them and neither is re-rendered into the other.
|
|
1513
|
+
*
|
|
1514
|
+
* `null` for every other `apply_patch` call, which keeps the envelope path
|
|
1515
|
+
* exactly as it was: the native hook's `apply_patch` tool sends a command
|
|
1516
|
+
* string and reaches this function's `null` on the first test.
|
|
1517
|
+
*/
|
|
1518
|
+
function codexFileChanges(toolInput) {
|
|
1519
|
+
const value = toolInput["file_changes"] ?? toolInput["fileChanges"];
|
|
1520
|
+
if (value === null || typeof value !== "object")
|
|
1521
|
+
return null;
|
|
1522
|
+
if (Array.isArray(value))
|
|
1523
|
+
return value.length === 0 ? null : value;
|
|
1524
|
+
const map = value;
|
|
1525
|
+
return Object.keys(map).length === 0 ? null : map;
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* The paths a change set names, in the order this runtime will classify them,
|
|
1529
|
+
* or `null` when an entry names none (APRV-379).
|
|
1530
|
+
*
|
|
1531
|
+
* The map's keys ARE its paths, sorted so one change set is one description
|
|
1532
|
+
* whatever key order the server used. The array's entries each carry a `path`
|
|
1533
|
+
* string, and the order is the server's own: an array is a sequence and
|
|
1534
|
+
* reordering it would be this runtime describing a change set nobody sent. An
|
|
1535
|
+
* entry that is not an object, or whose `path` is not a string, yields `null`
|
|
1536
|
+
* and the caller refuses the whole set, because a change set with one
|
|
1537
|
+
* unreadable member is a change set this runtime cannot say the extent of.
|
|
1538
|
+
*/
|
|
1539
|
+
function codexChangePaths(changes) {
|
|
1540
|
+
if (!Array.isArray(changes))
|
|
1541
|
+
return Object.keys(changes).sort();
|
|
1542
|
+
const paths = [];
|
|
1543
|
+
for (const entry of changes) {
|
|
1544
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
|
|
1545
|
+
return null;
|
|
1546
|
+
const declared = entry["path"];
|
|
1547
|
+
if (typeof declared !== "string")
|
|
1548
|
+
return null;
|
|
1549
|
+
paths.push(declared);
|
|
1550
|
+
}
|
|
1551
|
+
return paths;
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* What a change SET asks for: one class per path it names, and the change bound
|
|
1555
|
+
* whole (APRV-363, APRV-379).
|
|
1556
|
+
*
|
|
1557
|
+
* The class rule is the file tools' rule and not a second one: a protected
|
|
1558
|
+
* target takes its derived protected class, every other target is
|
|
1559
|
+
* `files.write.workspace`, and the policy decides the autonomy of either. The
|
|
1560
|
+
* payload is the change rather than the touch, for the reason
|
|
1561
|
+
* {@link fileToolGate} states at length, plus `content_sha256` over the set as
|
|
1562
|
+
* it ARRIVED, so a human's grant binds the bytes the server sent and a later
|
|
1563
|
+
* reader can check that it did. The set goes into the payload in the shape it
|
|
1564
|
+
* arrived in, map or array; nothing here reads `diff`, `kind` or any other
|
|
1565
|
+
* member, because one observed `add` is not a licence to parse Codex patch
|
|
1566
|
+
* semantics in this repository.
|
|
1567
|
+
*
|
|
1568
|
+
* Fails closed on a path this runtime cannot place: one that lands outside the
|
|
1569
|
+
* directory the server named, or one carrying a NUL. A change whose target
|
|
1570
|
+
* cannot be resolved cannot be classified, and a classification against the
|
|
1571
|
+
* wrong tree is the failure this whole file exists to avoid.
|
|
1572
|
+
*
|
|
1573
|
+
* An ABSOLUTE path is accepted when it resolves INSIDE that directory, which
|
|
1574
|
+
* APRV-379 changed: the observed item frame names absolute paths
|
|
1575
|
+
* (`docs/codex-app-server-bridge.md`, question 1), and an absolute path inside
|
|
1576
|
+
* the directory is exactly as placeable as a relative one. Containment is what
|
|
1577
|
+
* was ever doing the work here, and it still decides: a path outside is refused
|
|
1578
|
+
* whichever way it was spelled.
|
|
1579
|
+
*/
|
|
1580
|
+
function describeCodexFileChanges(changes, protectedPaths, cwd) {
|
|
1581
|
+
const classes = [];
|
|
1582
|
+
const notes = [];
|
|
1583
|
+
const paths = [];
|
|
1584
|
+
const declaredPaths = codexChangePaths(changes);
|
|
1585
|
+
if (declaredPaths === null) {
|
|
1586
|
+
return {
|
|
1587
|
+
kind: "deny",
|
|
1588
|
+
code: "hook-io",
|
|
1589
|
+
detail: "the file change set carries an entry that names no path string, so the extent of the change cannot be stated; nothing was classified",
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
for (const declared of declaredPaths) {
|
|
1593
|
+
if (declared.length === 0 || declared.includes("\0")) {
|
|
1594
|
+
return {
|
|
1595
|
+
kind: "deny",
|
|
1596
|
+
code: "hook-io",
|
|
1597
|
+
detail: `the file change names ${JSON.stringify(declared)}, which is not a path this runtime can place; a change whose target cannot be placed cannot be classified`,
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
const resolved = resolvePathSegments(cwd, declared);
|
|
1601
|
+
if (resolved !== cwd && !resolved.startsWith(`${cwd}${sep}`)) {
|
|
1602
|
+
return {
|
|
1603
|
+
kind: "deny",
|
|
1604
|
+
code: "hook-io",
|
|
1605
|
+
detail: `the file change names ${JSON.stringify(declared)}, which resolves outside ${cwd}`,
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
paths.push(declared);
|
|
1609
|
+
const cls = protectedPathClass(resolved, protectedPaths) ?? WORKSPACE_WRITE_CLASS;
|
|
1610
|
+
if (!classes.includes(cls))
|
|
1611
|
+
classes.push(cls);
|
|
1612
|
+
notes.push(`change ${declared} (${cls})`);
|
|
1613
|
+
}
|
|
1614
|
+
return {
|
|
1615
|
+
kind: "gated",
|
|
1616
|
+
classes,
|
|
1617
|
+
payload: {
|
|
1618
|
+
tool: "apply_patch",
|
|
1619
|
+
rule: "codex app-server file change",
|
|
1620
|
+
cwd,
|
|
1621
|
+
paths,
|
|
1622
|
+
content_sha256: payloadHash(changes),
|
|
1623
|
+
changes,
|
|
1624
|
+
},
|
|
1625
|
+
headline: `apply_patch ${String(paths.length)} file change(s)`,
|
|
1626
|
+
notes,
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1056
1629
|
/**
|
|
1057
1630
|
* What a non-Bash tool call asks for, or `null` when it names no file at all.
|
|
1058
1631
|
*
|
|
@@ -1149,6 +1722,68 @@ function fileToolGate(toolName, toolInput, protectedPaths, cwd) {
|
|
|
1149
1722
|
summary: summaryFor(tier, toolName, file),
|
|
1150
1723
|
};
|
|
1151
1724
|
}
|
|
1725
|
+
/**
|
|
1726
|
+
* The first entry of a `paths` array that falls outside the read scope, or
|
|
1727
|
+
* `null` when every entry is inside it (APRV-350).
|
|
1728
|
+
*
|
|
1729
|
+
* Separate from the single-path arm because the ANSWER is different: one path
|
|
1730
|
+
* either is or is not in scope, while a list is out of scope if ANY member is.
|
|
1731
|
+
* Returning the offending member rather than a boolean keeps the gated question
|
|
1732
|
+
* specific — an approver is asked about the directory that actually left the
|
|
1733
|
+
* scope, not about the whole list.
|
|
1734
|
+
*/
|
|
1735
|
+
function firstOutOfScope(toolInput, roots, cwd) {
|
|
1736
|
+
const paths = toolInput["paths"];
|
|
1737
|
+
if (!Array.isArray(paths))
|
|
1738
|
+
return null;
|
|
1739
|
+
for (const entry of paths) {
|
|
1740
|
+
if (typeof entry !== "string" || entry.trim() === "")
|
|
1741
|
+
continue;
|
|
1742
|
+
const resolved = resolvedReadTarget(entry, cwd);
|
|
1743
|
+
if (resolved === null || !isInReadScope(resolved, roots))
|
|
1744
|
+
return entry;
|
|
1745
|
+
}
|
|
1746
|
+
return null;
|
|
1747
|
+
}
|
|
1748
|
+
function readToolGate(toolName, toolInput, roots, cwd) {
|
|
1749
|
+
if (roots.length === 0)
|
|
1750
|
+
return null;
|
|
1751
|
+
const declared = readString(toolInput, "file_path") ??
|
|
1752
|
+
readString(toolInput, "notebook_path") ??
|
|
1753
|
+
readString(toolInput, "path") ??
|
|
1754
|
+
// APRV-350: Muse's `search` names an ARRAY under `paths`, and the live
|
|
1755
|
+
// capture caught one pointing clean out of the workspace. The FIRST entry
|
|
1756
|
+
// that resolves outside the scope is the one gated: a search over five
|
|
1757
|
+
// directories where one is out of scope is an out-of-scope read, and
|
|
1758
|
+
// gating the first in-scope entry instead would have let it through. An
|
|
1759
|
+
// unreadable entry counts as out of scope for the same fail-closed reason
|
|
1760
|
+
// the single-path arm below resolves that way.
|
|
1761
|
+
firstOutOfScope(toolInput, roots, cwd);
|
|
1762
|
+
if (declared === null)
|
|
1763
|
+
return null;
|
|
1764
|
+
// FAIL CLOSED on an unresolvable path (the task's AC1, and SPEC.md §11.1):
|
|
1765
|
+
// a target nothing on this disk can place is a target nothing can say is
|
|
1766
|
+
// inside the scope, so it is out of it.
|
|
1767
|
+
const resolved = resolvedReadTarget(declared, cwd);
|
|
1768
|
+
if (resolved !== null && isInReadScope(resolved, roots))
|
|
1769
|
+
return null;
|
|
1770
|
+
const file = resolved ?? absolute(declared, cwd);
|
|
1771
|
+
const input = { ...toolInput };
|
|
1772
|
+
delete input["description"];
|
|
1773
|
+
return {
|
|
1774
|
+
file,
|
|
1775
|
+
declared,
|
|
1776
|
+
// The binding bytes name the RESOLVED path as well as the tool's own input,
|
|
1777
|
+
// so an approver reads where the data actually comes from and a grant over
|
|
1778
|
+
// one spelling cannot be spent on another.
|
|
1779
|
+
payload: { tool: toolName, rule: "read-scope", file, input },
|
|
1780
|
+
summary: `${toolName} ${file} (outside the read scope)`,
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
/** The verdict note for a gated read: what was asked for, and against what. */
|
|
1784
|
+
function readScopeNote(gated, roots) {
|
|
1785
|
+
return `read-scope: ${gated.declared} resolves to ${gated.file}, which is outside the read scope (${renderReadRoots(roots)})`;
|
|
1786
|
+
}
|
|
1152
1787
|
/** The headline for a tier: the qualifier first, the touch after it. */
|
|
1153
1788
|
function summaryFor(tier, toolName, file) {
|
|
1154
1789
|
if (tier.worktree !== null) {
|
|
@@ -1567,44 +2202,19 @@ function announceWait(streams, run, waiting) {
|
|
|
1567
2202
|
streams.err(`approval: no listener is running for this log (${listener.reason}: ${socket}), so the request above may sit undelivered and a decision may go unconsumed. Start the gate's ambient runtime in the checkout that owns this log: \`eval "$(approval env)" && approval up\`, which runs the daemon loop and every configured channel in one process.\n`);
|
|
1568
2203
|
}
|
|
1569
2204
|
/**
|
|
1570
|
-
* The
|
|
1571
|
-
*
|
|
1572
|
-
* whatever verdict it printed.
|
|
1573
|
-
*
|
|
1574
|
-
* ## Requests are keyed by bytes, not by invocation (APRV-117)
|
|
1575
|
-
*
|
|
1576
|
-
* The action key is still `hook:<session>:<tool-use id>:<class>` and is still
|
|
1577
|
-
* unique per invocation — what changed is that intake LOOKS for an earlier
|
|
1578
|
-
* request about the same `{command, cwd}` before opening a new one, matching on
|
|
1579
|
-
* the `payload_hash` recorded on `approval.requested`. Three outcomes per class,
|
|
1580
|
-
* decided by `core/gate.ts`'s `findHarnessCarry`:
|
|
1581
|
-
*
|
|
1582
|
-
* - nothing to carry: register and request, exactly as before;
|
|
1583
|
-
* - a pending request: **adopt** it — wait out the remainder of this
|
|
1584
|
-
* invocation's window on somebody else's key, opening nothing. The approver's
|
|
1585
|
-
* phone never shows two prompts for one command, because there is only ever
|
|
1586
|
-
* one question;
|
|
1587
|
-
* - an unspent grant inside the TTL: **carry** it — no wait, no prompt, and
|
|
1588
|
-
* the grant is spent (once) before the allow is printed.
|
|
2205
|
+
* The hook's own rendering of a verdict: one decision object on stdout, in the
|
|
2206
|
+
* dialect of the harness that asked.
|
|
1589
2207
|
*
|
|
1590
|
-
*
|
|
1591
|
-
*
|
|
1592
|
-
*
|
|
1593
|
-
* call was a new request with a new key and a late tap therefore authorized
|
|
1594
|
-
* nothing: the human spent attention on a question whose asker had left. The
|
|
1595
|
-
* carryover above removes the premise. A late tap now authorizes the retry, so
|
|
1596
|
-
* the request stays open for the policy's TTL and the timeout says so.
|
|
1597
|
-
*
|
|
1598
|
-
* What still withdraws is every path where nothing can adopt the question: a
|
|
1599
|
-
* SIGTERM or SIGINT (the session is going away), a thrown failure, and an intake
|
|
1600
|
-
* refusal partway through a multi-class command (the command cannot proceed on
|
|
1601
|
-
* any retry, so the classes already opened are noise in a human's queue). The
|
|
1602
|
-
* signal handlers are installed for the duration of the wait ONLY, and removed
|
|
1603
|
-
* in `finally`: a hook process is short-lived and borrowing the harness's
|
|
1604
|
-
* signal disposition for longer than the loop would be a side effect nobody
|
|
1605
|
-
* asked for.
|
|
2208
|
+
* The one place a verdict becomes bytes, so the Codex guard inside
|
|
2209
|
+
* {@link allow} — an allow that lost its exact bound `tool_input.command` is an
|
|
2210
|
+
* I/O failure rather than a permission — still stands over every path.
|
|
1606
2211
|
*/
|
|
1607
|
-
function
|
|
2212
|
+
function renderVerdict(streams, adapter, codexCommand, verdict) {
|
|
2213
|
+
return verdict.permission === "allow"
|
|
2214
|
+
? allow(streams, verdict.reason, adapter.kind, codexCommand)
|
|
2215
|
+
: deny(streams, verdict.code, verdict.detail, adapter.kind);
|
|
2216
|
+
}
|
|
2217
|
+
export function gateHarnessCall(streams, run, classes,
|
|
1608
2218
|
/**
|
|
1609
2219
|
* The bytes the grant binds to: `{command, cwd}` for a Bash call, the change
|
|
1610
2220
|
* itself for a file tool (APRV-124). Whatever this is, it is what reaches the
|
|
@@ -1655,18 +2265,22 @@ floor = null) {
|
|
|
1655
2265
|
const floorApplies = (cls) => floor !== null && isSideEffectingClass(cls);
|
|
1656
2266
|
const hash = payloadHash(payload);
|
|
1657
2267
|
const summary = truncate(headline, SUMMARY_LIMIT);
|
|
1658
|
-
const sayAllow = (reason) => allow
|
|
2268
|
+
const sayAllow = (reason) => ({ permission: "allow", reason });
|
|
1659
2269
|
/**
|
|
1660
|
-
* Every deny this function can
|
|
2270
|
+
* Every deny this function can reach, with the floor's own sentence appended
|
|
1661
2271
|
* when a floor is what routed the command here (APRV-280). One wrapper rather
|
|
1662
2272
|
* than a sentence bolted onto the timeout alone: a floored invocation that
|
|
1663
2273
|
* ends in a rejection, a lapse or an I/O fault leaves the agent in exactly the
|
|
1664
2274
|
* same place, and the operator reading the harness's error stream needs the
|
|
1665
2275
|
* scope key either way.
|
|
1666
2276
|
*/
|
|
1667
|
-
const sayDeny = (code, detail) =>
|
|
1668
|
-
|
|
1669
|
-
|
|
2277
|
+
const sayDeny = (code, detail) => ({
|
|
2278
|
+
permission: "deny",
|
|
2279
|
+
code,
|
|
2280
|
+
detail: floor === null
|
|
2281
|
+
? detail
|
|
2282
|
+
: `${detail} This tool call was routed to a human by loop safety rather than by policy — loop-escalated: ${floor.scope} ${floor.key} has ${String(floor.consecutiveFailures)} consecutive failed side-effecting harness tool calls (amended SPEC.md §10.2). ${loopClearance(floor.scope, floor.key)}`,
|
|
2283
|
+
});
|
|
1670
2284
|
// Intake reads the VERIFIED log, once, before anything is written: an
|
|
1671
2285
|
// enforcement path reads nothing else (SPEC.md §11.1), and a carry decided
|
|
1672
2286
|
// from unverified bytes would be a grant invented by whoever could write the
|
|
@@ -2093,9 +2707,71 @@ function report(streams, code, detail, extra = {}) {
|
|
|
2093
2707
|
* whether two enumerated fields are present and what kind of value they hold.
|
|
2094
2708
|
* No text from any of them reaches the log or this function's return.
|
|
2095
2709
|
*/
|
|
2710
|
+
/**
|
|
2711
|
+
* Muse's outcome, which it actually sends (APRV-350).
|
|
2712
|
+
*
|
|
2713
|
+
* The shell tool's `tool_response` is a JSON STRING, not an object, carrying
|
|
2714
|
+
* `exit_code`, `terminal_status`, `output` and `truncated`. The generic reader
|
|
2715
|
+
* above would see a string, find neither `interrupted` nor `error` on it, and
|
|
2716
|
+
* call every command completed — including the ones that failed. So Muse gets
|
|
2717
|
+
* its own reading, and it is the richer one: this harness sends both facts
|
|
2718
|
+
* Codex lacks.
|
|
2719
|
+
*
|
|
2720
|
+
* `terminal_status` leads because it distinguishes a command that ran from one
|
|
2721
|
+
* that was killed or timed out; `exit_code` decides the rest. Anything this
|
|
2722
|
+
* cannot read is UNREADABLE rather than assumed complete, which appends nothing
|
|
2723
|
+
* and leaves the path as vacuous as it was — the same choice the generic reader
|
|
2724
|
+
* makes, for the same reason. Nothing of `output` is read.
|
|
2725
|
+
*/
|
|
2726
|
+
function readMuseReportedOutcome(input) {
|
|
2727
|
+
const raw = input.toolResponseRaw;
|
|
2728
|
+
// The file tools answer with an object (`filePath`, `structuredPatch`) and
|
|
2729
|
+
// the read tools with a plain string of file content. Neither carries an
|
|
2730
|
+
// outcome, and the event name is the only fact available for them.
|
|
2731
|
+
if (typeof raw !== "string") {
|
|
2732
|
+
return { ok: true, outcome: "completed" };
|
|
2733
|
+
}
|
|
2734
|
+
let body;
|
|
2735
|
+
try {
|
|
2736
|
+
body = JSON.parse(raw);
|
|
2737
|
+
}
|
|
2738
|
+
catch {
|
|
2739
|
+
// A non-JSON string is a read tool's content. It says nothing about
|
|
2740
|
+
// success, and the event fired at all, so the event name stands.
|
|
2741
|
+
return { ok: true, outcome: "completed" };
|
|
2742
|
+
}
|
|
2743
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
2744
|
+
return { ok: true, outcome: "completed" };
|
|
2745
|
+
}
|
|
2746
|
+
const fields = body;
|
|
2747
|
+
const status = fields["terminal_status"];
|
|
2748
|
+
if (typeof status === "string" && status !== "completed") {
|
|
2749
|
+
// `aborted`, `timed_out`, and whatever else Meta adds: a command that did
|
|
2750
|
+
// not finish on its own terms neither completed nor failed, exactly as an
|
|
2751
|
+
// interrupt does not.
|
|
2752
|
+
return {
|
|
2753
|
+
ok: false,
|
|
2754
|
+
detail: `terminal_status is ${JSON.stringify(status)}, so the command neither completed nor failed on its own terms`,
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
const exitCode = fields["exit_code"];
|
|
2758
|
+
if (typeof exitCode === "number") {
|
|
2759
|
+
return { ok: true, outcome: exitCode === 0 ? "completed" : "failed" };
|
|
2760
|
+
}
|
|
2761
|
+
return { ok: true, outcome: "completed" };
|
|
2762
|
+
}
|
|
2096
2763
|
function readReportedOutcome(input, adapter) {
|
|
2097
2764
|
if (adapter.kind === "codex")
|
|
2098
2765
|
return readCodexReportedOutcome(input);
|
|
2766
|
+
if (adapter.kind === "muse") {
|
|
2767
|
+
if (input.hookEventName !== "PostToolUse") {
|
|
2768
|
+
return {
|
|
2769
|
+
ok: false,
|
|
2770
|
+
detail: `hook_event_name is ${input.hookEventName === null ? "absent" : JSON.stringify(input.hookEventName)}, which is not the event this adapter reports an outcome for (PostToolUse)`,
|
|
2771
|
+
};
|
|
2772
|
+
}
|
|
2773
|
+
return readMuseReportedOutcome(input);
|
|
2774
|
+
}
|
|
2099
2775
|
const event = input.hookEventName;
|
|
2100
2776
|
if (event !== "PostToolUse" && event !== "PostToolUseFailure") {
|
|
2101
2777
|
return {
|
|
@@ -2132,7 +2808,12 @@ function readReportedOutcome(input, adapter) {
|
|
|
2132
2808
|
* is the reading of the event and nothing else.
|
|
2133
2809
|
*/
|
|
2134
2810
|
function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
|
|
2135
|
-
if (input.toolName !== adapter.shellTool &&
|
|
2811
|
+
if (input.toolName !== adapter.shellTool &&
|
|
2812
|
+
!adapter.fileTools.includes(input.toolName) &&
|
|
2813
|
+
// APRV-347: a read tool MAY have had a start written for it (one outside
|
|
2814
|
+
// the scope), so it belongs in this set. A read inside the scope wrote
|
|
2815
|
+
// nothing, and the close below finds no start and says so in its own words.
|
|
2816
|
+
!adapter.readTools.includes(input.toolName)) {
|
|
2136
2817
|
return report(streams, "post-tool-not-gated", `${input.toolName} is not a gated tool, so no execution.started was ever written for it`);
|
|
2137
2818
|
}
|
|
2138
2819
|
if (input.toolUseId === null) {
|
|
@@ -2142,9 +2823,19 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
|
|
|
2142
2823
|
// `approval status` rather than closed against a guess.
|
|
2143
2824
|
return report(streams, "post-tool-unidentified", "the event carries no tool_use_id, so the task id the pre-execution hook minted cannot be reconstructed; nothing was appended");
|
|
2144
2825
|
}
|
|
2826
|
+
// APRV-311. The id the pre-execution half minted for this same call, derived
|
|
2827
|
+
// here from the same two native fields it derived it from. It rides the
|
|
2828
|
+
// unreadable arm so that the line naming a start nobody closed also names
|
|
2829
|
+
// WHICH start: on Codex that arm is the only arm, and a diagnostic that
|
|
2830
|
+
// cannot be joined to an `execution.started` leaves an operator grepping a
|
|
2831
|
+
// log for a record they cannot identify. It is the correlation and nothing
|
|
2832
|
+
// more — no outcome is read, inferred, or appended on this path.
|
|
2833
|
+
const task = adapter.kind === "codex"
|
|
2834
|
+
? codexBinding(input, cwd).task
|
|
2835
|
+
: `hook:${input.sessionId}:${input.toolUseId}`;
|
|
2145
2836
|
const reading = readReportedOutcome(input, adapter);
|
|
2146
2837
|
if (!reading.ok) {
|
|
2147
|
-
return report(streams, "post-tool-unreadable-outcome", `${reading.detail}; nothing was appended
|
|
2838
|
+
return report(streams, "post-tool-unreadable-outcome", `${reading.detail}; nothing was appended`, { task });
|
|
2148
2839
|
}
|
|
2149
2840
|
const { logPath, root } = hookScope(flags, cwd);
|
|
2150
2841
|
if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
|
|
@@ -2159,12 +2850,35 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
|
|
|
2159
2850
|
reportedBy: "post-tool-use",
|
|
2160
2851
|
}, actor);
|
|
2161
2852
|
if (!finished.ok) {
|
|
2162
|
-
return report(streams, `post-tool-gate-refused:${finished.code}`, finished.message);
|
|
2853
|
+
return report(streams, `post-tool-gate-refused:${finished.code}`, finished.message, { task });
|
|
2163
2854
|
}
|
|
2164
2855
|
return report(streams, POST_TOOL_REPORTED, `recorded ${reading.outcome} for ${String(finished.records.length)} delegated execution(s) of ${finished.task}`, { task: finished.task, outcome: reading.outcome, appended: finished.records.length });
|
|
2165
2856
|
}
|
|
2166
|
-
function describeToolCall(input, adapter, protectedPaths, cwd) {
|
|
2857
|
+
function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
|
|
2167
2858
|
if (adapter.kind === "codex" && input.toolName === "apply_patch") {
|
|
2859
|
+
// APRV-363. The app-server's LEGACY `applyPatchApproval` carries its change
|
|
2860
|
+
// as a MAP of path to change, inline on the request, and never as an
|
|
2861
|
+
// `apply_patch` envelope. Rendering the map into an envelope so the parser
|
|
2862
|
+
// below could read it would classify bytes this runtime wrote rather than
|
|
2863
|
+
// bytes the server sent, which is the hazard APRV-362 exists for on the
|
|
2864
|
+
// command side, so the map is classified as a map: by the paths it names,
|
|
2865
|
+
// through the same protected-path rules every other file tool uses, with
|
|
2866
|
+
// the change itself bound verbatim. It is described HERE rather than by the
|
|
2867
|
+
// caller because one describer answers "what is this call", and a caller
|
|
2868
|
+
// that could hand this module its own classes would be the party under
|
|
2869
|
+
// oversight choosing its own scrutiny (SPEC §11.1 invariant 4).
|
|
2870
|
+
//
|
|
2871
|
+
// APRV-379 widened the material this branch reads and changed nothing about
|
|
2872
|
+
// the reading. The ITEM-BASED API delivers the same change set as an ARRAY
|
|
2873
|
+
// on an earlier `item/started` frame, and the bridge correlates that frame
|
|
2874
|
+
// to the approval request by item id before calling in here. One describer
|
|
2875
|
+
// still answers the question for both APIs, which is the point: two
|
|
2876
|
+
// describers would be two answers to "what is this call", and the second
|
|
2877
|
+
// one would be the one nobody reviewed.
|
|
2878
|
+
const changes = codexFileChanges(input.toolInput);
|
|
2879
|
+
if (changes !== null) {
|
|
2880
|
+
return describeCodexFileChanges(changes, protectedPaths, cwd);
|
|
2881
|
+
}
|
|
2168
2882
|
const raw = readString(input.toolInput, "command");
|
|
2169
2883
|
if (raw === null) {
|
|
2170
2884
|
return { kind: "deny", code: "hook-io", detail: "apply_patch tool_input carries no command string" };
|
|
@@ -2192,17 +2906,55 @@ function describeToolCall(input, adapter, protectedPaths, cwd) {
|
|
|
2192
2906
|
detail: `${adapter.shellTool} tool_input carries no command string`,
|
|
2193
2907
|
};
|
|
2194
2908
|
}
|
|
2909
|
+
// APRV-362. A call may state the argv its command renders, and the app-server
|
|
2910
|
+
// bridge does, so the payload can name the words the kernel receives rather
|
|
2911
|
+
// than a string somebody still has to parse. Two accounts of one call that
|
|
2912
|
+
// disagree describe no call at all, so the disagreement is refused here
|
|
2913
|
+
// instead of being resolved in favour of either. `codexArgv` accepts an
|
|
2914
|
+
// argv only when the command splits to exactly it, which is why the only
|
|
2915
|
+
// reachable outcomes are "absent", "agrees" and this.
|
|
2916
|
+
if (adapter.kind === "codex" && codexArgvDisagrees(input.toolInput, raw)) {
|
|
2917
|
+
return {
|
|
2918
|
+
kind: "deny",
|
|
2919
|
+
code: "hook-io",
|
|
2920
|
+
detail: "the call states an argv that its own command string does not split to; a payload cannot bind two readings of one command, so nothing was classified",
|
|
2921
|
+
};
|
|
2922
|
+
}
|
|
2923
|
+
// APRV-350: the PER-CALL working directory, where the harness sends one.
|
|
2924
|
+
//
|
|
2925
|
+
// Muse's `bash` carries `workdir`, and it is the directory the command will
|
|
2926
|
+
// actually run in; the top-level `cwd` is the session root and the two can
|
|
2927
|
+
// differ. The classifier resolves relative paths against this, so binding
|
|
2928
|
+
// the session root instead would classify `rm -rf docs` against the wrong
|
|
2929
|
+
// tree. Only an ABSOLUTE value is taken: a relative `workdir` is the
|
|
2930
|
+
// harness describing a location this runtime cannot place, and a
|
|
2931
|
+
// self-reported field may not talk its way into a narrower answer
|
|
2932
|
+
// (SPEC §11.1), so the fallback is the value that was already trusted.
|
|
2933
|
+
// `null` for every adapter that declares no key, which leaves both the
|
|
2934
|
+
// payload and the classification EXACTLY as they were. That is not
|
|
2935
|
+
// defensiveness: `input.cwd` and the hook's own `cwd` are different values,
|
|
2936
|
+
// and a first version of this that used the envelope's `cwd` for every
|
|
2937
|
+
// adapter re-classified Grok's commands against a directory that does not
|
|
2938
|
+
// exist on disk and denied them all.
|
|
2939
|
+
const perCallCwd = adapter.shellCwdKey === undefined
|
|
2940
|
+
? null
|
|
2941
|
+
: readString(input.toolInput, adapter.shellCwdKey);
|
|
2942
|
+
const shellCwd = perCallCwd !== null && isAbsolute(perCallCwd) ? perCallCwd : null;
|
|
2195
2943
|
// Unchanged since APRV-117, deliberately: the payload is the WHOLE command
|
|
2196
2944
|
// and the directory it runs in, so the FULL PAYLOAD block on the phone
|
|
2197
2945
|
// carries every byte the harness will execute. Only `summary` is shortened.
|
|
2198
2946
|
const payload = adapter.bindToolName
|
|
2199
2947
|
? codexBinding(input, cwd).payload
|
|
2200
|
-
: { command: raw, cwd: input.cwd };
|
|
2948
|
+
: { command: raw, cwd: shellCwd ?? input.cwd };
|
|
2201
2949
|
// APRV-108: a local rewrite of history this checkout never published is a
|
|
2202
2950
|
// commit. APRV-267: a delete confined to the agent's own scratch is not a
|
|
2203
2951
|
// decision. Both run in the hook's own cwd, after classification and never
|
|
2204
2952
|
// inside it, and neither claims anything it cannot establish from the disk.
|
|
2205
|
-
|
|
2953
|
+
// Classified against the directory the command will RUN in, not the
|
|
2954
|
+
// hook's own, so a relative path in the command resolves the way the shell
|
|
2955
|
+
// will resolve it. For every adapter without a per-call working directory
|
|
2956
|
+
// this is `input.cwd` or the hook's own cwd exactly as before.
|
|
2957
|
+
const refined = classifyForHook(raw, protectedPaths, shellCwd ?? cwd, readRoots);
|
|
2206
2958
|
const classified = refined.result;
|
|
2207
2959
|
if (!classified.ok) {
|
|
2208
2960
|
return {
|
|
@@ -2293,6 +3045,25 @@ function describeToolCall(input, adapter, protectedPaths, cwd) {
|
|
|
2293
3045
|
segments: classified.segments,
|
|
2294
3046
|
};
|
|
2295
3047
|
}
|
|
3048
|
+
// APRV-347, above the file-tool branch because the two lists are disjoint and
|
|
3049
|
+
// a read is the cheaper question to answer: a read inside the scope, or one
|
|
3050
|
+
// naming no path at all, returns `null` here and takes the allow below.
|
|
3051
|
+
if (adapter.readTools.includes(input.toolName)) {
|
|
3052
|
+
const read = readToolGate(input.toolName, input.toolInput, readRoots, cwd);
|
|
3053
|
+
if (read === null) {
|
|
3054
|
+
return {
|
|
3055
|
+
kind: "allow",
|
|
3056
|
+
reason: `${input.toolName} is not a gated tool`,
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
3059
|
+
return {
|
|
3060
|
+
kind: "gated",
|
|
3061
|
+
classes: [READ_OUT_OF_SCOPE_CLASS],
|
|
3062
|
+
payload: read.payload,
|
|
3063
|
+
headline: read.summary,
|
|
3064
|
+
notes: [readScopeNote(read, readRoots)],
|
|
3065
|
+
};
|
|
3066
|
+
}
|
|
2296
3067
|
const gated = fileToolGate(input.toolName, input.toolInput, protectedPaths, cwd);
|
|
2297
3068
|
if (gated === null) {
|
|
2298
3069
|
return { kind: "allow", reason: `${input.toolName} names no file, so there is nothing to gate` };
|
|
@@ -2439,7 +3210,11 @@ decidedOn) {
|
|
|
2439
3210
|
const policyNote = load.ok
|
|
2440
3211
|
? null
|
|
2441
3212
|
: `the policy did not load (${load.code}: ${load.message}), so no protected path beyond the built-ins was known here and no class could be resolved to human-only`;
|
|
2442
|
-
const described = describeToolCall(input, adapter, protectedPaths, cwd
|
|
3213
|
+
const described = describeToolCall(input, adapter, protectedPaths, cwd,
|
|
3214
|
+
// APRV-347. The window suspends the POLICY, not the classification: a read
|
|
3215
|
+
// outside the scope is described as one here too, so the bypass RECORD says
|
|
3216
|
+
// what was actually authorized rather than calling it an ordinary read.
|
|
3217
|
+
resolveReadRoots(cwd, policyRootOf(load, scope.root), load.ok ? load.policy.read_scope?.roots : undefined));
|
|
2443
3218
|
if (described.kind === "deny") {
|
|
2444
3219
|
return deny(streams, described.code, `${described.detail} The open window does not reach this: a command the classifier cannot read is a command nothing here can establish is safe to run unapproved.`, adapter.kind);
|
|
2445
3220
|
}
|
|
@@ -2459,6 +3234,15 @@ decidedOn) {
|
|
|
2459
3234
|
return deny(streams, "hook-class-human-only", `${mutation} is never reachable through the open window: the window suspends the POLICY, and the log is what the window itself is derived from. A bypass able to write the log could rewrite its own authorization. Nothing was appended; a human writes the log directory by hand or not at all.`, adapter.kind);
|
|
2460
3235
|
}
|
|
2461
3236
|
if (load.ok) {
|
|
3237
|
+
// APRV-354, above the human-only check here for the same reason it sits
|
|
3238
|
+
// above it on the ordinary path. A window suspends what the policy DECIDES;
|
|
3239
|
+
// it cannot supply a decision the policy never made. A launch the policy
|
|
3240
|
+
// names no rule for is not a class the window is holding open — it is a
|
|
3241
|
+
// class nobody has opted into — so the window does not reach it either.
|
|
3242
|
+
const unruled = classes.find((cls) => harnessLaunchNeedsRule(cls, resolvePolicy(load, cls)));
|
|
3243
|
+
if (unruled !== undefined) {
|
|
3244
|
+
return deny(streams, "hook-harness-launch-unruled", `${harnessLaunchUnruledRefusal(unruled, "this command may not run under an agent")} An open window does not reach it: a window suspends what the policy decides, and this is a class the policy has not spoken about at all.`, adapter.kind);
|
|
3245
|
+
}
|
|
2462
3246
|
const reserved = classes.find((cls) => resolvePolicy(load, cls).autonomy === "human-only");
|
|
2463
3247
|
if (reserved !== undefined) {
|
|
2464
3248
|
return deny(streams, "hook-class-human-only", `${humanOnlyRefusal(reserved, "this command may not run under an agent")} An open window does not reach it: the window suspends what the policy DECIDES, and a human-only class is one the policy reserves to human hands, which a window opened by a human does not lend to an agent (SPEC.md §11.1 invariant 9).`, adapter.kind);
|
|
@@ -2511,7 +3295,18 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2511
3295
|
if (!parsed.ok)
|
|
2512
3296
|
return configurationError(parsed.message);
|
|
2513
3297
|
if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
|
|
2514
|
-
|
|
3298
|
+
// APRV-243. Grok's help carries the `.grok/hooks/*.json` the human commits,
|
|
3299
|
+
// because that file's `timeout` is load-bearing: it must exceed `--timeout`
|
|
3300
|
+
// or Grok abandons the hook mid-wait and, failing open, runs the command.
|
|
3301
|
+
// Muse gets its own for the same reason and one more: its `.muse/hooks.json`
|
|
3302
|
+
// shape is not Claude's file under a different name, and an operator who
|
|
3303
|
+
// guessed would get "Hooks: 0 runnable" and a session that looks gated.
|
|
3304
|
+
const harnessHelp = adapter.kind === "grok"
|
|
3305
|
+
? HOOK_GROK_HELP
|
|
3306
|
+
: adapter.kind === "muse"
|
|
3307
|
+
? HOOK_MUSE_HELP
|
|
3308
|
+
: HOOK_HELP;
|
|
3309
|
+
streams.out(`${harnessHelp}\n`);
|
|
2515
3310
|
return EXIT_OK;
|
|
2516
3311
|
}
|
|
2517
3312
|
const extra = parsed.positionals[0];
|
|
@@ -2541,16 +3336,65 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2541
3336
|
if (graceMs === null) {
|
|
2542
3337
|
return configurationError(`--retry-grace expects a duration like 5m, 30s, 1ms, got ${JSON.stringify(graceText)}`);
|
|
2543
3338
|
}
|
|
2544
|
-
const parsedInput = parseHookInput(readStdin());
|
|
3339
|
+
const parsedInput = parseHookInput(readStdin(), adapter.camelCaseEnvelope === true);
|
|
2545
3340
|
if (!parsedInput.ok)
|
|
2546
3341
|
return deny(streams, "hook-io", parsedInput.detail, adapter.kind);
|
|
2547
3342
|
const input = parsedInput.input;
|
|
2548
3343
|
if (adapter.kind === "codex") {
|
|
2549
3344
|
const checked = checkCodexHookInput(input, cwd);
|
|
2550
|
-
if (!checked.ok)
|
|
2551
|
-
|
|
3345
|
+
if (!checked.ok) {
|
|
3346
|
+
// APRV-311. WHICH PHASE the malformed event belongs to decides which
|
|
3347
|
+
// vocabulary refuses it, and until now both took the pre-execution one.
|
|
3348
|
+
// A `PostToolUse` event naming an unsupported tool, or carrying an id
|
|
3349
|
+
// this adapter will not accept, printed
|
|
3350
|
+
// `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"}}`
|
|
3351
|
+
// at exit 0: a permission decision about a call that has already run, in
|
|
3352
|
+
// the same words the pre-execution refusal uses, so nothing downstream
|
|
3353
|
+
// could tell the two apart. Nothing was ever going to be authorized here.
|
|
3354
|
+
//
|
|
3355
|
+
// The strict answer on this side is the one the rest of the post path
|
|
3356
|
+
// already gives: say so on stderr at the exit code that makes the line
|
|
3357
|
+
// visible, append nothing, and print no verdict. The event name is read
|
|
3358
|
+
// off the raw input rather than off the check, because the check is what
|
|
3359
|
+
// just failed.
|
|
3360
|
+
return input.hookEventName === CODEX_POST_TOOL_EVENT
|
|
3361
|
+
? report(streams, "post-tool-io", `${checked.detail}; nothing was appended, so the start this event would have closed is still open`)
|
|
3362
|
+
: deny(streams, "hook-io", checked.detail, adapter.kind);
|
|
3363
|
+
}
|
|
2552
3364
|
}
|
|
2553
3365
|
const codexCommand = adapter.kind === "codex" ? codexBinding(input, cwd).payload.command : undefined;
|
|
3366
|
+
// APRV-350. The contributor guard runs BEFORE the event dispatch, the policy
|
|
3367
|
+
// and everything else, because it is not a question about the action: a
|
|
3368
|
+
// Contributor-tier session discloses every byte it reads, so there is nothing
|
|
3369
|
+
// for a policy to authorize. Refusing first also means no unparseable payload,
|
|
3370
|
+
// no missing log and no classifier quirk can route around it.
|
|
3371
|
+
//
|
|
3372
|
+
// On a POST event it refuses in the post vocabulary and prints no verdict:
|
|
3373
|
+
// Muse rejects a permission field on a post event, and a rejected hook is a
|
|
3374
|
+
// FAILED hook, which fails open. There is nothing to block after the fact
|
|
3375
|
+
// anyway; the line exists so the disclosure is on a stream somebody reads.
|
|
3376
|
+
if (adapter.contributorModelGuard === true) {
|
|
3377
|
+
const refusal = contributorModelRefusal(input.model);
|
|
3378
|
+
if (refusal !== null) {
|
|
3379
|
+
const postEvent = input.hookEventName !== null && POST_TOOL_EVENTS.includes(input.hookEventName);
|
|
3380
|
+
return postEvent
|
|
3381
|
+
? report(streams, MUSE_CONTRIBUTOR_REFUSAL, `${refusal} Nothing was appended.`)
|
|
3382
|
+
: deny(streams, MUSE_CONTRIBUTOR_REFUSAL, refusal, adapter.kind);
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
// APRV-350. The harness's own bookkeeping tool, answered and never gated.
|
|
3386
|
+
// Muse fires both events for `submit_reminder_decision` continuously — 100 of
|
|
3387
|
+
// the 139 events in the live capture — and it records a self-assessment and
|
|
3388
|
+
// touches nothing. It is answered with an explicit allow rather than left to
|
|
3389
|
+
// fall through, so the verdict is one dialect and nothing else, which is the
|
|
3390
|
+
// only shape this harness parses.
|
|
3391
|
+
if (adapter.passThroughTools?.includes(input.toolName) === true) {
|
|
3392
|
+
const passPostEvent = input.hookEventName !== null && POST_TOOL_EVENTS.includes(input.hookEventName);
|
|
3393
|
+
if (passPostEvent) {
|
|
3394
|
+
return report(streams, "post-tool-not-gated", `${input.toolName} is the harness's own bookkeeping tool, so no execution.started was ever written for it`);
|
|
3395
|
+
}
|
|
3396
|
+
return allow(streams, `${input.toolName} is ${adapter.kind}'s own bookkeeping tool: it records the session's self-assessment and touches nothing outside the session`, adapter.kind);
|
|
3397
|
+
}
|
|
2554
3398
|
// APRV-145: WHICH EVENT THIS IS, read first and read at all. One command is
|
|
2555
3399
|
// registered for two events, and they do opposite things — one answers before
|
|
2556
3400
|
// the tool runs, the other records how it went — so the dispatch is the first
|
|
@@ -2571,11 +3415,19 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2571
3415
|
// already finished, and the reason the counterpart did not land would be
|
|
2572
3416
|
// dressed as a permission decision. A throw on this path is `post-tool-io`,
|
|
2573
3417
|
// on stderr, at the exit code that makes the line visible.
|
|
3418
|
+
//
|
|
3419
|
+
// APRV-243. Grok Build reads exit 2 as DENY, full stop, so the visibility
|
|
3420
|
+
// exit this path uses everywhere else would be a permission decision about
|
|
3421
|
+
// a tool call that has already run. On this one harness the post-execution
|
|
3422
|
+
// path therefore always exits 0. The machine-readable line still goes to
|
|
3423
|
+
// stderr; whether Grok shows it is Grok's business, and losing a debug line
|
|
3424
|
+
// is a smaller harm than emitting a verdict the protocol will act on.
|
|
3425
|
+
const settle = (code) => (adapter.kind === "grok" ? EXIT_OK : code);
|
|
2574
3426
|
try {
|
|
2575
|
-
return runPostToolUse(parsed.flags, streams, cwd, input, actor, adapter);
|
|
3427
|
+
return settle(runPostToolUse(parsed.flags, streams, cwd, input, actor, adapter));
|
|
2576
3428
|
}
|
|
2577
3429
|
catch (cause) {
|
|
2578
|
-
return report(streams, "post-tool-io", `the counterpart failed: ${cause instanceof Error ? cause.message : String(cause)}; nothing was appended, so the start this event would have closed is still open`);
|
|
3430
|
+
return settle(report(streams, "post-tool-io", `the counterpart failed: ${cause instanceof Error ? cause.message : String(cause)}; nothing was appended, so the start this event would have closed is still open`));
|
|
2579
3431
|
}
|
|
2580
3432
|
}
|
|
2581
3433
|
// Codex 0.152.1 can execute Bash in a per-call working directory that is
|
|
@@ -2585,10 +3437,32 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2585
3437
|
// harness executes. Refuse before the open-window, gate-self, carry, or
|
|
2586
3438
|
// registration paths; none of those can supply the missing directory fact.
|
|
2587
3439
|
if (adapter.kind === "codex" && input.toolName === "Bash") {
|
|
2588
|
-
return deny(streams, "hook-
|
|
3440
|
+
return deny(streams, "hook-unsupported-execution-context", "Codex Bash is disabled because the native hook contract does not expose the effective per-call working directory; no policy or open window can authorize bytes the hook cannot bind", adapter.kind);
|
|
2589
3441
|
}
|
|
2590
3442
|
if (input.toolName !== adapter.shellTool && !adapter.fileTools.includes(input.toolName)) {
|
|
2591
|
-
|
|
3443
|
+
if (!adapter.readTools.includes(input.toolName)) {
|
|
3444
|
+
return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
|
|
3445
|
+
}
|
|
3446
|
+
// APRV-347, and the placement is the whole of its cost. A read tool call is
|
|
3447
|
+
// the most frequent event a session produces, and before this task it was
|
|
3448
|
+
// answered here with no policy load, no verified read of the log and no
|
|
3449
|
+
// window lookup. Everything below this line is expensive, so the reads that
|
|
3450
|
+
// do not need it must not pay for it.
|
|
3451
|
+
//
|
|
3452
|
+
// The short-circuit is sound because `read_scope` is ADDITIVE: it can only
|
|
3453
|
+
// widen the roots, so a target already inside the BUILT-IN roots is inside
|
|
3454
|
+
// the effective ones whatever the policy says, and the policy need not be
|
|
3455
|
+
// read to know it. A target outside them falls through, the policy is
|
|
3456
|
+
// loaded below, and `describeToolCall` asks again with the widened set —
|
|
3457
|
+
// which is where a policy-declared root actually takes effect.
|
|
3458
|
+
//
|
|
3459
|
+
// The cost of the fast path is a handful of `realpath` calls and no process
|
|
3460
|
+
// spawn, provided the hook is configured with `--dir` as the docs show
|
|
3461
|
+
// (otherwise `hookScope` runs `git rev-parse` to find the primary).
|
|
3462
|
+
const early = hookScope(parsed.flags, cwd);
|
|
3463
|
+
if (readToolGate(input.toolName, input.toolInput, resolveReadRoots(cwd, early.root), cwd) === null) {
|
|
3464
|
+
return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
|
|
3465
|
+
}
|
|
2592
3466
|
}
|
|
2593
3467
|
// APRV-188. From here on this process may resume a verified read behind the
|
|
2594
3468
|
// snapshot the daemon published, instead of walking the chain from genesis:
|
|
@@ -2625,6 +3499,40 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2625
3499
|
// verdict and the record are one read of the log.
|
|
2626
3500
|
looked.records === null ? null : { records: looked.records, head: looked.head });
|
|
2627
3501
|
}
|
|
3502
|
+
return renderVerdict(streams, adapter, codexCommand, decideHarnessCall({
|
|
3503
|
+
streams,
|
|
3504
|
+
input,
|
|
3505
|
+
adapter,
|
|
3506
|
+
cwd,
|
|
3507
|
+
logPath,
|
|
3508
|
+
root,
|
|
3509
|
+
options,
|
|
3510
|
+
actor,
|
|
3511
|
+
timeoutMs,
|
|
3512
|
+
intervalMs,
|
|
3513
|
+
graceMs,
|
|
3514
|
+
codexCommand,
|
|
3515
|
+
windowRecords: looked.records,
|
|
3516
|
+
}));
|
|
3517
|
+
}
|
|
3518
|
+
/**
|
|
3519
|
+
* Classify, resolve, gate and wait: one harness tool call, from the event to a
|
|
3520
|
+
* verdict (APRV-361).
|
|
3521
|
+
*
|
|
3522
|
+
* Extracted from the hook's own verb so a SECOND caller can reach a decision
|
|
3523
|
+
* through exactly this sequence. `cli/codex-bridge.ts` answers Codex's
|
|
3524
|
+
* app-server approval requests over JSON-RPC rather than on stdout, and the
|
|
3525
|
+
* thing it must not do is re-implement any of what is below: the human-only
|
|
3526
|
+
* refusal, the unruled `harness.launch.*` refusal, the sandbox requirement, the
|
|
3527
|
+
* loop floor, the unattended guard, the autonomous charge, and the register,
|
|
3528
|
+
* request and wait that follow. Two implementations of that sequence would be
|
|
3529
|
+
* two gates, and the second one would be the one nobody reviewed.
|
|
3530
|
+
*
|
|
3531
|
+
* It returns a verdict and prints none. `streams.err` still carries the
|
|
3532
|
+
* progress and withdrawal lines, which are a report rather than a decision.
|
|
3533
|
+
*/
|
|
3534
|
+
export function decideHarnessCall(decide) {
|
|
3535
|
+
const { streams, input, adapter, cwd, logPath, root, options, actor, timeoutMs, intervalMs, graceMs, codexCommand, windowRecords, } = decide;
|
|
2628
3536
|
// The policy is read BEFORE the command is classified (APRV-107): the
|
|
2629
3537
|
// protected-path set is built-ins plus `policy.protected_paths`, so what
|
|
2630
3538
|
// counts as a protected path is a policy question and the classifier cannot be
|
|
@@ -2637,25 +3545,36 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2637
3545
|
? { dir: options.policy?.dir ?? cwd }
|
|
2638
3546
|
: { file: options.policy.file });
|
|
2639
3547
|
if (!load.ok) {
|
|
2640
|
-
return
|
|
3548
|
+
return {
|
|
3549
|
+
permission: "deny",
|
|
3550
|
+
code: "hook-policy-unavailable",
|
|
3551
|
+
detail: `${load.code}: ${load.message}; every class resolves to manual and the hook cannot verify a decision`,
|
|
3552
|
+
};
|
|
2641
3553
|
}
|
|
2642
3554
|
const protectedPaths = load.policy.protected_paths ?? [];
|
|
3555
|
+
// APRV-347. Resolved from the LOADED policy's own directory, so the scope is
|
|
3556
|
+
// anchored to the gate a decision will be recorded in, and widened by
|
|
3557
|
+
// `read_scope.roots` if that policy declares any.
|
|
3558
|
+
const readRoots = resolveReadRoots(cwd, policyRootOf(load, root), load.policy.read_scope?.roots);
|
|
2643
3559
|
// What is being asked for, as one or more classes. One description site for
|
|
2644
3560
|
// both paths since APRV-214 (see `describeToolCall`): the open window
|
|
2645
3561
|
// classifies exactly as the closed one does, and a second copy of this would
|
|
2646
3562
|
// be a second answer to "what is this command".
|
|
2647
|
-
const described = describeToolCall(input, adapter, protectedPaths, cwd);
|
|
3563
|
+
const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots);
|
|
2648
3564
|
if (described.kind === "deny") {
|
|
2649
|
-
return deny
|
|
3565
|
+
return { permission: "deny", code: described.code, detail: described.detail };
|
|
2650
3566
|
}
|
|
2651
3567
|
if (described.kind === "allow") {
|
|
2652
|
-
return allow
|
|
3568
|
+
return { permission: "allow", reason: described.reason };
|
|
2653
3569
|
}
|
|
2654
3570
|
const { classes, payload, headline } = described;
|
|
2655
3571
|
/** What the history-rewrite refinement did, for the decision reason. */
|
|
2656
3572
|
const notes = [...described.notes];
|
|
2657
3573
|
if (classes.length === 0) {
|
|
2658
|
-
return
|
|
3574
|
+
return {
|
|
3575
|
+
permission: "allow",
|
|
3576
|
+
reason: "the approval CLI is the gate itself and is not gated by it",
|
|
3577
|
+
};
|
|
2659
3578
|
}
|
|
2660
3579
|
// Every path from here needs the log, the fast paths included (APRV-139):
|
|
2661
3580
|
// attestation and loop-escalation are facts about the log, so the
|
|
@@ -2664,7 +3583,11 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2664
3583
|
// on-disk policy called autonomous; it now denies, which is the same answer
|
|
2665
3584
|
// it already gave every other class.
|
|
2666
3585
|
if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
|
|
2667
|
-
return
|
|
3586
|
+
return {
|
|
3587
|
+
permission: "deny",
|
|
3588
|
+
code: "hook-log-unreachable",
|
|
3589
|
+
detail: `no log at ${logPath}; the hook writes to an existing log and never creates one. Run \`approval init\` (then \`approval policy attest\`) in ${root}, or pass --log <path> to point the hook at the log that already exists`,
|
|
3590
|
+
};
|
|
2668
3591
|
}
|
|
2669
3592
|
// Minted once, here, and carried into `gateAndWait`: the loop-escalation
|
|
2670
3593
|
// check below and any registration that follows must name the same task.
|
|
@@ -2690,7 +3613,26 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2690
3613
|
// state.
|
|
2691
3614
|
channels: Object.keys(load.policy.channels ?? {}).sort(),
|
|
2692
3615
|
};
|
|
2693
|
-
const
|
|
3616
|
+
const resolutions = classes.map((cls) => resolvePolicy(load, cls));
|
|
3617
|
+
const autonomies = resolutions.map((resolution) => resolution.autonomy);
|
|
3618
|
+
// APRV-354, and ABOVE the human-only deny because it is the narrower
|
|
3619
|
+
// statement: this class is not merely reserved, it is one the policy has not
|
|
3620
|
+
// spoken about at all, and the repair is a line rather than a person.
|
|
3621
|
+
//
|
|
3622
|
+
// A `harness.launch.*` class resolves only under a rule an operator wrote. A
|
|
3623
|
+
// policy naming neither the family nor the member refuses the launch instead
|
|
3624
|
+
// of falling to `defaults.autonomy`, which keeps arrival exactly as strict as
|
|
3625
|
+
// it was before the family existed (`hook-unclassified`, refused) until
|
|
3626
|
+
// somebody opts in. See `harnessLaunchNeedsRule` for why the family gets this
|
|
3627
|
+
// and no other class does.
|
|
3628
|
+
const unruled = classes.find((cls, index) => harnessLaunchNeedsRule(cls, resolutions[index]));
|
|
3629
|
+
if (unruled !== undefined) {
|
|
3630
|
+
return {
|
|
3631
|
+
permission: "deny",
|
|
3632
|
+
code: "hook-harness-launch-unruled",
|
|
3633
|
+
detail: harnessLaunchUnruledRefusal(unruled, "this command may not run under an agent"),
|
|
3634
|
+
};
|
|
3635
|
+
}
|
|
2694
3636
|
// APRV-185, amended SPEC.md §5.2, and the first verdict this function reaches
|
|
2695
3637
|
// once the classes have autonomies. A command touching a class the policy
|
|
2696
3638
|
// reserves to human hands is denied outright: no request is opened, no task is
|
|
@@ -2706,7 +3648,11 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2706
3648
|
// strictest thing in it.
|
|
2707
3649
|
const reserved = classes.find((_cls, index) => autonomies[index] === "human-only");
|
|
2708
3650
|
if (reserved !== undefined) {
|
|
2709
|
-
return
|
|
3651
|
+
return {
|
|
3652
|
+
permission: "deny",
|
|
3653
|
+
code: "hook-class-human-only",
|
|
3654
|
+
detail: `${humanOnlyRefusal(reserved, "this command may not run under an agent")} The gate's own code for this fact is \`class-human-only\`.`,
|
|
3655
|
+
};
|
|
2710
3656
|
}
|
|
2711
3657
|
// APRV-193, and BELOW the human-only deny for the same reason that one sits
|
|
2712
3658
|
// above the floor: a class no agent may run is answered before a question
|
|
@@ -2714,7 +3660,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2714
3660
|
// refused command leaves the log exactly as it found it.
|
|
2715
3661
|
const unsandboxed = sandboxRequirement(described.segments, autonomies);
|
|
2716
3662
|
if (unsandboxed !== null) {
|
|
2717
|
-
return deny
|
|
3663
|
+
return { permission: "deny", code: "hook-sandbox-required", detail: unsandboxed };
|
|
2718
3664
|
}
|
|
2719
3665
|
// APRV-145, amended SPEC.md §10.2: loop safety on a surface that mints a
|
|
2720
3666
|
// fresh task id per tool call. The floor is applied AFTER class resolution and
|
|
@@ -2729,9 +3675,9 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2729
3675
|
// have proceeded is routed to the human gate for this invocation (APRV-297
|
|
2730
3676
|
// narrowed it to those); a class that already resolves manual is untouched,
|
|
2731
3677
|
// because it was already going there.
|
|
2732
|
-
const floored = harnessFloor(logPath, task, actor,
|
|
3678
|
+
const floored = harnessFloor(logPath, task, actor, windowRecords);
|
|
2733
3679
|
if (!floored.ok)
|
|
2734
|
-
return deny
|
|
3680
|
+
return { permission: "deny", code: "hook-io", detail: floored.detail };
|
|
2735
3681
|
/**
|
|
2736
3682
|
* The streak the log shows, before the read carve-out (APRV-297).
|
|
2737
3683
|
*
|
|
@@ -2780,9 +3726,10 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2780
3726
|
/** No class here needs a human, so nothing downstream will ask for one. */
|
|
2781
3727
|
const unattended = floor === null && autonomies.every((autonomy) => autonomy !== "manual");
|
|
2782
3728
|
if (unattended) {
|
|
2783
|
-
const refused = unattendedGuard(logPath, load.source.path, task,
|
|
2784
|
-
if (refused !== null)
|
|
2785
|
-
return deny
|
|
3729
|
+
const refused = unattendedGuard(logPath, load.source.path, task, windowRecords);
|
|
3730
|
+
if (refused !== null) {
|
|
3731
|
+
return { permission: "deny", code: refused.code, detail: refused.detail };
|
|
3732
|
+
}
|
|
2786
3733
|
}
|
|
2787
3734
|
if (floor === null && autonomies.every((autonomy) => autonomy === "autonomous")) {
|
|
2788
3735
|
// No approval lifecycle: an autonomous action has none (amended SPEC.md
|
|
@@ -2792,9 +3739,13 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2792
3739
|
// charge is not a budget. See `recordUnattended`.
|
|
2793
3740
|
const charged = recordUnattended(run, task, classes, payloadHash(payload));
|
|
2794
3741
|
if (charged !== null) {
|
|
2795
|
-
return
|
|
3742
|
+
return {
|
|
3743
|
+
permission: "deny",
|
|
3744
|
+
code: `hook-gate-refused:${charged.code}`,
|
|
3745
|
+
detail: charged.message,
|
|
3746
|
+
};
|
|
2796
3747
|
}
|
|
2797
|
-
return allow
|
|
3748
|
+
return { permission: "allow", reason: `autonomous: ${classes.join(", ")}${note}` };
|
|
2798
3749
|
}
|
|
2799
3750
|
// Past here the hook appends. It writes to a log that already exists and
|
|
2800
3751
|
// creates none: a log the hook scaffolded where it happened to be standing
|
|
@@ -2802,7 +3753,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
|
2802
3753
|
// do not survive a merge. An initialized-but-empty `.approval/log/` counts as
|
|
2803
3754
|
// reachable — an audit trail that has recorded nothing is an empty log, not a
|
|
2804
3755
|
// missing one (see `preflightLog`) — and `register` appends the first line.
|
|
2805
|
-
return
|
|
3756
|
+
return gateHarnessCall(streams, run, classes, payload, headline, task, note, floor);
|
|
2806
3757
|
}
|
|
2807
3758
|
function commandHarnessHook(argv, streams, cwd, readStdin, adapter) {
|
|
2808
3759
|
try {
|
|
@@ -2833,17 +3784,14 @@ export function commandHook(argv, streams, cwd, readStdin = defaultStdin) {
|
|
|
2833
3784
|
streams.out(`${HOOK_HELP}\n`);
|
|
2834
3785
|
return EXIT_OK;
|
|
2835
3786
|
}
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
case "codex":
|
|
2842
|
-
return commandHarnessHook(rest, streams, cwd, readStdin, CODEX_ADAPTER);
|
|
2843
|
-
case "classify":
|
|
2844
|
-
return commandClassify(rest, streams, cwd);
|
|
2845
|
-
default:
|
|
2846
|
-
return usageError(streams, `unknown subcommand ${JSON.stringify(sub)} for \`approval hook\``);
|
|
3787
|
+
// APRV-358: one list. `isHarnessKind` is the same predicate the write
|
|
3788
|
+
// boundary and doctor use, so a kind that can be recorded is a kind that can
|
|
3789
|
+
// be invoked, and neither can be added without the other.
|
|
3790
|
+
if (isHarnessKind(sub)) {
|
|
3791
|
+
return commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub]);
|
|
2847
3792
|
}
|
|
3793
|
+
if (sub === "classify")
|
|
3794
|
+
return commandClassify(rest, streams, cwd);
|
|
3795
|
+
return usageError(streams, `unknown subcommand ${JSON.stringify(sub)} for \`approval hook\``);
|
|
2848
3796
|
}
|
|
2849
3797
|
//# sourceMappingURL=hook.js.map
|