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
|
@@ -32,7 +32,23 @@
|
|
|
32
32
|
* Self-reported text is never read. The hook passes the command only; the
|
|
33
33
|
* harness's `description` field is authored by the very agent being gated
|
|
34
34
|
* (SPEC.md §11.1: self-reported fields never reduce scrutiny).
|
|
35
|
+
*
|
|
36
|
+
* Two imports, and both are pure in exactly the same way this file is: no disk,
|
|
37
|
+
* no clock, no environment, no dependencies.
|
|
38
|
+
*
|
|
39
|
+
* `core/read-scope.ts` (APRV-347) holds the read-side path arithmetic, so the
|
|
40
|
+
* hook and the policy explainer can ask the same questions this file asks, of
|
|
41
|
+
* the same table, rather than each growing a copy of it.
|
|
42
|
+
*
|
|
43
|
+
* `core/policy-match.ts` (APRV-354) is imported for ONE name, the
|
|
44
|
+
* `harness.launch.` prefix. The classifier emits the family and two enforcement
|
|
45
|
+
* paths refuse a member of it that no policy rule names, so the three have to
|
|
46
|
+
* agree on what the family is; a second spelling of the prefix would close one
|
|
47
|
+
* of those doors and leave the other open. The import is type-safe in the
|
|
48
|
+
* dependency sense as well: `policy-match.ts` itself imports only types.
|
|
35
49
|
*/
|
|
50
|
+
import { HARNESS_LAUNCH_PREFIX } from "./policy-match.js";
|
|
51
|
+
import { READ_OUT_OF_SCOPE_CLASS, isUnreadableTarget, readTargetVerdict, readTargetsOf, } from "./read-scope.js";
|
|
36
52
|
/**
|
|
37
53
|
* The pass-through pseudo-class for the gate's own CLI.
|
|
38
54
|
*
|
|
@@ -240,6 +256,36 @@ export function protectedPathClass(candidate, extra = []) {
|
|
|
240
256
|
if (next === "hooks.json" || next === "hooks" || next === "agents")
|
|
241
257
|
return "policy.core";
|
|
242
258
|
}
|
|
259
|
+
// Grok Build's equivalent: `.grok/hooks/*.json` is where its PreToolUse
|
|
260
|
+
// entries are installed, and the scripts beside them are what those
|
|
261
|
+
// entries run. Same property as `.cursor/hooks.json` above and for the
|
|
262
|
+
// same reason (APRV-243): an agent that could write those could write
|
|
263
|
+
// itself out of the gate. Grok also READS `.claude/settings.json` and
|
|
264
|
+
// `.cursor/hooks.json` for compatibility, and both are already here.
|
|
265
|
+
if (segment === ".grok") {
|
|
266
|
+
const next = segments[index + 1];
|
|
267
|
+
if (next === "hooks.json" || next === "hooks")
|
|
268
|
+
return "policy.core";
|
|
269
|
+
}
|
|
270
|
+
// Muse Code's equivalent, OBSERVED rather than guessed (APRV-350): the live
|
|
271
|
+
// probe on muse-bin-1.3.0-R3233.1 established that the installed build reads
|
|
272
|
+
// `.muse/hooks.json` in the project, and that it rejects a malformed one
|
|
273
|
+
// loudly at startup. `settings` is listed beside it because Meta documents
|
|
274
|
+
// user-level hooks inside a `settings.json` `hooks` block, so a
|
|
275
|
+
// project-level copy would be the same organ under a second name; it never
|
|
276
|
+
// fired in the probe, and an entry for a file Muse does not read is INERT,
|
|
277
|
+
// while a missing entry for one it does read would be the hole.
|
|
278
|
+
//
|
|
279
|
+
// `worktrees` is deliberately NOT here. Muse keeps its own worktree state
|
|
280
|
+
// under `.muse/worktrees/`, which is ordinary workspace content: making it
|
|
281
|
+
// `policy.core` would price routine session bookkeeping at a human's
|
|
282
|
+
// attention, which is the failure mode §11 asks to avoid.
|
|
283
|
+
if (segment === ".muse") {
|
|
284
|
+
const next = segments[index + 1];
|
|
285
|
+
if (next === "hooks.json" || next === "hooks" || next?.startsWith("settings")) {
|
|
286
|
+
return "policy.core";
|
|
287
|
+
}
|
|
288
|
+
}
|
|
243
289
|
// Codex installs its hook through these configuration and script paths.
|
|
244
290
|
if (segment === ".codex") {
|
|
245
291
|
const next = segments[index + 1];
|
|
@@ -472,6 +518,19 @@ export const NON_SECRET_ENV_NAMES = [
|
|
|
472
518
|
"APPROVAL_MD",
|
|
473
519
|
"APPROVAL_HOME",
|
|
474
520
|
"APPROVAL_DIR",
|
|
521
|
+
/**
|
|
522
|
+
* Where the per-machine bot-ownership registry lives (APRV-390).
|
|
523
|
+
*
|
|
524
|
+
* A DIRECTORY PATH, and the same kind of thing `APPROVAL_HOME` and
|
|
525
|
+
* `APPROVAL_DIR` already are. It holds no secret and opens nothing: the file
|
|
526
|
+
* it points at carries bot ids, usernames and instance directories, all of
|
|
527
|
+
* which `.approval/env` carries in the open, and nothing reads it to widen a
|
|
528
|
+
* permission. Passed through because a child `approval` verb must resolve the
|
|
529
|
+
* SAME registry as its parent — a child that silently fell back to the
|
|
530
|
+
* platform default would answer "which instance owns this bot?" from a
|
|
531
|
+
* different file than the process that asked it.
|
|
532
|
+
*/
|
|
533
|
+
"APPROVAL_STATE_DIR",
|
|
475
534
|
];
|
|
476
535
|
/**
|
|
477
536
|
* Does this bare variable name name credential material?
|
|
@@ -552,6 +611,33 @@ const OPERATOR_CHARS = new Set(["&", "|", ";", "(", ")", "<", ">", "\n"]);
|
|
|
552
611
|
* Not understood, on purpose: parameter expansion values. `$VAR` and `${VAR}`
|
|
553
612
|
* are kept verbatim in the word text, and every rule that reads a path or a
|
|
554
613
|
* refspec treats a word containing `$` as unknown, which resolves stricter.
|
|
614
|
+
*
|
|
615
|
+
* ## Quoted text is DATA, and that is a contract (APRV-353)
|
|
616
|
+
*
|
|
617
|
+
* The command boundary this tokenizer honours is the shell's own. A quoted
|
|
618
|
+
* argument is ONE word to the shell, so nothing inside it is an operator, a
|
|
619
|
+
* redirection, a segment separator or a command name here either:
|
|
620
|
+
*
|
|
621
|
+
* - single-quoted text is wholly inert, every byte of it, `$` and backtick
|
|
622
|
+
* included;
|
|
623
|
+
* - double-quoted text is inert too, with the two exceptions the shell itself
|
|
624
|
+
* makes — `$(…)` and backticks, which it expands before the command runs and
|
|
625
|
+
* which therefore keep the class they have anywhere else (a substitution is
|
|
626
|
+
* classified recursively, a backtick is `opaque`);
|
|
627
|
+
* - adjacent quoted and unquoted runs concatenate into one word (`'a'"b"c`),
|
|
628
|
+
* and a backslash escape is applied where the shell applies it;
|
|
629
|
+
* - UNQUOTED operators split exactly as they always did, and quoting that does
|
|
630
|
+
* not balance is {@link LexResult} `ok: false` — `unparseable`, a refusal —
|
|
631
|
+
* rather than a guess at what the writer meant.
|
|
632
|
+
*
|
|
633
|
+
* This is stated rather than merely true because the failure it prevents is
|
|
634
|
+
* silent and one-directional. A backlog note that says the word `bash`, carries
|
|
635
|
+
* an angle-bracketed placeholder, a pipe or a semicolon is prose about work; a
|
|
636
|
+
* tokenizer that read it as syntax would refuse an ordinary workspace write and
|
|
637
|
+
* push its author toward rewording the record of what they did, which is the
|
|
638
|
+
* audit cost SPEC.md §11 exists to protect. Every printable ASCII character is
|
|
639
|
+
* covered in both quote styles by `tests/command-class-quoting.test.ts`, so an
|
|
640
|
+
* edit that loses the property fails there rather than in someone's notes.
|
|
555
641
|
*/
|
|
556
642
|
function lex(command) {
|
|
557
643
|
const segments = [];
|
|
@@ -737,6 +823,19 @@ function lex(command) {
|
|
|
737
823
|
flush(command.length);
|
|
738
824
|
return { ok: true, segments };
|
|
739
825
|
}
|
|
826
|
+
/**
|
|
827
|
+
* The refusal detail for a backtick the shell would expand inside a
|
|
828
|
+
* double-quoted argument (APRV-353).
|
|
829
|
+
*
|
|
830
|
+
* Same code (`opaque`), same verdict (deny), more use: double quotes are what
|
|
831
|
+
* an author reaches for when the text carries an apostrophe, and a note that
|
|
832
|
+
* quotes a command in backticks is then legal shell that really does run
|
|
833
|
+
* something. The refusal names the spelling that is inert, because a refusal a
|
|
834
|
+
* reader cannot act on costs the same attention as one they can (SPEC.md §11.1:
|
|
835
|
+
* refusals are machine-readable and distinct — the code stays the machine's
|
|
836
|
+
* half, this is the human's).
|
|
837
|
+
*/
|
|
838
|
+
const QUOTED_BACKTICK_OPAQUE = "backtick command substitution inside a double-quoted argument, which the shell expands; single quotes make the same text literal";
|
|
740
839
|
/** Scan a double-quoted string starting at the opening quote. */
|
|
741
840
|
function readDoubleQuoted(command, start) {
|
|
742
841
|
let text = "";
|
|
@@ -760,7 +859,7 @@ function readDoubleQuoted(command, start) {
|
|
|
760
859
|
const close = command.indexOf("`", index + 1);
|
|
761
860
|
if (close === -1)
|
|
762
861
|
return null;
|
|
763
|
-
opaque =
|
|
862
|
+
opaque = QUOTED_BACKTICK_OPAQUE;
|
|
764
863
|
index = close;
|
|
765
864
|
continue;
|
|
766
865
|
}
|
|
@@ -895,7 +994,45 @@ function isTagRefspec(refspec) {
|
|
|
895
994
|
return isTagRef(refspec);
|
|
896
995
|
return isTagRef(refspec.slice(0, colon)) || isTagRef(refspec.slice(colon + 1));
|
|
897
996
|
}
|
|
898
|
-
/**
|
|
997
|
+
/**
|
|
998
|
+
* Deleting a remote ref: its own class, never the trunk-push one (APRV-352).
|
|
999
|
+
*
|
|
1000
|
+
* A `git push` that deletes refs was `vcs.push.main` until now, which reads as
|
|
1001
|
+
* "this reaches the trunk" and in a repository that samples trunk pushes
|
|
1002
|
+
* retrospectively means an irreversible removal proceeds unasked and is looked
|
|
1003
|
+
* at afterwards. It is not the same act. A push adds commits somebody can still
|
|
1004
|
+
* see; a deletion removes the only name an unmerged branch had, and the
|
|
1005
|
+
* reflog that could find it again lives on a server nobody in the session can
|
|
1006
|
+
* reach. The two belong on separate policy lines, and a policy that wants them
|
|
1007
|
+
* on one can still write `vcs.*`.
|
|
1008
|
+
*
|
|
1009
|
+
* Distinct from `vcs.history.rewrite` as well, which guards SHARED history: a
|
|
1010
|
+
* force push moves a ref other people have already built on. That class stays
|
|
1011
|
+
* exactly where it was, above this one, so a force push that also deletes is
|
|
1012
|
+
* still a rewrite.
|
|
1013
|
+
*/
|
|
1014
|
+
const REF_DELETE_CLASS = "vcs.ref.delete";
|
|
1015
|
+
/**
|
|
1016
|
+
* The ref a deleting refspec names, or `null` when the refspec deletes nothing.
|
|
1017
|
+
*
|
|
1018
|
+
* `:dst` (empty source) is the deletion git documents; `src:` (empty
|
|
1019
|
+
* destination) is the spelling the classifier has always treated as one too,
|
|
1020
|
+
* and it keeps doing so rather than being narrowed here. The non-empty side is
|
|
1021
|
+
* the name worth showing an approver either way.
|
|
1022
|
+
*/
|
|
1023
|
+
function deletedRef(refspec) {
|
|
1024
|
+
const colon = refspec.indexOf(":");
|
|
1025
|
+
if (colon === -1)
|
|
1026
|
+
return null;
|
|
1027
|
+
const source = refspec.slice(0, colon);
|
|
1028
|
+
const destination = refspec.slice(colon + 1);
|
|
1029
|
+
if (destination.length === 0)
|
|
1030
|
+
return source.length === 0 ? refspec : source;
|
|
1031
|
+
if (source.length === 0)
|
|
1032
|
+
return destination;
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
/** `git push` — force, release, deletion, trunk and branch turn on flags and refspecs. */
|
|
899
1036
|
function refineGitPush(ctx) {
|
|
900
1037
|
const args = ctx.args.slice(1);
|
|
901
1038
|
if (hasFlag(args, ["--force", "-f", "--force-with-lease", "--force-if-includes", "--mirror"])) {
|
|
@@ -906,29 +1043,45 @@ function refineGitPush(ctx) {
|
|
|
906
1043
|
if (refspecs.some((refspec) => refspec.startsWith("+"))) {
|
|
907
1044
|
return { class: "vcs.history.rewrite", rule: "git-push-force" };
|
|
908
1045
|
}
|
|
1046
|
+
// The tag check stays ABOVE the deletion check, deliberately. A tag is the
|
|
1047
|
+
// name a release was published under, and this repository's policy prices
|
|
1048
|
+
// `release.publish` accordingly; deleting one is a release act whichever
|
|
1049
|
+
// spelling removes it. Moving the deletion check up would re-label
|
|
1050
|
+
// `git push origin :refs/tags/v1.2.3` and a bulk form that mixes a tag in,
|
|
1051
|
+
// and APRV-352 asks for a class for branch deletions, not a loosening of the
|
|
1052
|
+
// tag surface.
|
|
909
1053
|
if (hasFlag(args, ["--tags", "--follow-tags"]) ||
|
|
910
1054
|
refspecs.some(isTagRefspec) ||
|
|
911
1055
|
refspecs.some((word, index) => word === "tag" && index + 1 < refspecs.length)) {
|
|
912
1056
|
return { class: "release.publish", rule: "git-push-tag" };
|
|
913
1057
|
}
|
|
914
|
-
//
|
|
915
|
-
//
|
|
1058
|
+
// `--delete` / `-d`: every refspec after the remote is a ref being removed.
|
|
1059
|
+
// A `--delete` naming no ref at all is a git error, and it stays in this
|
|
1060
|
+
// class with nothing bound rather than falling through to a push class: an
|
|
1061
|
+
// invocation whose targets cannot be read is the one that least deserves the
|
|
1062
|
+
// looser answer.
|
|
916
1063
|
if (hasFlag(args, ["--delete", "-d"])) {
|
|
917
|
-
return {
|
|
1064
|
+
return {
|
|
1065
|
+
class: REF_DELETE_CLASS,
|
|
1066
|
+
rule: "git-ref-delete",
|
|
1067
|
+
...(refspecs.length === 0 ? {} : { path: refspecs.join(" ") }),
|
|
1068
|
+
};
|
|
918
1069
|
}
|
|
919
1070
|
if (refspecs.length === 0) {
|
|
920
1071
|
return { class: "vcs.push.main", rule: "git-push-implicit" };
|
|
921
1072
|
}
|
|
1073
|
+
// The colon-refspec spellings, which need no flag: `:refs/heads/x`, `:x`, and
|
|
1074
|
+
// a bulk form mixing several. ONE deleting refspec makes the whole command a
|
|
1075
|
+
// deletion, because the command's effect is the union of its refspecs and the
|
|
1076
|
+
// destructive half is the half a person is being asked about.
|
|
1077
|
+
const deleted = refspecs.map(deletedRef).filter((ref) => ref !== null);
|
|
1078
|
+
if (deleted.length > 0) {
|
|
1079
|
+
return { class: REF_DELETE_CLASS, rule: "git-ref-delete", path: deleted.join(" ") };
|
|
1080
|
+
}
|
|
922
1081
|
let sawMain = false;
|
|
923
1082
|
for (const refspec of refspecs) {
|
|
924
1083
|
const colon = refspec.indexOf(":");
|
|
925
1084
|
const destination = colon === -1 ? refspec : refspec.slice(colon + 1);
|
|
926
|
-
// `:branch` (empty source) and `src:` (empty destination) both delete a
|
|
927
|
-
// remote ref. A deletion is destructive whatever it names, so it takes the
|
|
928
|
-
// stricter class rather than the branch one.
|
|
929
|
-
if (destination.length === 0 || (colon !== -1 && refspec.slice(0, colon).length === 0)) {
|
|
930
|
-
return { class: "vcs.push.main", rule: "git-push-delete" };
|
|
931
|
-
}
|
|
932
1085
|
if (isUnknownValue(destination)) {
|
|
933
1086
|
sawMain = true;
|
|
934
1087
|
continue;
|
|
@@ -955,6 +1108,211 @@ function refineGitPush(ctx) {
|
|
|
955
1108
|
* Everything that is not provably scratch keeps the old class.
|
|
956
1109
|
*/
|
|
957
1110
|
const SCRATCH_DELETE_CLASS = "files.delete.scratch";
|
|
1111
|
+
// ---------------------------------------------------------------------------
|
|
1112
|
+
// Agent harnesses (APRV-354)
|
|
1113
|
+
// ---------------------------------------------------------------------------
|
|
1114
|
+
/**
|
|
1115
|
+
* Launching an agent harness is its own class family, `harness.launch.NAME`.
|
|
1116
|
+
*
|
|
1117
|
+
* Until this, a command whose first word was `codex`, `muse`, `grok`, `claude`
|
|
1118
|
+
* or `cursor-agent` was `unclassified`: denied, which is fail closed and also
|
|
1119
|
+
* blunt. It told an approver nothing, it gave a human no class to grant through
|
|
1120
|
+
* the ordinary manual path, and it meant a lane could not so much as read a
|
|
1121
|
+
* harness version without going around the gate. The family fixes the second
|
|
1122
|
+
* half without touching the first: an unknown class still falls to
|
|
1123
|
+
* `defaults.autonomy`, so a project that adopts nothing is exactly where it was.
|
|
1124
|
+
*
|
|
1125
|
+
* ## The laundering hazard, which is why this is a class and not a table row
|
|
1126
|
+
*
|
|
1127
|
+
* A launched harness runs its own tools OUTSIDE this gate unless that harness's
|
|
1128
|
+
* own adapter is installed and attested. So a grant of `harness.launch.codex`
|
|
1129
|
+
* authorizes the LAUNCH and nothing the launched session then does — every
|
|
1130
|
+
* file it writes, every command it runs and every request it makes happens
|
|
1131
|
+
* where this runtime cannot see it. A policy that made the family autonomous
|
|
1132
|
+
* would have written itself out of the gate in one line, which is why SPEC.md
|
|
1133
|
+
* §7 says the family is never inferred autonomous and why the reference
|
|
1134
|
+
* proposal holds it at `manual`. The blessed Codex entry point is the confined
|
|
1135
|
+
* `approval codex start` (APRV-325.3), which keeps its own class.
|
|
1136
|
+
*
|
|
1137
|
+
* ## Muse, which carries a second hazard
|
|
1138
|
+
*
|
|
1139
|
+
* A Muse Code model id ending `-contributor` trades price for permission to
|
|
1140
|
+
* train on prompts and completions, so running one over real code shares that
|
|
1141
|
+
* code. Carter has ruled that Muse must never run with a Contributor model
|
|
1142
|
+
* selected. The classifier cannot enforce that — the model is a self-reported
|
|
1143
|
+
* argument, and a session may select one in its own UI where no command line
|
|
1144
|
+
* shows it — so it does the one thing a classifier honestly can: it BINDS the
|
|
1145
|
+
* `--model` value where it appears and gives a contributor value its own rule
|
|
1146
|
+
* id, so a prompt and a policy can both see it. This may only ever RAISE
|
|
1147
|
+
* scrutiny. A `-standard` id, or no `--model` at all, returns the ordinary rule
|
|
1148
|
+
* id and changes nothing, because a self-reported field that could lower
|
|
1149
|
+
* scrutiny is a field an agent would learn to write (SPEC.md §11.1 invariant 4).
|
|
1150
|
+
*
|
|
1151
|
+
* ## Where the family's name lives
|
|
1152
|
+
*
|
|
1153
|
+
* {@link HARNESS_LAUNCH_PREFIX} is `core/policy-match.ts`'s, not this file's,
|
|
1154
|
+
* and is imported rather than repeated. Two enforcement paths refuse a launch
|
|
1155
|
+
* that no policy rule names (`harnessLaunchNeedsRule`), and a second spelling of
|
|
1156
|
+
* the prefix is the shape of bug that closes one of those doors and leaves the
|
|
1157
|
+
* other open.
|
|
1158
|
+
*/
|
|
1159
|
+
/** The rule id prefix, so a reader can tell a launch row from a probe. */
|
|
1160
|
+
const HARNESS_LAUNCH_RULE_PREFIX = "harness-launch-";
|
|
1161
|
+
/**
|
|
1162
|
+
* Harness binaries, by BASENAME, to the name their class carries.
|
|
1163
|
+
*
|
|
1164
|
+
* Basenames, because that is what {@link classifySegment} derives before any
|
|
1165
|
+
* rule sees a command, and it is what makes `/opt/homebrew/bin/codex`,
|
|
1166
|
+
* `~/.local/bin/muse` and `$HOME/.local/bin/muse` all land here without this
|
|
1167
|
+
* table knowing anything about where a binary lives. A spelling the basename
|
|
1168
|
+
* derivation cannot see through — `$MUSE_BIN`, a wrapper script of another
|
|
1169
|
+
* name — is `unclassified`, which is the answer it had before and the answer it
|
|
1170
|
+
* should keep.
|
|
1171
|
+
*
|
|
1172
|
+
* `cursor-agent` carries the name `cursor` so the class reads
|
|
1173
|
+
* `harness.launch.cursor` beside the `cursor` adapter and the `.cursor/`
|
|
1174
|
+
* protected paths. `gemini` is deliberately absent: APRV-354 names five
|
|
1175
|
+
* harnesses, `gemini update` keeps its `deps.upgrade` row above this one, and a
|
|
1176
|
+
* bare `gemini` stays `unclassified` until somebody makes that its own decision.
|
|
1177
|
+
*/
|
|
1178
|
+
const HARNESS_BINS = {
|
|
1179
|
+
codex: "codex",
|
|
1180
|
+
muse: "muse",
|
|
1181
|
+
grok: "grok",
|
|
1182
|
+
claude: "claude",
|
|
1183
|
+
"cursor-agent": "cursor",
|
|
1184
|
+
};
|
|
1185
|
+
/**
|
|
1186
|
+
* Package specs a package runner may name, EXACTLY, to the same harness names.
|
|
1187
|
+
*
|
|
1188
|
+
* Exact, and the exactness is the rule: `npx codex-helper` is not a codex
|
|
1189
|
+
* launch, and a substring match that said it was would let any package whose
|
|
1190
|
+
* name happens to contain a harness's take a class it did not earn. A spec this
|
|
1191
|
+
* table does not know keeps whatever class the runner already had.
|
|
1192
|
+
*/
|
|
1193
|
+
const HARNESS_PACKAGES = {
|
|
1194
|
+
codex: "codex",
|
|
1195
|
+
"@openai/codex": "codex",
|
|
1196
|
+
claude: "claude",
|
|
1197
|
+
"@anthropic-ai/claude-code": "claude",
|
|
1198
|
+
"cursor-agent": "cursor",
|
|
1199
|
+
muse: "muse",
|
|
1200
|
+
grok: "grok",
|
|
1201
|
+
};
|
|
1202
|
+
/**
|
|
1203
|
+
* Argv that starts nothing: a version or help probe.
|
|
1204
|
+
*
|
|
1205
|
+
* A probe prints a string and exits, so it is a read, and reading a harness's
|
|
1206
|
+
* own version is exactly what a session needs to be able to do without a
|
|
1207
|
+
* prompt. `help` counts only as the WHOLE argv: `codex help` prints usage,
|
|
1208
|
+
* while `codex help me refactor this` is a session.
|
|
1209
|
+
*/
|
|
1210
|
+
const HARNESS_PROBE_FLAGS = ["--version", "-V", "--help", "-h"];
|
|
1211
|
+
/** The probe's rule id and class. A probe starts no session, so it reads. */
|
|
1212
|
+
const HARNESS_PROBE_RULE = "harness-probe";
|
|
1213
|
+
const HARNESS_PROBE_CLASS = "read.shell";
|
|
1214
|
+
/** The rule id a Muse launch takes when its `--model` names a Contributor model. */
|
|
1215
|
+
const MUSE_CONTRIBUTOR_RULE = "harness-launch-muse-contributor";
|
|
1216
|
+
/**
|
|
1217
|
+
* The suffix that marks a Muse model as training on what it is shown.
|
|
1218
|
+
*
|
|
1219
|
+
* Exported since APRV-350 so the hook adapter's contributor guard and this
|
|
1220
|
+
* classifier's `harness.launch.muse` refinement test the SAME mark. Two
|
|
1221
|
+
* spellings of "which models are unsafe" would drift, and the direction they
|
|
1222
|
+
* drift in is the one where a launch is refused and a tool call is not.
|
|
1223
|
+
*/
|
|
1224
|
+
export const CONTRIBUTOR_SUFFIX = "-contributor";
|
|
1225
|
+
/** The `--model` value in either spelling, or `null` when none is written. */
|
|
1226
|
+
function harnessModel(args) {
|
|
1227
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1228
|
+
const arg = args[index];
|
|
1229
|
+
if (arg === "--model") {
|
|
1230
|
+
const value = args[index + 1];
|
|
1231
|
+
return value === undefined || isFlag(value) ? null : value;
|
|
1232
|
+
}
|
|
1233
|
+
if (arg.startsWith("--model="))
|
|
1234
|
+
return arg.slice("--model=".length);
|
|
1235
|
+
}
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* The harness a package spec names, version suffix stripped, or `null`.
|
|
1240
|
+
*
|
|
1241
|
+
* `@openai/codex@0.152.1` is the scoped case the split has to get right: the
|
|
1242
|
+
* `@` that opens a scope is not the `@` that opens a version.
|
|
1243
|
+
*/
|
|
1244
|
+
function harnessPackage(spec) {
|
|
1245
|
+
const at = spec.startsWith("@") ? spec.indexOf("@", 1) : spec.indexOf("@");
|
|
1246
|
+
const bare = at === -1 ? spec : spec.slice(0, at);
|
|
1247
|
+
return HARNESS_PACKAGES[bare] ?? null;
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* One harness invocation, given its name and the argv that follows its identity.
|
|
1251
|
+
*
|
|
1252
|
+
* Shared by the direct rows and the package-runner refinement, so a launch
|
|
1253
|
+
* spelled `npx @openai/codex exec` answers exactly as `codex exec` does. The
|
|
1254
|
+
* argv is bound to `path`: the segment's own `text` carries the command as
|
|
1255
|
+
* written, and `path` carries the part the class is ABOUT, which is what lets a
|
|
1256
|
+
* channel name it without re-parsing the line.
|
|
1257
|
+
*/
|
|
1258
|
+
function harnessRefinement(name, args) {
|
|
1259
|
+
if (args.length === 1 &&
|
|
1260
|
+
(HARNESS_PROBE_FLAGS.includes(args[0]) || args[0] === "help")) {
|
|
1261
|
+
return { class: HARNESS_PROBE_CLASS, rule: HARNESS_PROBE_RULE };
|
|
1262
|
+
}
|
|
1263
|
+
// Anything else is a session. A probe flag beside other arguments is NOT a
|
|
1264
|
+
// probe here: the classifier cannot know which of the two the binary will
|
|
1265
|
+
// honour, and the stricter reading of an ambiguous harness invocation is the
|
|
1266
|
+
// one that assumes a session started.
|
|
1267
|
+
const model = name === "muse" ? harnessModel(args) : null;
|
|
1268
|
+
const contributor = model !== null && model.toLowerCase().endsWith(CONTRIBUTOR_SUFFIX);
|
|
1269
|
+
return {
|
|
1270
|
+
class: `${HARNESS_LAUNCH_PREFIX}${name}`,
|
|
1271
|
+
rule: contributor ? MUSE_CONTRIBUTOR_RULE : `${HARNESS_LAUNCH_RULE_PREFIX}${name}`,
|
|
1272
|
+
...(args.length === 0 ? {} : { path: args.join(" ") }),
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
/** `codex …`, `muse …`, `grok …`, `claude …`, `cursor-agent …`. */
|
|
1276
|
+
function refineHarness(ctx) {
|
|
1277
|
+
const name = HARNESS_BINS[ctx.bin];
|
|
1278
|
+
// Unreachable through the table, which matches on these basenames; a defensive
|
|
1279
|
+
// arm rather than a silent wrong class if a row is ever edited apart from the
|
|
1280
|
+
// table it is generated from.
|
|
1281
|
+
if (name === undefined) {
|
|
1282
|
+
return { opaque: `${ctx.bin} is an agent harness this table cannot name` };
|
|
1283
|
+
}
|
|
1284
|
+
return harnessRefinement(name, ctx.args);
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* `npx`, `tsx`, `tsc`, … — unchanged, except a package runner naming a harness.
|
|
1288
|
+
*
|
|
1289
|
+
* `npx @openai/codex` starts the same session `codex` starts, and before this
|
|
1290
|
+
* it was `files.write.workspace`: the looser of the two answers, reachable by
|
|
1291
|
+
* typing four extra characters. The refinement is deliberately narrow — only
|
|
1292
|
+
* `npx`, only an EXACT package spec, and everything else returns the row's own
|
|
1293
|
+
* answer byte for byte, which is what keeps this from being a widening of the
|
|
1294
|
+
* workspace-tool row.
|
|
1295
|
+
*/
|
|
1296
|
+
function refineWorkspaceTool(ctx) {
|
|
1297
|
+
const unchanged = { class: "files.write.workspace", rule: "workspace-tool" };
|
|
1298
|
+
if (ctx.bin !== "npx")
|
|
1299
|
+
return unchanged;
|
|
1300
|
+
const index = ctx.args.findIndex((arg) => !isFlag(arg));
|
|
1301
|
+
if (index === -1)
|
|
1302
|
+
return unchanged;
|
|
1303
|
+
const name = harnessPackage(ctx.args[index]);
|
|
1304
|
+
if (name === null)
|
|
1305
|
+
return unchanged;
|
|
1306
|
+
return harnessRefinement(name, ctx.args.slice(index + 1));
|
|
1307
|
+
}
|
|
1308
|
+
/** The five generated harness rows, one per binary, sharing one refinement. */
|
|
1309
|
+
const HARNESS_RULES = Object.entries(HARNESS_BINS).map(([bin, name]) => ({
|
|
1310
|
+
id: `${HARNESS_LAUNCH_RULE_PREFIX}${name}`,
|
|
1311
|
+
bins: [bin],
|
|
1312
|
+
class: `${HARNESS_LAUNCH_PREFIX}${name}`,
|
|
1313
|
+
emits: [HARNESS_PROBE_CLASS],
|
|
1314
|
+
refine: refineHarness,
|
|
1315
|
+
}));
|
|
958
1316
|
/**
|
|
959
1317
|
* Is `candidate` a STRICT descendant of `root`? Both are compared by path
|
|
960
1318
|
* segment, so `/private/tmpfoo` is not under `/private/tmp` and a root is never
|
|
@@ -998,6 +1356,43 @@ function allTargetsAreScratch(targets, roots) {
|
|
|
998
1356
|
}
|
|
999
1357
|
return true;
|
|
1000
1358
|
}
|
|
1359
|
+
// ===========================================================================
|
|
1360
|
+
// Read scope (read.file.out_of_scope, APRV-347)
|
|
1361
|
+
// ===========================================================================
|
|
1362
|
+
/** The rule id for a read whose ABSOLUTE target sits outside every root. */
|
|
1363
|
+
export const READ_OUT_OF_SCOPE_RULE = "read-out-of-scope";
|
|
1364
|
+
/** The rule id for a read target whose expansion the text cannot show. */
|
|
1365
|
+
export const READ_UNREADABLE_TARGET_RULE = "read-unreadable-path";
|
|
1366
|
+
/**
|
|
1367
|
+
* The first target of this read command that the TEXT places outside every
|
|
1368
|
+
* root, or `null` when nothing here settles it.
|
|
1369
|
+
*
|
|
1370
|
+
* `null` covers three different situations on purpose, and all three are
|
|
1371
|
+
* handed on rather than decided: the binary is not a scoped reader, every
|
|
1372
|
+
* target is provably inside a root, or a target is relative (or carries `..`)
|
|
1373
|
+
* and therefore means nothing without a working directory. The last of those
|
|
1374
|
+
* is the common case, and it is why the hook's second pass exists.
|
|
1375
|
+
*/
|
|
1376
|
+
function escapedReadTarget(bin, positionals, args, roots) {
|
|
1377
|
+
const targets = readTargetsOf(bin, positionals, args);
|
|
1378
|
+
if (targets === null)
|
|
1379
|
+
return null;
|
|
1380
|
+
for (const target of targets) {
|
|
1381
|
+
switch (readTargetVerdict(target, roots)) {
|
|
1382
|
+
case "out-of-scope":
|
|
1383
|
+
return {
|
|
1384
|
+
path: target,
|
|
1385
|
+
rule: isUnreadableTarget(target)
|
|
1386
|
+
? READ_UNREADABLE_TARGET_RULE
|
|
1387
|
+
: READ_OUT_OF_SCOPE_RULE,
|
|
1388
|
+
};
|
|
1389
|
+
case "in-scope":
|
|
1390
|
+
case "needs-disk":
|
|
1391
|
+
break;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
return null;
|
|
1395
|
+
}
|
|
1001
1396
|
/** `rm` — everything outside the workspace, and every unreadable path, is manual. */
|
|
1002
1397
|
function refineRm(ctx) {
|
|
1003
1398
|
const recursive = hasFlag(ctx.args, ["--recursive"]) || hasShortFlag(ctx.args, ["r", "R"]);
|
|
@@ -1427,12 +1822,28 @@ function isGateEntrypoint(path) {
|
|
|
1427
1822
|
* ritual reached the approver's phone as `policy.edit` over a protected path —
|
|
1428
1823
|
* true, and useless. Classified by name it arrives as what it is.
|
|
1429
1824
|
*
|
|
1430
|
-
* `
|
|
1431
|
-
*
|
|
1825
|
+
* `policy attest --path` (APRV-338) is the newest member and the one that reads
|
|
1826
|
+
* a FLAG, because the flag is what changes the act. Without it the verb attests
|
|
1827
|
+
* the policy file or a gate organ, which are records about the gate's own
|
|
1828
|
+
* configuration; with it the verb ratifies protected TEXT, and that record is
|
|
1829
|
+
* what resolves SPEC.md's pending-sign-off suffix. An agent able to write one
|
|
1830
|
+
* could ratify its own amendments, so it is classified where the rest of the
|
|
1831
|
+
* gate's ceremonies are: `policy.core`, which this repository's policy holds
|
|
1832
|
+
* human-only, so the hook denies it with `hook-class-human-only` before the
|
|
1833
|
+
* verb's own `actor-not-human` refusal is ever reached. It mints no new class
|
|
1834
|
+
* (§11.1 invariant 9): `policy.core` already exists and is already in the row's
|
|
1835
|
+
* `emits`.
|
|
1836
|
+
*
|
|
1837
|
+
* `positionals` is read rather than `args` for the verb words, so a flag between
|
|
1838
|
+
* them cannot hide the verb: `approval --json log sync` is the same invocation.
|
|
1839
|
+
* `args` is read only where a flag is the act, as it is above.
|
|
1432
1840
|
*/
|
|
1433
|
-
function refineApprovalVerb(positionals) {
|
|
1841
|
+
function refineApprovalVerb(positionals, args = []) {
|
|
1434
1842
|
const verb = positionals[0];
|
|
1435
1843
|
const sub = positionals[1];
|
|
1844
|
+
if (verb === "policy" && sub === "attest" && hasFlag(args, ["--path"])) {
|
|
1845
|
+
return { class: "policy.core", rule: "approval-policy-signoff" };
|
|
1846
|
+
}
|
|
1436
1847
|
if (verb === "quickstart") {
|
|
1437
1848
|
return { class: "policy.core", rule: "approval-quickstart" };
|
|
1438
1849
|
}
|
|
@@ -1461,6 +1872,22 @@ function refineApprovalVerb(positionals) {
|
|
|
1461
1872
|
return { class: "policy.core", rule: "approval-gate-close" };
|
|
1462
1873
|
return null;
|
|
1463
1874
|
}
|
|
1875
|
+
// APRV-343. `policy apply` WRITES `APPROVAL.md`, which is the one file in this
|
|
1876
|
+
// repository nothing but a human's own hand may change: an agent that could
|
|
1877
|
+
// run it could widen the policy that governs it and then attest the result
|
|
1878
|
+
// through the amendment the verb goes on to run. Classified where the file
|
|
1879
|
+
// already is (`policy.core`, human-only in the reference policy), so the hook
|
|
1880
|
+
// denies it with `hook-class-human-only`, behind the verb's own
|
|
1881
|
+
// `apply-agent-actor` refusal. It mints no new class (SPEC.md §11.1 invariant
|
|
1882
|
+
// 9): `policy.core` already exists and already covers the policy's machinery.
|
|
1883
|
+
//
|
|
1884
|
+
// The other `policy` subcommands stay pass-through. `check` and `test` read,
|
|
1885
|
+
// `attest` and `amend` refuse a non-human actor in code and collect a human's
|
|
1886
|
+
// tap through a channel when an agent runs them, which is the widening
|
|
1887
|
+
// APRV-109 deliberately made — and neither of them writes the policy file.
|
|
1888
|
+
if (verb === "policy" && sub === "apply") {
|
|
1889
|
+
return { class: "policy.core", rule: "approval-policy-apply" };
|
|
1890
|
+
}
|
|
1464
1891
|
// APRV-257. `setup checkpoint` MINTS the key `log checkpoint` signs with, so
|
|
1465
1892
|
// an agent that could run it could mint a key, store it, and vouch for a
|
|
1466
1893
|
// chain it had just written — the mechanism defeated at its source rather
|
|
@@ -1487,7 +1914,7 @@ function refineApprovalVerb(positionals) {
|
|
|
1487
1914
|
* two log verbs keeps the pass-through class and the row's own rule id.
|
|
1488
1915
|
*/
|
|
1489
1916
|
function refineApproval(ctx) {
|
|
1490
|
-
return refineApprovalVerb(ctx.positionals) ?? { class: GATE_SELF_CLASS, rule: "approval" };
|
|
1917
|
+
return (refineApprovalVerb(ctx.positionals, ctx.args) ?? { class: GATE_SELF_CLASS, rule: "approval" });
|
|
1491
1918
|
}
|
|
1492
1919
|
/**
|
|
1493
1920
|
* `node` — an inline script is opaque, the gate's own entry point is
|
|
@@ -1500,7 +1927,7 @@ function refineNode(ctx) {
|
|
|
1500
1927
|
if (script !== undefined && isGateEntrypoint(script)) {
|
|
1501
1928
|
// `node cli.js log sync` is `approval log sync` spelled the long way, and
|
|
1502
1929
|
// it must classify identically or the classification is a spelling test.
|
|
1503
|
-
return (refineApprovalVerb(ctx.positionals.slice(1)) ?? {
|
|
1930
|
+
return (refineApprovalVerb(ctx.positionals.slice(1), ctx.args) ?? {
|
|
1504
1931
|
class: GATE_SELF_CLASS,
|
|
1505
1932
|
rule: "node-approval-cli",
|
|
1506
1933
|
});
|
|
@@ -1524,7 +1951,7 @@ export const COMMAND_RULES = [
|
|
|
1524
1951
|
bins: ["git"],
|
|
1525
1952
|
subs: ["push"],
|
|
1526
1953
|
class: "vcs.push.main",
|
|
1527
|
-
emits: ["vcs.push.branch", "vcs.push.main", "vcs.history.rewrite"],
|
|
1954
|
+
emits: ["vcs.push.branch", "vcs.push.main", "vcs.history.rewrite", "vcs.ref.delete"],
|
|
1528
1955
|
refine: refineGitPush,
|
|
1529
1956
|
},
|
|
1530
1957
|
{
|
|
@@ -1649,8 +2076,20 @@ export const COMMAND_RULES = [
|
|
|
1649
2076
|
// harness unattended. `uca` matches with ANY arguments, `--dry-run` included:
|
|
1650
2077
|
// the classifier reads text, cannot know which flags the script honours, and
|
|
1651
2078
|
// the strictest reading of an updater is that it updates.
|
|
2079
|
+
//
|
|
2080
|
+
// APRV-354 answers the other half of that sentence: the launch rows below now
|
|
2081
|
+
// name what a bare `claude` or `codex …` is. This row stays ABOVE them on
|
|
2082
|
+
// purpose, so `codex update` and `claude update` keep `deps.upgrade` — an
|
|
2083
|
+
// upgrade swaps the binary that hosts the hook, which is the stricter of the
|
|
2084
|
+
// two readings and the class they already had.
|
|
1652
2085
|
{ id: "harness-update", bins: ["claude", "codex", "gemini"], subs: ["update"], class: "deps.upgrade" },
|
|
1653
2086
|
{ id: "harness-updater", bins: ["uca"], class: "deps.upgrade" },
|
|
2087
|
+
// -- agent harness launch (APRV-354) --------------------------------------
|
|
2088
|
+
// Generated from {@link HARNESS_BINS}, one row per binary, all sharing
|
|
2089
|
+
// {@link refineHarness}. Below `harness-update` so an upgrade keeps its
|
|
2090
|
+
// class; above the workspace tools so a package-runner spelling is the only
|
|
2091
|
+
// one that has to be refined rather than matched.
|
|
2092
|
+
...HARNESS_RULES,
|
|
1654
2093
|
// -- workspace tools -----------------------------------------------------
|
|
1655
2094
|
// APRV-193: three of the rules below hand control to code the runtime did not
|
|
1656
2095
|
// author, and they are named in {@link CODE_EXECUTING_RULES}.
|
|
@@ -1672,6 +2111,14 @@ export const COMMAND_RULES = [
|
|
|
1672
2111
|
id: "workspace-tool",
|
|
1673
2112
|
bins: ["npx", "tsx", "ts-node", "tsc", "oxlint", "eslint", "prettier", "vitest", "jest", "backlog", "make"],
|
|
1674
2113
|
class: "files.write.workspace",
|
|
2114
|
+
// APRV-354: only `npx` naming a harness package changes; see
|
|
2115
|
+
// {@link refineWorkspaceTool}, which returns this row's own answer for
|
|
2116
|
+
// every other binary and every other package.
|
|
2117
|
+
emits: [
|
|
2118
|
+
HARNESS_PROBE_CLASS,
|
|
2119
|
+
...Object.values(HARNESS_PACKAGES).map((name) => `${HARNESS_LAUNCH_PREFIX}${name}`),
|
|
2120
|
+
],
|
|
2121
|
+
refine: refineWorkspaceTool,
|
|
1675
2122
|
},
|
|
1676
2123
|
{
|
|
1677
2124
|
id: "workspace-write",
|
|
@@ -1830,9 +2277,17 @@ function refineGh(ctx) {
|
|
|
1830
2277
|
* Binaries whose effect lives in a string this classifier will not interpret.
|
|
1831
2278
|
*
|
|
1832
2279
|
* A second parser for the same text is a second answer waiting to disagree with
|
|
1833
|
-
* the shell's, so these refuse instead. `
|
|
1834
|
-
*
|
|
2280
|
+
* the shell's, so these refuse instead. `eval`, `xargs` and the `-e`
|
|
2281
|
+
* interpreters can express anything at all; `sudo` and `env` re-launch
|
|
1835
2282
|
* something else with different authority.
|
|
2283
|
+
*
|
|
2284
|
+
* ONE NARROW EXCEPTION since APRV-380, and it is a narrowing of this rule
|
|
2285
|
+
* rather than a hole in it: a segment that is EXACTLY a known shell, one
|
|
2286
|
+
* inline-script flag and one script word is classified by the script, through
|
|
2287
|
+
* this same classifier. No second parser is written and the text is not read a
|
|
2288
|
+
* second way. {@link loginShellScript} states the shape and the reasoning, and
|
|
2289
|
+
* everything outside it — including the same shell with a script file, or a
|
|
2290
|
+
* shell nested inside an unwrapped script — is refused here exactly as before.
|
|
1836
2291
|
*/
|
|
1837
2292
|
const OPAQUE_BINS = {
|
|
1838
2293
|
bash: "runs a shell script",
|
|
@@ -1854,6 +2309,93 @@ const OPAQUE_BINS = {
|
|
|
1854
2309
|
timeout: "runs another command under a timer",
|
|
1855
2310
|
time: "runs another command under a timer",
|
|
1856
2311
|
};
|
|
2312
|
+
/**
|
|
2313
|
+
* The shells {@link loginShellScript} will unwrap: the six in
|
|
2314
|
+
* {@link OPAQUE_BINS} that run a script.
|
|
2315
|
+
*
|
|
2316
|
+
* The other opaque binaries stay opaque under every shape. `eval`, `xargs` and
|
|
2317
|
+
* the `-e` interpreters build their text from somewhere this file cannot see;
|
|
2318
|
+
* `sudo`, `doas`, `env`, `nohup`, `exec`, `source`, `.`, `watch`, `timeout` and
|
|
2319
|
+
* `time` re-launch something else, with different authority or under a timer,
|
|
2320
|
+
* and what they re-launch is an argv rather than a script.
|
|
2321
|
+
*/
|
|
2322
|
+
const UNWRAPPABLE_SHELLS = new Set([
|
|
2323
|
+
"bash",
|
|
2324
|
+
"sh",
|
|
2325
|
+
"zsh",
|
|
2326
|
+
"dash",
|
|
2327
|
+
"ksh",
|
|
2328
|
+
"fish",
|
|
2329
|
+
]);
|
|
2330
|
+
/**
|
|
2331
|
+
* The inline-script flags a wrapper may carry: `-c`, with any run of `l` and
|
|
2332
|
+
* `i` before it (APRV-380).
|
|
2333
|
+
*
|
|
2334
|
+
* `-lc` is the shape the 2026-09-18 probe recorded on every Codex exec request.
|
|
2335
|
+
* `c` must be LAST, because that is the letter that takes the next word as its
|
|
2336
|
+
* argument, and a combination where it is not last is one whose reading depends
|
|
2337
|
+
* on the shell's own option parser. That is a shape this rule declines to guess
|
|
2338
|
+
* at, so it stays opaque.
|
|
2339
|
+
*/
|
|
2340
|
+
const INLINE_SCRIPT_FLAG = /^-[li]*c$/u;
|
|
2341
|
+
/**
|
|
2342
|
+
* The script a LOGIN-SHELL WRAPPER runs, when the segment is exactly one
|
|
2343
|
+
* (APRV-380), or `null`.
|
|
2344
|
+
*
|
|
2345
|
+
* ## Why this is not the second parser the table above refuses
|
|
2346
|
+
*
|
|
2347
|
+
* {@link OPAQUE_BINS} states the position this narrows: a second parser for the
|
|
2348
|
+
* same text is a second answer waiting to disagree with the shell's. The hazard
|
|
2349
|
+
* that names is reading shell text a SECOND WAY. This is not that. When the
|
|
2350
|
+
* argv is exactly `[shell, -lc, script]` and nothing else, the script is the
|
|
2351
|
+
* text a Claude Code `Bash` call hands this classifier directly, and what
|
|
2352
|
+
* happens to it here is what happens to that: the same lexer, the same segment
|
|
2353
|
+
* rules, the same table. No new parser is written, and the text is not read a
|
|
2354
|
+
* second way — it is read the first way, by the only reader there is.
|
|
2355
|
+
*
|
|
2356
|
+
* ## The line, and it is exact
|
|
2357
|
+
*
|
|
2358
|
+
* Three words, no more: a known shell, one inline-script flag, one script. Any
|
|
2359
|
+
* of these keeps the wrapper opaque, because each is a shape whose effect
|
|
2360
|
+
* depends on something the argv alone does not say:
|
|
2361
|
+
*
|
|
2362
|
+
* - extra words (a script FILE, `--`, an option this rule does not model);
|
|
2363
|
+
* - an assignment prefix, which changes the environment the script runs in;
|
|
2364
|
+
* - a redirection on the wrapper, which is the outer shell's and not the
|
|
2365
|
+
* script's;
|
|
2366
|
+
* - a substitution in any of the three words, whose effect happens before the
|
|
2367
|
+
* shell even starts;
|
|
2368
|
+
* - a nested shell inside the script, refused by {@link ClassifierContext} when
|
|
2369
|
+
* the recursion runs (the inner classification unwraps nothing).
|
|
2370
|
+
*
|
|
2371
|
+
* The BINDING does not move. `cli/hook.ts` binds the outer command and argv
|
|
2372
|
+
* exactly as APRV-362 built them; what this changes is only which text is
|
|
2373
|
+
* classified, and the classes are additional evidence about bytes that are
|
|
2374
|
+
* bound elsewhere and unchanged.
|
|
2375
|
+
*/
|
|
2376
|
+
export function loginShellScript(segment) {
|
|
2377
|
+
if (segment.opaque !== null)
|
|
2378
|
+
return null;
|
|
2379
|
+
if (segment.redirects.length > 0)
|
|
2380
|
+
return null;
|
|
2381
|
+
if (segment.words.length !== 3)
|
|
2382
|
+
return null;
|
|
2383
|
+
const [shell, flag, script] = segment.words;
|
|
2384
|
+
if (shell.substitutions.length > 0)
|
|
2385
|
+
return null;
|
|
2386
|
+
if (flag.substitutions.length > 0)
|
|
2387
|
+
return null;
|
|
2388
|
+
if (script.substitutions.length > 0)
|
|
2389
|
+
return null;
|
|
2390
|
+
const base = pathSegments(shell.text).slice(-1)[0] ?? shell.text;
|
|
2391
|
+
if (!UNWRAPPABLE_SHELLS.has(base))
|
|
2392
|
+
return null;
|
|
2393
|
+
if (!INLINE_SCRIPT_FLAG.test(flag.text))
|
|
2394
|
+
return null;
|
|
2395
|
+
// An empty script runs nothing, and `classifyCommand` answers `unclassified`
|
|
2396
|
+
// for an empty command. Leaving it wrapped keeps that answer the wrapper's.
|
|
2397
|
+
return script.text.trim().length === 0 ? null : script.text;
|
|
2398
|
+
}
|
|
1857
2399
|
/** Interpreters that are opaque only when handed inline source. */
|
|
1858
2400
|
const INLINE_SOURCE_BINS = {
|
|
1859
2401
|
python: ["-c"],
|
|
@@ -1962,6 +2504,20 @@ export const CODE_EXECUTING_RULES = [
|
|
|
1962
2504
|
"node-script",
|
|
1963
2505
|
/** `npx`, `tsx`, `tsc`, `vitest`, `jest`, `make`, and kin. */
|
|
1964
2506
|
"workspace-tool",
|
|
2507
|
+
/**
|
|
2508
|
+
* Launching an agent harness, and probing one (APRV-354).
|
|
2509
|
+
*
|
|
2510
|
+
* Every spelling is here, the probe included. A launch hands control to a
|
|
2511
|
+
* whole second agent, which is the most complete form of "code the runtime
|
|
2512
|
+
* did not author"; a probe still executes the same binary. The list is
|
|
2513
|
+
* matched against a segment's RULE, so the generated launch ids and the two
|
|
2514
|
+
* ids a refinement can return on its own — the probe and the Muse
|
|
2515
|
+
* contributor id — all have to be named, or `npx @openai/codex` would have
|
|
2516
|
+
* quietly stopped requiring a sandbox by gaining a better class.
|
|
2517
|
+
*/
|
|
2518
|
+
...HARNESS_RULES.map((rule) => rule.id),
|
|
2519
|
+
HARNESS_PROBE_RULE,
|
|
2520
|
+
MUSE_CONTRIBUTOR_RULE,
|
|
1965
2521
|
];
|
|
1966
2522
|
/**
|
|
1967
2523
|
* Every class the table can emit, for docs and for the dogfood test.
|
|
@@ -1988,8 +2544,45 @@ export const CLASSIFIER_CLASSES = (() => {
|
|
|
1988
2544
|
seen.add(CREDENTIAL_CLASS);
|
|
1989
2545
|
seen.add("files.write.workspace");
|
|
1990
2546
|
seen.add("read.shell");
|
|
2547
|
+
// APRV-347: emitted from `classifySegment`'s tail rather than from a row of
|
|
2548
|
+
// the table, because it is a refinement of `read.shell` against roots the
|
|
2549
|
+
// CALLER resolved and no binary implies it on its own.
|
|
2550
|
+
seen.add(READ_OUT_OF_SCOPE_CLASS);
|
|
1991
2551
|
return [...seen].sort();
|
|
1992
2552
|
})();
|
|
2553
|
+
/**
|
|
2554
|
+
* The class the DAEMON's own cadence advance is gated as (APRV-382).
|
|
2555
|
+
*
|
|
2556
|
+
* The sub-class exists because the policy grammar has no actor condition and
|
|
2557
|
+
* this repository wanted one: an advance publishes records the log already
|
|
2558
|
+
* holds, so the daemon may make it unattended, while the same act from a
|
|
2559
|
+
* session in a worktree or a human terminal stays supervised. Two classes are
|
|
2560
|
+
* how that is written down, and which of them a cycle asks under is decided by
|
|
2561
|
+
* `core/advance-cycle.ts` from the running process, never from an argument.
|
|
2562
|
+
*
|
|
2563
|
+
* NO COMMAND SPELLS IT, on purpose. `approval log advance` classifies
|
|
2564
|
+
* `log.advance` whoever types it, so the looser line is unreachable from a
|
|
2565
|
+
* shell an agent can drive: it is reached only from inside the daemon process,
|
|
2566
|
+
* which an agent cannot become without a `gate.self` command this policy holds
|
|
2567
|
+
* at the manual default.
|
|
2568
|
+
*/
|
|
2569
|
+
export const ADVANCE_DAEMON_CLASS = "log.advance.daemon";
|
|
2570
|
+
/**
|
|
2571
|
+
* Classes this RUNTIME emits for its own gated actions, which no command spells.
|
|
2572
|
+
*
|
|
2573
|
+
* Separate from {@link CLASSIFIER_CLASSES}, which is the binary table's own set
|
|
2574
|
+
* and is what `docs/claude-code-hook.md` documents row by row. A class here is
|
|
2575
|
+
* emitted by a runtime cycle that registers and requests it directly — the
|
|
2576
|
+
* daemon's advance is the first — so a policy declaring it is declaring a line
|
|
2577
|
+
* that CAN fire, and the reachability check `core/policy-expectations.ts` runs
|
|
2578
|
+
* at the amendment ceremony must say so. Without this list that ceremony would
|
|
2579
|
+
* refuse the line `unreachable`, which is a true statement about the command
|
|
2580
|
+
* classifier and a false one about the runtime.
|
|
2581
|
+
*
|
|
2582
|
+
* Adding a name here is a claim that some path in this codebase asks the gate
|
|
2583
|
+
* for that class, and widening it is a reviewable diff.
|
|
2584
|
+
*/
|
|
2585
|
+
export const RUNTIME_CLASSES = [ADVANCE_DAEMON_CLASS];
|
|
1993
2586
|
/**
|
|
1994
2587
|
* Can the classifier emit `actionClass` for a project whose policy carries
|
|
1995
2588
|
* these `protected_paths`? (APRV-266.)
|
|
@@ -2005,10 +2598,17 @@ export const CLASSIFIER_CLASSES = (() => {
|
|
|
2005
2598
|
* A routed name is reachable exactly when some entry routes to it. A
|
|
2006
2599
|
* `policy.edit.spec` rule in a policy whose `protected_paths` routes nothing to
|
|
2007
2600
|
* it is a line that will never fire, and saying so is the whole point.
|
|
2601
|
+
*
|
|
2602
|
+
* {@link RUNTIME_CLASSES} is the third answer (APRV-382): a class no command
|
|
2603
|
+
* spells and a runtime cycle asks for directly is reachable in every project,
|
|
2604
|
+
* with no policy entry needed, because the path that emits it is in this
|
|
2605
|
+
* codebase rather than in the operator's file.
|
|
2008
2606
|
*/
|
|
2009
2607
|
export function emittableClass(actionClass, protectedPaths = []) {
|
|
2010
2608
|
if (CLASSIFIER_CLASSES.includes(actionClass))
|
|
2011
2609
|
return true;
|
|
2610
|
+
if (RUNTIME_CLASSES.includes(actionClass))
|
|
2611
|
+
return true;
|
|
2012
2612
|
if (!POLICY_EDIT_SUBCLASS.test(actionClass))
|
|
2013
2613
|
return false;
|
|
2014
2614
|
return protectedPaths.some((entry) => parseProtectedEntry(entry)?.routed === actionClass);
|
|
@@ -2250,6 +2850,11 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
2250
2850
|
}
|
|
2251
2851
|
let cls = refined === null ? rule.class : refined.class;
|
|
2252
2852
|
let ruleId = refined === null ? rule.id : refined.rule;
|
|
2853
|
+
// The value a refinement bound, when one did (APRV-352). Same field and same
|
|
2854
|
+
// meaning as the protected-path binding above: the words the classifier
|
|
2855
|
+
// matched, verbatim, so an approver is told WHICH refs a deletion names
|
|
2856
|
+
// rather than being handed a class and left to re-read the command.
|
|
2857
|
+
const boundPath = refined !== null && "path" in refined ? refined.path : undefined;
|
|
2253
2858
|
// A protected path anywhere in an effectful segment takes that path's class:
|
|
2254
2859
|
// the command is editing the gate, whatever else it is doing. Every
|
|
2255
2860
|
// positional is scanned, source and destination alike, so `cp` stays
|
|
@@ -2268,7 +2873,31 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
2268
2873
|
cls = "files.write.workspace";
|
|
2269
2874
|
ruleId = "redirect-write";
|
|
2270
2875
|
}
|
|
2271
|
-
|
|
2876
|
+
// APRV-347, last because it is the narrowest: a read whose target the TEXT
|
|
2877
|
+
// places outside every root the caller named. Only `read.shell` is scoped —
|
|
2878
|
+
// `read.web` reaches no file, and a segment that has already taken a
|
|
2879
|
+
// protected, credential or write class is not a read at all. The relative
|
|
2880
|
+
// and symlinked cases are deliberately NOT decided here; they are left at
|
|
2881
|
+
// `read.shell` for the hook's disk pass, which tightens and never loosens.
|
|
2882
|
+
if (cls === "read.shell" && (context.readRoots ?? []).length > 0) {
|
|
2883
|
+
const escaped = escapedReadTarget(basename, positionals, args, context.readRoots ?? []);
|
|
2884
|
+
if (escaped !== null) {
|
|
2885
|
+
return {
|
|
2886
|
+
ok: true,
|
|
2887
|
+
class: READ_OUT_OF_SCOPE_CLASS,
|
|
2888
|
+
rule: escaped.rule,
|
|
2889
|
+
path: escaped.path,
|
|
2890
|
+
...(sandbox === null ? {} : { sandbox }),
|
|
2891
|
+
};
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
return {
|
|
2895
|
+
ok: true,
|
|
2896
|
+
class: cls,
|
|
2897
|
+
rule: ruleId,
|
|
2898
|
+
...(boundPath === undefined ? {} : { path: boundPath }),
|
|
2899
|
+
...(sandbox === null ? {} : { sandbox }),
|
|
2900
|
+
};
|
|
2272
2901
|
}
|
|
2273
2902
|
/**
|
|
2274
2903
|
* Classify a shell command line into the classes it would produce.
|
|
@@ -2305,6 +2934,30 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
|
|
|
2305
2934
|
const segments = [];
|
|
2306
2935
|
const classes = [];
|
|
2307
2936
|
for (const segment of lexed.segments) {
|
|
2937
|
+
// APRV-380. A LOGIN-SHELL WRAPPER is classified by the script it runs.
|
|
2938
|
+
// `loginShellScript` states the exact shape and why this is not the second
|
|
2939
|
+
// parser `OPAQUE_BINS` refuses; the inner classification is run with
|
|
2940
|
+
// `unwrapShell: false`, so a shell nested inside the script stays opaque
|
|
2941
|
+
// and the recursion is one level deep by construction.
|
|
2942
|
+
const script = context.unwrapShell === false ? null : loginShellScript(segment);
|
|
2943
|
+
if (script !== null) {
|
|
2944
|
+
const inner = classifyCommand(script, protectedPaths, { ...context, unwrapShell: false });
|
|
2945
|
+
if (!inner.ok) {
|
|
2946
|
+
// The refusal is the INNER one, reported against the inner segment: an
|
|
2947
|
+
// operator told `hook-opaque` for `zsh` learns nothing, and one told
|
|
2948
|
+
// which part of their script could not be read can rewrite it.
|
|
2949
|
+
return inner;
|
|
2950
|
+
}
|
|
2951
|
+
// Spliced rather than collapsed into one segment: a script is a command
|
|
2952
|
+
// line and its parts have their own classes, which is the thing a
|
|
2953
|
+
// one-class answer would lose.
|
|
2954
|
+
for (const found of inner.segments) {
|
|
2955
|
+
segments.push(found);
|
|
2956
|
+
if (!classes.includes(found.class))
|
|
2957
|
+
classes.push(found.class);
|
|
2958
|
+
}
|
|
2959
|
+
continue;
|
|
2960
|
+
}
|
|
2308
2961
|
const outcome = classifySegment(segment, protectedPaths, context);
|
|
2309
2962
|
if (!outcome.ok) {
|
|
2310
2963
|
return { ok: false, code: outcome.code, segment: segment.text, detail: outcome.detail };
|