approval-md 0.4.5 → 0.4.7

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.
Files changed (83) hide show
  1. package/SPEC.md +24 -3
  2. package/dist/src/channels/contract.d.ts +15 -0
  3. package/dist/src/channels/contract.js +23 -1
  4. package/dist/src/channels/contract.js.map +1 -1
  5. package/dist/src/channels/telegram.d.ts +180 -33
  6. package/dist/src/channels/telegram.js +256 -87
  7. package/dist/src/channels/telegram.js.map +1 -1
  8. package/dist/src/cli/channel-telegram.d.ts +114 -35
  9. package/dist/src/cli/channel-telegram.js +259 -120
  10. package/dist/src/cli/channel-telegram.js.map +1 -1
  11. package/dist/src/cli/doctor.js +33 -13
  12. package/dist/src/cli/doctor.js.map +1 -1
  13. package/dist/src/cli/help.d.ts +2 -0
  14. package/dist/src/cli/help.js +39 -3
  15. package/dist/src/cli/help.js.map +1 -1
  16. package/dist/src/cli/hook-exec-code.d.ts +150 -0
  17. package/dist/src/cli/hook-exec-code.js +357 -0
  18. package/dist/src/cli/hook-exec-code.js.map +1 -0
  19. package/dist/src/cli/hook.d.ts +154 -1
  20. package/dist/src/cli/hook.js +2344 -111
  21. package/dist/src/cli/hook.js.map +1 -1
  22. package/dist/src/cli/instructions.js +9 -6
  23. package/dist/src/cli/instructions.js.map +1 -1
  24. package/dist/src/cli/main.js +9 -0
  25. package/dist/src/cli/main.js.map +1 -1
  26. package/dist/src/cli/study.d.ts +22 -0
  27. package/dist/src/cli/study.js +153 -0
  28. package/dist/src/cli/study.js.map +1 -0
  29. package/dist/src/cli/verb-registry.d.ts +14 -0
  30. package/dist/src/cli/verb-registry.js +37 -0
  31. package/dist/src/cli/verb-registry.js.map +1 -1
  32. package/dist/src/cli/wordmark.d.ts +2 -2
  33. package/dist/src/cli/wordmark.js +2 -2
  34. package/dist/src/core/command-class.d.ts +349 -1
  35. package/dist/src/core/command-class.js +2347 -87
  36. package/dist/src/core/command-class.js.map +1 -1
  37. package/dist/src/core/exec-code.d.ts +77 -0
  38. package/dist/src/core/exec-code.js +60 -0
  39. package/dist/src/core/exec-code.js.map +1 -0
  40. package/dist/src/core/gate.d.ts +38 -0
  41. package/dist/src/core/gate.js +26 -0
  42. package/dist/src/core/gate.js.map +1 -1
  43. package/dist/src/core/git-push-argv.d.ts +111 -0
  44. package/dist/src/core/git-push-argv.js +375 -0
  45. package/dist/src/core/git-push-argv.js.map +1 -0
  46. package/dist/src/core/harness-version.d.ts +71 -30
  47. package/dist/src/core/harness-version.js +101 -34
  48. package/dist/src/core/harness-version.js.map +1 -1
  49. package/dist/src/core/policy-load.d.ts +8 -0
  50. package/dist/src/core/policy-load.js.map +1 -1
  51. package/dist/src/core/sender-identity.d.ts +13 -1
  52. package/dist/src/core/sender-identity.js +15 -0
  53. package/dist/src/core/sender-identity.js.map +1 -1
  54. package/dist/src/core/study-withdraw.d.ts +137 -0
  55. package/dist/src/core/study-withdraw.js +280 -0
  56. package/dist/src/core/study-withdraw.js.map +1 -0
  57. package/dist/src/daemon/git-evidence.d.ts +2 -2
  58. package/dist/src/daemon/git-evidence.js +1 -1
  59. package/dist/src/mcp/server.js +1 -1
  60. package/dist/src/mcp/server.js.map +1 -1
  61. package/docs/README-extended.md +3 -2
  62. package/docs/cli-reference.md +83 -7
  63. package/package.json +2 -2
  64. package/schema/event.schema.json +213 -1
  65. package/schema/fixtures/event/invalid/approval-granted-study.json +23 -0
  66. package/schema/fixtures/event/invalid/approval-rejected-looks-wrong-cli-channel.json +23 -0
  67. package/schema/fixtures/event/invalid/approval-rejected-looks-wrong-operator.json +24 -0
  68. package/schema/fixtures/event/invalid/approval-rejected-study-extra-key.json +24 -0
  69. package/schema/fixtures/event/invalid/approval-rejected-study-unknown-reason.json +23 -0
  70. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-email-operator.json +20 -0
  71. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-no-operator.json +19 -0
  72. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-no-ref.json +19 -0
  73. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-wrong-actor.json +20 -0
  74. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-wrong-channel.json +20 -0
  75. package/schema/fixtures/event/invalid/approval-revoked-study.json +20 -0
  76. package/schema/fixtures/event/valid/approval-rejected-looks-wrong.json +23 -0
  77. package/schema/fixtures/event/valid/approval-rejected-study-withdrawn.json +20 -0
  78. package/schema/fixtures/policy/invalid/exec-code-custody-boolean.json +11 -0
  79. package/schema/fixtures/policy/invalid/exec-code-execute-code-value.json +11 -0
  80. package/schema/fixtures/policy/invalid/exec-code-unknown-key.json +12 -0
  81. package/schema/fixtures/policy/valid/exec-code-both.json +17 -0
  82. package/schema/fixtures/policy/valid/exec-code-custody.json +11 -0
  83. package/schema/policy.schema.json +23 -0
@@ -78,7 +78,7 @@ import { tmpdir } from "node:os";
78
78
  import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments, sep, } from "node:path";
79
79
  import { attestationRefusal, checkAttestation, checkAttestationOfBytes } from "../core/attest.js";
80
80
  import { childEnvironment } from "../core/child-env.js";
81
- import { classifyCommand, commandSegmentShapes, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, isCredentialPath, NODE_SKILL_SCRIPT_RULE, protectedPathClass, SKILL_SCRIPT_RULE, } from "../core/command-class.js";
81
+ import { braceCandidates, copyArgvUnknown, classifyCommand, commandSegmentShapes, copyTargetDirectories, fetchOutputs, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, credentialReadWords, GATE_SELF_CLASS, gitVerbIndex, isCredentialPath, NODE_SKILL_SCRIPT_RULE, protectedPathClass, SKILL_SCRIPT_RULE, SUBSTITUTION_SENTINEL, } from "../core/command-class.js";
82
82
  import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, isProposalTask, register, request, startHarnessExecution, withdraw, } from "../core/gate.js";
83
83
  import { openGateWindow, recordGateBypass, } from "../core/gate-window.js";
84
84
  import { harnessProvenance, isHarnessKind, } from "../core/harness-version.js";
@@ -89,6 +89,7 @@ import { appendLockHeld, DEFAULT_LOCK_RETRY_MS, DEFAULT_LOCK_TIMEOUT_MS, } from
89
89
  import { settleTerminationGuards } from "../core/log-lock.js";
90
90
  import { payloadHash } from "../core/payload.js";
91
91
  import { classifyApplyPatch, parseApplyPatch } from "../core/apply-patch.js";
92
+ import { closedExecCodeGate, EXEC_CODE_CUSTODY_KEY, EXEC_CODE_EXECUTE_KEY, execCodeGate, } from "../core/exec-code.js";
92
93
  import { loadPolicy, loadPolicyText, parseDuration, POLICY_FILENAMES, } from "../core/policy-load.js";
93
94
  import { isRecordableToolName, toolMapVerdict, UNMAPPED_TOOL_CLASS, } from "../core/tool-map.js";
94
95
  import { READ_OUT_OF_SCOPE_CLASS, effectiveReadRoots, isInReadScope, readTargetsOf, renderReadRoots, } from "../core/read-scope.js";
@@ -102,6 +103,7 @@ import { DEFAULT_LOG_PATH } from "./paths.js";
102
103
  import { refusal as renderRefusal, style, table } from "./style.js";
103
104
  import { usageErrorText } from "./usage.js";
104
105
  import { checkCodexHookInput, codexArgvDisagrees, codexBinding, CODEX_POST_TOOL_EVENT, readCodexReportedOutcome, } from "./hook-codex.js";
106
+ import { bindExecCodeCommand, bindExecuteCode, describeProgram } from "./hook-exec-code.js";
105
107
  /** Identity accepted for the proposing side: a person or an agent. */
106
108
  const PRINCIPAL_ACTOR = /^(human|agent):.+/u;
107
109
  /**
@@ -356,6 +358,28 @@ export const HOOK_DENY_CODES = [
356
358
  * say about bytes it could see. Here there are no bytes to see.
357
359
  */
358
360
  "hook-hermes-execute-code-unbound",
361
+ /**
362
+ * An opaque program (`python3 -c`, `node -e` and kin, an interpreter running a
363
+ * file that is not a skill script, or a Hermes `execute_code` call with
364
+ * `exec.code.execute_code: hooked`) under a policy that NAMES the `exec.code`
365
+ * class but does not declare `exec.code.custody: attested` (APRV-510).
366
+ *
367
+ * Distinct from `hook-opaque`, and the distinction is the repair: that one
368
+ * says rewrite the command, this one says the operator has asked for the
369
+ * class and the one line that opens it is missing. A policy that does not
370
+ * name `exec.code` at all keeps today's refusal byte for byte, so this code
371
+ * is never a surprise to an operator who has not opted in. Nothing appends.
372
+ */
373
+ "hook-exec-code-custody-unattested",
374
+ /**
375
+ * An `exec.code` file program this hook cannot read and hash (APRV-510): it
376
+ * is absent, unreadable by this process (EACCES under co-location), not a
377
+ * regular file, too large, or relative after a `cd` the hook cannot place.
378
+ * A record of an opaque program binds its hash, and a program whose bytes
379
+ * cannot be read is one nothing could bind, so it is refused rather than
380
+ * recorded without one. Nothing appends.
381
+ */
382
+ "hook-exec-code-unreadable",
359
383
  /** Malformed hook input, or a log/filesystem fact that stopped the check. */
360
384
  "hook-io",
361
385
  ];
@@ -676,6 +700,13 @@ function hermesToolRule(toolName, toolInput) {
676
700
  if (toolName === "send_message") {
677
701
  return { kind: "gated", cls: "message.send", headline: headline("message.send") };
678
702
  }
703
+ // APRV-510. Claimed here so no `tools` entry can name it and the post half
704
+ // closes the start it wrote. It reaches the gated path only through the
705
+ // pre-dispatch refusal in `runHarnessHook`, which lets it by only when the
706
+ // policy declares both `exec.code` keys; `describeToolCall` binds it.
707
+ if (toolName === HERMES_EXECUTE_CODE_TOOL) {
708
+ return { kind: "gated", cls: "exec.code", headline: headline("exec.code") };
709
+ }
679
710
  return null;
680
711
  }
681
712
  /**
@@ -1169,6 +1200,36 @@ function writeInterruptedWait(signal, harness) {
1169
1200
  export const HERMES_EXECUTE_CODE_REFUSAL = "hook-hermes-execute-code-unbound";
1170
1201
  /** The tool that carries a program and no path, no argv and no directory. */
1171
1202
  const HERMES_EXECUTE_CODE_TOOL = "execute_code";
1203
+ /**
1204
+ * The words of a `hook-hermes-execute-code-unbound` refusal (APRV-398, APRV-510).
1205
+ *
1206
+ * `unhooked` is the refusal as it stood before APRV-510, byte for byte, and it
1207
+ * is what a policy that says nothing about `exec.code` still gets.
1208
+ * `unhooked-attested` adds one sentence for a policy that attests custody and
1209
+ * has not declared the second key. `window` is the open window's.
1210
+ */
1211
+ function executeCodeRefusal(why, shellTool = "terminal") {
1212
+ const base = `Hermes ${HERMES_EXECUTE_CODE_TOOL} is refused because the call carries a program and nothing else: no path, no argv and no working directory, so no class can be resolved and no payload can bind what it would do. Run the work through the \`${shellTool}\` tool, where the words are visible to the classifier, or through \`approval run\` with a granted token. No policy or open window authorizes bytes the hook cannot read`;
1213
+ if (why === "unhooked")
1214
+ return base;
1215
+ if (why === "window") {
1216
+ return `${base}. exec.code is not reached through an open window: opening it is a policy statement, and the window suspends the policy`;
1217
+ }
1218
+ return `${base}. This policy attests credential custody (\`${EXEC_CODE_CUSTODY_KEY}\`) and does not declare \`${EXEC_CODE_EXECUTE_KEY}\`, which the operator sets only once a probe has shown that a tool call made inside the ${HERMES_EXECUTE_CODE_TOOL} kernel re-fires this hook`;
1219
+ }
1220
+ /**
1221
+ * What `policy` opens of `exec.code` on this adapter (APRV-510).
1222
+ *
1223
+ * Codex is closed whatever the policy says: its allow carries the native
1224
+ * command bytes back (`codexBinding`), and the `exec.code` payload replaces
1225
+ * the program text with its hash, so the two bindings would disagree. The
1226
+ * class stays refused there until a task reconciles them.
1227
+ */
1228
+ function execGateOf(adapter, policy) {
1229
+ if (adapter.bindToolName === true)
1230
+ return closedExecCodeGate("harness");
1231
+ return execCodeGate(policy);
1232
+ }
1172
1233
  /**
1173
1234
  * The retry a Hermes session makes after one of these refusals, in one place.
1174
1235
  *
@@ -1929,15 +1990,21 @@ export function refineReadScope(result, roots, cwd) {
1929
1990
  return segment;
1930
1991
  const words = commandSegmentWords(segment.text);
1931
1992
  const parsed = words === null ? undefined : words[0];
1932
- if (parsed === undefined)
1993
+ // APRV-522 (S2): the paths the segment opens through `<`, which are reads
1994
+ // whatever the binary is, and the only reads of a bare `< file` segment.
1995
+ const redirected = commandSegmentShapes(segment.text)?.[0]?.reads ?? [];
1996
+ if (parsed === undefined && redirected.length === 0)
1933
1997
  return segment;
1934
- const positionals = parsed.args.filter((arg) => !arg.startsWith("-") || arg === "-");
1935
- const declaredTargets = readTargetsOf(parsed.bin, positionals, parsed.args);
1936
- if (declaredTargets === null)
1998
+ const positionals = parsed === undefined ? [] : parsed.args.filter((arg) => !arg.startsWith("-") || arg === "-");
1999
+ const declaredTargets = parsed === undefined ? null : readTargetsOf(parsed.bin, positionals, parsed.args);
2000
+ if (declaredTargets === null && redirected.length === 0)
1937
2001
  return segment;
1938
2002
  // No operand is not "no read": `ls`, `find` and a piped `grep needle` all
1939
2003
  // read the working directory, so the working directory is what is checked.
1940
- const targets = declaredTargets.length === 0 ? ["."] : declaredTargets;
2004
+ const targets = [
2005
+ ...(declaredTargets === null ? [] : declaredTargets.length === 0 ? ["."] : declaredTargets),
2006
+ ...redirected,
2007
+ ];
1941
2008
  const reject = (path, detail) => {
1942
2009
  notes.push(`${READ_SCOPE_REJECTED_RULE}: ${detail}`);
1943
2010
  return {
@@ -1968,6 +2035,1034 @@ export function refineReadScope(result, roots, cwd) {
1968
2035
  return { result: { ok: true, segments, classes }, notes };
1969
2036
  }
1970
2037
  // ===========================================================================
2038
+ // Credential reads, the disk half (APRV-522)
2039
+ // ===========================================================================
2040
+ /** The rule a credential read found by resolving its path reports. */
2041
+ export const CREDENTIAL_RESOLVED_RULE = "credential-path-resolved";
2042
+ /**
2043
+ * A shell word with the variables this hook can know expanded (APRV-445
2044
+ * refutation B2; shared since APRV-522 by the Hermes pass and the credential
2045
+ * pass, so the two expand alike).
2046
+ *
2047
+ * `$HERMES_HOME` / `${HERMES_HOME}` expand to `APPROVAL_HERMES_HOME`, else
2048
+ * `HERMES_HOME`, from this process's environment; `~`, `$HOME` and `${HOME}`
2049
+ * to `HOME`. `unresolved` is set while any `$` or backtick is left.
2050
+ *
2051
+ * `landingUnknown` (APRV-522 fix round 1, M-C) is set when the word opens with
2052
+ * a tilde prefix this hook does not expand (`~+`, `~-`, `~name`, or a bare `~`
2053
+ * with no `HOME`). Where such a word lands is unknown, so callers fail closed
2054
+ * on it rather than judge its text. Fix round 2 adds the variable spellings of
2055
+ * the same directories: `$OLDPWD` (what `~-` names) and the directory stack
2056
+ * (`$DIRSTACK`, zsh's `$dirstack`). An earlier `cd` or `pushd` in the same
2057
+ * command sets them, so they are as unknown as `~-` and must not be judged by
2058
+ * their unexpanded text.
2059
+ */
2060
+ function expandHookWord(word, env) {
2061
+ const hermesHome = env["APPROVAL_HERMES_HOME"] ?? env["HERMES_HOME"] ?? null;
2062
+ const home = env["HOME"] ?? null;
2063
+ let text = word;
2064
+ let hermesUnknown = false;
2065
+ let landingUnknown = false;
2066
+ const hermesRef = /^\$(?:\{HERMES_HOME\}|HERMES_HOME)(?=\/|$)/u;
2067
+ if (hermesRef.test(text)) {
2068
+ if (hermesHome !== null && hermesHome.length > 0)
2069
+ text = text.replace(hermesRef, hermesHome);
2070
+ else
2071
+ hermesUnknown = true;
2072
+ }
2073
+ const homeRef = /^(?:~|\$\{HOME\}|\$HOME)(?=\/|$)/u;
2074
+ if (homeRef.test(text)) {
2075
+ if (home !== null && home.length > 0)
2076
+ text = text.replace(homeRef, home);
2077
+ else
2078
+ landingUnknown = true;
2079
+ }
2080
+ if (text.startsWith("~"))
2081
+ landingUnknown = true;
2082
+ if (/^\$\{?(?:OLDPWD|DIRSTACK|dirstack)(?![A-Za-z0-9_])/u.test(text))
2083
+ landingUnknown = true;
2084
+ return {
2085
+ text,
2086
+ hermesUnknown,
2087
+ unresolved: hermesUnknown || landingUnknown || /[$`]/u.test(text),
2088
+ landingUnknown,
2089
+ };
2090
+ }
2091
+ /**
2092
+ * Is a `cd`/`pushd` operand one whose landing the hook can name? (APRV-522 fix
2093
+ * round 1, M-B.) A relative operand that does not open with `.` or `..` is
2094
+ * looked up through `CDPATH` first, and the shell's `CDPATH` (set in its
2095
+ * profile, inherited, or assigned on the line) is not visible here, so only an
2096
+ * absolute operand, `.`, `..`, `./…` and `../…` land where they are spelled.
2097
+ * POSIX `cd` and bash both skip `CDPATH` for those.
2098
+ */
2099
+ function cdOperandLandsAsSpelled(text) {
2100
+ return (isAbsolute(text) || text === "." || text === ".." || text.startsWith("./") || text.startsWith("../"));
2101
+ }
2102
+ /**
2103
+ * What a `cd` or `pushd` names, read the way bash and zsh read its argv
2104
+ * (APRV-522 fix round 2, the path-parser differential). Shared by the
2105
+ * credential pass and the Hermes pass.
2106
+ *
2107
+ * Only the options that leave the landing where the operand says (`-L`, `-P`,
2108
+ * bash's `-e`, zsh's `-q` and `-s`) may come before the operand, and `--`
2109
+ * ends them. Everything else leaves the landing unknown:
2110
+ *
2111
+ * - any other option: zsh's `-N` directory-stack form, bash's `-@` (a file's
2112
+ * extended-attribute view), and options this hook does not know;
2113
+ * - an operand opening with `-` or `+` (after `--` as well), `-` itself, and
2114
+ * an empty operand. Before this round `cd -- -x` was read as a bare `cd`
2115
+ * (the home directory), and zsh's `+N` as a relative directory name;
2116
+ * - two or more operands: zsh's `cd OLD NEW` substitutes NEW for OLD in the
2117
+ * current directory's path, which can name any directory at all;
2118
+ * - an assignment prefix (`CDPATH=`, `HOME=` or any other; fix round 1, M-B).
2119
+ *
2120
+ * No operand is the home directory for `cd`, and unknown for `pushd`, which
2121
+ * swaps the top of a stack this hook never saw.
2122
+ */
2123
+ function cdTarget(bin, args, assignments) {
2124
+ if (assignments.length > 0)
2125
+ return { kind: "unknown" };
2126
+ let at = 0;
2127
+ for (; at < args.length; at += 1) {
2128
+ const arg = args[at];
2129
+ if (arg === "--") {
2130
+ at += 1;
2131
+ break;
2132
+ }
2133
+ if (arg === "-" || !arg.startsWith("-"))
2134
+ break;
2135
+ if (!/^-[LPeqs]+$/u.test(arg))
2136
+ return { kind: "unknown" };
2137
+ }
2138
+ const operands = args.slice(at);
2139
+ if (operands.length === 0)
2140
+ return bin === "cd" ? { kind: "home" } : { kind: "unknown" };
2141
+ const word = operands[0];
2142
+ if (operands.length > 1 || word.length === 0 || /^[-+]/u.test(word))
2143
+ return { kind: "unknown" };
2144
+ return { kind: "dir", word };
2145
+ }
2146
+ /**
2147
+ * The segments of a relative word, case folded, once per way the word may
2148
+ * split (APRV-522 fix round 2): on `/` and `\` both, which is how the
2149
+ * credential rows split, and on `/` alone when the word holds a backslash,
2150
+ * which is how the kernel splits on macOS and Linux. `null` when any
2151
+ * splitting holds a `..`: under a directory a `CDPATH` lookup may have chosen,
2152
+ * a `..` cannot be resolved, lexically or physically, since the kernel climbs
2153
+ * from wherever a symlink inside that unknown directory points.
2154
+ */
2155
+ function steeredSegments(text) {
2156
+ const splittings = [text.split(/[/\\]+/u)];
2157
+ if (text.includes("\\"))
2158
+ splittings.push(text.split(/\/+/u));
2159
+ const out = [];
2160
+ for (const raw of splittings) {
2161
+ const segments = raw.filter((segment) => segment.length > 0 && segment !== ".").map((segment) => segment.toLowerCase());
2162
+ if (segments.includes(".."))
2163
+ return null;
2164
+ out.push(segments);
2165
+ }
2166
+ return out;
2167
+ }
2168
+ /**
2169
+ * A name the credential rows treat as credential material directly below a
2170
+ * home: `keys`, `vault*`, `env` and `env.*` below the approval home, `.env`,
2171
+ * `.env.*` and `auth.json` below a Hermes or profile home ({@link isCredentialPath}).
2172
+ */
2173
+ function credentialHomeChild(segment) {
2174
+ return (segment === "keys" ||
2175
+ segment.startsWith("vault") ||
2176
+ segment === "env" ||
2177
+ segment.startsWith("env.") ||
2178
+ segment === ".env" ||
2179
+ segment.startsWith(".env.") ||
2180
+ segment === "auth.json");
2181
+ }
2182
+ /** The most directories a `CDPATH` lookup may have chosen that the pass tracks before it fails closed. */
2183
+ const STEERED_TAIL_CAP = 64;
2184
+ /** The most glob segments a word under such a directory may carry before it fails closed. */
2185
+ const STEERED_GLOB_CAP = 3;
2186
+ /** The basenames of the Hermes home this process is configured with, case folded. */
2187
+ function configuredHermesNames(env) {
2188
+ const names = [];
2189
+ for (const value of [env["APPROVAL_HERMES_HOME"], env["HERMES_HOME"]]) {
2190
+ if (value === undefined || value.length === 0)
2191
+ continue;
2192
+ const name = basename(value).toLowerCase();
2193
+ if (name.length > 0 && name !== "." && name !== ".." && !names.includes(name))
2194
+ names.push(name);
2195
+ }
2196
+ return names;
2197
+ }
2198
+ /**
2199
+ * Could `<unknown prefix>/<tail>/<word>` name credential material or a gate
2200
+ * home? (APRV-522 fix round 2: the narrowed M-B rule.)
2201
+ *
2202
+ * `tail` is the operand of a `cd` a `CDPATH` lookup may have steered (and of
2203
+ * any `cd` after it), so the directory the word is read from ends in `tail`
2204
+ * below a prefix this hook cannot know. The word is a credential read when:
2205
+ *
2206
+ * - the operand carries a name the credential rows give to credential
2207
+ * material below a home (`keys`, `vault*`, `env*`, `.env*`, `auth.json`):
2208
+ * the unknown prefix may be that home;
2209
+ * - the joined path matches a credential row as written, or once the unknown
2210
+ * prefix is taken to be an approval home, a Hermes home or a profiles
2211
+ * directory (so the operand's first segment is a profile name);
2212
+ * - the word, read from the operand's directory taken as a Hermes or profile
2213
+ * home of any name, reaches a credential file there (`.env*`, `auth.json`,
2214
+ * or `profiles/<name>/` followed by one);
2215
+ * - the path ends in a gate home's name (`.approval`, `.hermes`, the
2216
+ * configured Hermes home's basename) or in `profiles/<name>`.
2217
+ *
2218
+ * A configured Hermes home's basename is also judged as `.hermes`. A glob
2219
+ * segment is judged as every representative name it matches, the way
2220
+ * {@link globCouldNameCredential} judges a pattern whose directory is unknown;
2221
+ * past {@link STEERED_GLOB_CAP} glob segments, or for a recursive `**`, the
2222
+ * answer is yes. Everything else keeps its normal class: a file below a home
2223
+ * that is not credential material (`.hermes/skills/…`) is ordinary work, as
2224
+ * it is when the directory is known.
2225
+ */
2226
+ function steeredReadCouldNameCredential(tail, word, hermesNames) {
2227
+ if (tail.some((segment) => credentialHomeChild(segment)))
2228
+ return true;
2229
+ const homeNames = [".approval", ".hermes", ...hermesNames];
2230
+ const representatives = [
2231
+ ".env", ".env.local", "auth.json", "env", "env.local", "vault", "vault.enc", "keys", "profiles", "p", ...homeNames,
2232
+ ];
2233
+ let spellings = [[]];
2234
+ let globs = 0;
2235
+ for (const segment of word) {
2236
+ let alternatives = [segment];
2237
+ if (hasGlobChars(segment)) {
2238
+ globs += 1;
2239
+ if (globs > STEERED_GLOB_CAP || segment.includes("**"))
2240
+ return true;
2241
+ const pattern = globRegex(segment);
2242
+ const matched = representatives.filter((name) => pattern.test(name));
2243
+ if (matched.length > 0)
2244
+ alternatives = matched;
2245
+ }
2246
+ spellings = spellings.flatMap((head) => alternatives.map((alternative) => [...head, alternative]));
2247
+ }
2248
+ for (const spelled of spellings) {
2249
+ const written = [...tail, ...spelled];
2250
+ const mapped = written.map((segment) => (hermesNames.includes(segment) ? ".hermes" : segment));
2251
+ for (const segments of [written, mapped]) {
2252
+ const joined = segments.join("/");
2253
+ for (const prefix of ["", ".approval/", ".hermes/", ".hermes/profiles/"]) {
2254
+ if (isCredentialPath(`${prefix}${joined}`))
2255
+ return true;
2256
+ }
2257
+ const below = segments.slice(tail.length).join("/");
2258
+ if (below.length > 0 && isCredentialPath(`.hermes/${below}`))
2259
+ return true;
2260
+ const last = segments[segments.length - 1];
2261
+ if (last !== undefined && homeNames.includes(last))
2262
+ return true;
2263
+ if (segments.length >= 2 && segments[segments.length - 2] === "profiles")
2264
+ return true;
2265
+ }
2266
+ }
2267
+ return false;
2268
+ }
2269
+ /**
2270
+ * Where the KERNEL lands `text` opened from `from` (APRV-522): each component
2271
+ * in turn, a symlink followed as soon as it is met, so a `..` after a
2272
+ * symlinked directory climbs from where the link points, not from the link's
2273
+ * own parent. {@link canonicalPath}
2274
+ * resolves `..` lexically first, which is the reading a shell's own `cd`
2275
+ * takes; both readings are judged.
2276
+ */
2277
+ function physicalLanding(from, text) {
2278
+ let current = isAbsolute(text) ? sep : from;
2279
+ try {
2280
+ current = realpathSync(current);
2281
+ }
2282
+ catch {
2283
+ // An ancestor that does not exist stays as spelled.
2284
+ }
2285
+ // Fix round 2 (path-parser differential): the kernel's separator. On macOS
2286
+ // and Linux a backslash is a character of a name, never a place to climb a
2287
+ // `..` from; the credential rows still split on both (stricter).
2288
+ for (const component of text.split(KERNEL_SEPARATORS)) {
2289
+ if (component.length === 0 || component === ".")
2290
+ continue;
2291
+ if (component === "..") {
2292
+ current = dirname(current);
2293
+ continue;
2294
+ }
2295
+ const next = join(current, component);
2296
+ try {
2297
+ current = realpathSync(next);
2298
+ }
2299
+ catch {
2300
+ current = next;
2301
+ }
2302
+ }
2303
+ return current;
2304
+ }
2305
+ /**
2306
+ * The words of a `{a,b}` brace list, expanded the way bash expands one before
2307
+ * anything else sees the word (APRV-522), or `[text]` when it carries none.
2308
+ * Bounded: past {@link BRACE_WORD_CAP} words it answers `null`, and the caller
2309
+ * fails closed (APRV-522 fix round 1, M-D): the unexpanded spelling never
2310
+ * matches a credential row, so a dropped word would be a read never judged.
2311
+ */
2312
+ function braceWords(text) {
2313
+ const open = text.indexOf("{");
2314
+ if (open === -1)
2315
+ return [text];
2316
+ let depth = 0;
2317
+ const commas = [];
2318
+ let close = -1;
2319
+ for (let at = open; at < text.length; at += 1) {
2320
+ const ch = text[at];
2321
+ if (ch === "{")
2322
+ depth += 1;
2323
+ else if (ch === "}") {
2324
+ depth -= 1;
2325
+ if (depth === 0) {
2326
+ close = at;
2327
+ break;
2328
+ }
2329
+ }
2330
+ else if (ch === "," && depth === 1)
2331
+ commas.push(at);
2332
+ }
2333
+ if (close === -1)
2334
+ return [text];
2335
+ const head = text.slice(0, open);
2336
+ const tail = text.slice(close + 1);
2337
+ const body = text.slice(open + 1, close);
2338
+ let parts;
2339
+ if (commas.length > 0) {
2340
+ parts = [];
2341
+ let start = open + 1;
2342
+ for (const comma of [...commas, close]) {
2343
+ parts.push(text.slice(start, comma));
2344
+ start = comma + 1;
2345
+ }
2346
+ }
2347
+ else {
2348
+ const sequence = /^(-?\d+|[A-Za-z])\.\.(-?\d+|[A-Za-z])$/u.exec(body);
2349
+ if (sequence === null) {
2350
+ const rests = braceWords(tail);
2351
+ if (rests === null || rests.length > BRACE_WORD_CAP)
2352
+ return null;
2353
+ return rests.map((rest) => `${head}{${body}}${rest}`);
2354
+ }
2355
+ const [from, to] = [sequence[1], sequence[2]];
2356
+ const numeric = /^-?\d+$/u.test(from) && /^-?\d+$/u.test(to);
2357
+ const a = numeric ? Number(from) : from.charCodeAt(0);
2358
+ const b = numeric ? Number(to) : to.charCodeAt(0);
2359
+ parts = [];
2360
+ for (let v = a; a <= b ? v <= b : v >= b; v += a <= b ? 1 : -1) {
2361
+ parts.push(numeric ? String(v) : String.fromCharCode(v));
2362
+ if (parts.length > BRACE_WORD_CAP)
2363
+ return null;
2364
+ }
2365
+ }
2366
+ const out = [];
2367
+ for (const part of parts) {
2368
+ const rests = braceWords(`${part}${tail}`);
2369
+ if (rests === null)
2370
+ return null;
2371
+ for (const rest of rests) {
2372
+ out.push(`${head}${rest}`);
2373
+ if (out.length > BRACE_WORD_CAP)
2374
+ return null;
2375
+ }
2376
+ }
2377
+ return out;
2378
+ }
2379
+ /** The separators the kernel splits a path on: `/`, and `\` on Windows only. */
2380
+ const KERNEL_SEPARATORS = process.platform === "win32" ? /[/\\]+/u : /\/+/u;
2381
+ /** The most words {@link braceWords} expands before it fails closed. */
2382
+ const BRACE_WORD_CAP = 64;
2383
+ /** The deepest `$(…)` or shell-script nesting the credential pass walks. */
2384
+ const SUBSTITUTION_DEPTH_CAP = 8;
2385
+ /**
2386
+ * A word opening with `${NAME:-default}` (or `-`, `:=`, `=`) for a variable
2387
+ * this hook expands, as both words it may become (APRV-522): the variable,
2388
+ * which wins when it is set in the shell that runs the command, and the
2389
+ * default, which wins when it is not. The hook cannot see that shell's
2390
+ * environment, so both are judged.
2391
+ */
2392
+ function parameterSpellings(word) {
2393
+ const match = /^\$\{(HERMES_HOME|HOME|PWD):?[-=]([^}]*)\}/u.exec(word);
2394
+ if (match === null)
2395
+ return [word];
2396
+ const rest = word.slice(match[0].length);
2397
+ return [word, `$${match[1]}${rest}`, `${match[2]}${rest}`];
2398
+ }
2399
+ /**
2400
+ * Builtins and utilities whose words are never opened as files (APRV-522), so
2401
+ * the credential pass does not resolve them: printing a credential file's NAME
2402
+ * into a note opens nothing (the L-e rule of the Hermes pass). The pure classifier still
2403
+ * judges their words as written, which is the stricter text answer.
2404
+ */
2405
+ const NON_OPENING_BINS = new Set([
2406
+ "echo", "printf", "test", "[", "[[", "true", "false", "type", "which", "pwd",
2407
+ "basename", "dirname", "realpath", "readlink", "export", "unset", "alias",
2408
+ "sleep", "read", "local", "declare", "typeset", "readonly", "shift", "exit", "return",
2409
+ ]);
2410
+ /** Of those, the ones whose words reach a pipe's next command as text. */
2411
+ const PRINTING_BINS = new Set(["echo", "printf"]);
2412
+ /**
2413
+ * A first character the shell may still expand into something else (a
2414
+ * parameter, a substitution, a tilde, an escape, a glob or extended glob, a
2415
+ * brace list, zsh's `=cmd`, `^`, `<N-M>` and `(a|b)` patterns, a process
2416
+ * substitution), so the value curl finally receives may open with `@`. A
2417
+ * leading `-` is here too: a word this walk takes for a value may be an option
2418
+ * curl reads instead (`-K.env`), because the walk does not know every option
2419
+ * that takes a value.
2420
+ */
2421
+ const CURL_VALUE_UNKNOWN_OPENING = /^[\s\-$`~\\*?[\]{}()!+^<>=#%|&;'"]/u;
2422
+ /** Is this option value one curl (or wget) sends as written and never opens? */
2423
+ function curlValueSentAsWritten(value, opensFileWithAt) {
2424
+ if (value.length === 0)
2425
+ return false;
2426
+ // curl reads `-H @FILE` as a file of headers and `--url @FILE` (8.13+) as a
2427
+ // file of URLs. wget has no such spelling; the rule is the same anyway.
2428
+ if (opensFileWithAt && value.startsWith("@"))
2429
+ return false;
2430
+ if (CURL_VALUE_UNKNOWN_OPENING.test(value))
2431
+ return false;
2432
+ // A `file:` URL opens a local file. Which file it reads is the network
2433
+ // classifier's to say, never this pass's; such a value keeps the judgement
2434
+ // it had, and a header that opens `file:` is judged with it, since the walk
2435
+ // may have taken a positional URL for a header value.
2436
+ return !/^file:/iu.test(value);
2437
+ }
2438
+ /**
2439
+ * The curl and wget option values that are not file reads (EDGEOS-BLOCK,
2440
+ * APRV-533), taken out of the argv the credential pass judges.
2441
+ *
2442
+ * Every resident box runs its terminal in `$HERMES_HOME`, which holds
2443
+ * credential material, and the pass reads a word with an unexpanded `$` as a
2444
+ * read of the directory it may land in. So a header carrying `$EDGEOS_API_KEY`
2445
+ * and a `--url "${EDGEOS_API_BASE:-…}/…"` were each a credential read there.
2446
+ * curl opens neither: it reads a header from a file only for `-H @FILE`, and a
2447
+ * URL from a file only for `--url @FILE`. What is taken out:
2448
+ *
2449
+ * - curl's `-H`/`--header` value (separate, or attached as `-HVALUE`) and
2450
+ * wget's `--header` value (separate, or `--header=VALUE`), when it opens
2451
+ * with neither `@` nor a character the shell may still expand
2452
+ * ({@link CURL_VALUE_UNKNOWN_OPENING}). `-H "$X"` and `-H @"$F"` are judged
2453
+ * as before: the expansion may open with `@`.
2454
+ * - curl's `--url` value under the same rule, and additionally only when it
2455
+ * holds no unexpanded span, or names a literal scheme other than `file:`
2456
+ * before its first one (`https://h/${P}`).
2457
+ * - curl's `--url` value opening with an unexpanded span moves to
2458
+ * `spannedUrls`, which the caller judges as the path an `@` or `file:`
2459
+ * expansion would read; a value whose last component is the span (`"$U"`)
2460
+ * stays a read of the working directory, as before.
2461
+ *
2462
+ * Everything else stays: every other option (`-d`, `--data*`, `-F`, `-T`,
2463
+ * `-K`, `-o`, `-b`, `-c`, `--cacert`, `--cert`, `--key`, `-E`,
2464
+ * `--netrc-file`, `--proxy-header`, `--variable`, …), a short-option cluster
2465
+ * ending in `H` (`-sH`), curl's `--header=…` (curl takes no `=` spelling), and
2466
+ * every word after `--`. The pure classifier's text rule
2467
+ * ({@link credentialReadWords}) still reads every one of these words.
2468
+ */
2469
+ function curlValuesNotRead(bin, args) {
2470
+ if (bin !== "curl" && bin !== "wget")
2471
+ return { args, spannedUrls: [] };
2472
+ const curl = bin === "curl";
2473
+ const kept = [];
2474
+ const spannedUrls = [];
2475
+ for (let at = 0; at < args.length; at += 1) {
2476
+ const arg = args[at];
2477
+ if (arg === "--") {
2478
+ kept.push(...args.slice(at));
2479
+ break;
2480
+ }
2481
+ const next = args[at + 1];
2482
+ if ((curl ? arg === "-H" || arg === "--header" : arg === "--header") && next !== undefined) {
2483
+ kept.push(arg);
2484
+ if (curlValueSentAsWritten(next, true))
2485
+ at += 1;
2486
+ continue;
2487
+ }
2488
+ if (curl && arg.startsWith("-H") && arg.length > 2) {
2489
+ if (!curlValueSentAsWritten(arg.slice(2), true))
2490
+ kept.push(arg);
2491
+ continue;
2492
+ }
2493
+ if (!curl && arg.startsWith("--header=")) {
2494
+ if (!curlValueSentAsWritten(arg.slice("--header=".length), true))
2495
+ kept.push(arg);
2496
+ continue;
2497
+ }
2498
+ if (curl && arg === "--url" && next !== undefined) {
2499
+ kept.push(arg);
2500
+ const span = next.search(/[$`]/u);
2501
+ const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):/u.exec(next);
2502
+ if (span === 0) {
2503
+ spannedUrls.push(next);
2504
+ at += 1;
2505
+ }
2506
+ else if (curlValueSentAsWritten(next, true) && (span === -1 || (scheme !== null && scheme[0].length <= span))) {
2507
+ at += 1;
2508
+ }
2509
+ continue;
2510
+ }
2511
+ kept.push(arg);
2512
+ }
2513
+ return { args: kept, spannedUrls };
2514
+ }
2515
+ /**
2516
+ * Every existing path a glob matches, any component of it a pattern
2517
+ * (APRV-522), or `null` when a directory on the way cannot be listed or the
2518
+ * matches run past 256, so the caller falls back to asking whether the
2519
+ * pattern could name a credential at all. Over-matches rather than under:
2520
+ * `*` matches a leading dot here, which the shell's glob does not.
2521
+ */
2522
+ function expandGlobPath(path) {
2523
+ // Fix round 2 (path-parser differential): the shell's glob splits on `/`
2524
+ // alone and reads a backslash as an escape, so a pattern holding one is not
2525
+ // listed here (the caller judges whether it could name a credential).
2526
+ if (process.platform !== "win32" && path.includes("\\"))
2527
+ return null;
2528
+ const components = path.split(KERNEL_SEPARATORS).filter((component) => component.length > 0);
2529
+ let current = [sep];
2530
+ for (const component of components) {
2531
+ const next = [];
2532
+ for (const base of current) {
2533
+ if (component === "..") {
2534
+ next.push(dirname(base));
2535
+ continue;
2536
+ }
2537
+ if (!hasGlobChars(component)) {
2538
+ next.push(join(base, component));
2539
+ continue;
2540
+ }
2541
+ let names;
2542
+ try {
2543
+ names = readdirSync(base);
2544
+ }
2545
+ catch {
2546
+ if (!existsSync(base))
2547
+ continue;
2548
+ return null;
2549
+ }
2550
+ const pattern = globRegex(component);
2551
+ for (const name of names)
2552
+ if (pattern.test(name))
2553
+ next.push(join(base, name));
2554
+ }
2555
+ if (next.length > 256)
2556
+ return null;
2557
+ current = next;
2558
+ }
2559
+ return current.filter((candidate) => existsSync(candidate));
2560
+ }
2561
+ /** Binaries that copy a directory they are handed, contents and all. */
2562
+ const DIRECTORY_COPIERS = new Set(["cp", "rsync", "scp"]);
2563
+ /**
2564
+ * Every credential file a command reads once its paths are resolved the way
2565
+ * the shell and the filesystem will resolve them (APRV-522, M4 and M5).
2566
+ *
2567
+ * The pure classifier judges the words as written. A word written relative
2568
+ * after a `cd`, through a symlinked parent, as a glob, or inside a `$(…)` does
2569
+ * not carry the segments the credential rule matches, so it was `read.shell`
2570
+ * through the Claude Code hook (a relative read after a `cd` into the approval
2571
+ * home, a read through a symlinked alias of it, a glob or `<` target naming a
2572
+ * file in it), and through the Hermes hook too when the reader's segment also
2573
+ * wrote, or the reader sat inside a substitution.
2574
+ *
2575
+ * Every adapter and `hook classify` reach this through {@link classifyForHook}.
2576
+ * What is judged, per segment, whatever class the segment took:
2577
+ *
2578
+ * - the words {@link credentialReadWords} says the binary reads (the pure
2579
+ * rule's own list: positionals, option-embedded values, git `<rev>:<path>`),
2580
+ * and every `<` target;
2581
+ * - each word expanded ({@link expandHookWord}, brace lists included), then
2582
+ * landed from every directory the command may be in: lexically
2583
+ * ({@link canonicalPath}) and physically ({@link physicalLanding}), a glob
2584
+ * expanded against its directory (or, where that is unknown, judged by
2585
+ * whether its pattern could name a credential);
2586
+ * - a directory handed to a recursive reader or a directory copier, when it
2587
+ * {@link holdsCredentials}. git and diff count as recursive readers here
2588
+ * (fix round 1, S): git's no-index grep and diff walk a directory, and diff
2589
+ * compares a directory's files. A recursive search also reads the working
2590
+ * directory (fix round 1, M-E), whether or not it names a path, because
2591
+ * which of its words are option values cannot be known for every reader;
2592
+ * - a one-operand link or copy keeps its only operand as a read, because the
2593
+ * destination is the working directory (fix round 1, M-F);
2594
+ * - `cd` and `pushd` move the directory for later segments, the directories
2595
+ * before and after both kept (a `cd` may fail, or sit behind `||`), each as
2596
+ * spelled and with its symlinks resolved (fix round 2). After a `cd` this
2597
+ * hook cannot resolve, every later RELATIVE read word is a credential read:
2598
+ * where it lands is unknown, and it may be the vault. A `cd` is
2599
+ * unresolvable when its operand is a variable, `-`, a glob or a brace list,
2600
+ * or a tilde prefix this hook does not expand; when it carries an
2601
+ * assignment prefix; and in the argv shapes {@link cdTarget} lists. `popd`
2602
+ * is always unresolvable. (A segment that reassigns `HOME` or `CDPATH` on
2603
+ * its own, `export`, `unset` or `read` of either included, is refused by the
2604
+ * classifier before this pass runs.)
2605
+ * - a relative operand that does not open with `./` or `../` may be found
2606
+ * through a `CDPATH` this hook cannot see (fix round 1, M-B). Since fix
2607
+ * round 2 that does not make the directory wholly unknown: the `cd` also
2608
+ * lands at `<unknown prefix>/<operand>`, and each later relative word is a
2609
+ * credential read there only when {@link steeredReadCouldNameCredential}
2610
+ * says that path could name credential material or a gate home. A
2611
+ * recursive reader, a word holding `..` and a `**` glob are credential
2612
+ * reads there, since what they reach below an unknown directory cannot be
2613
+ * bounded.
2614
+ * - the body of every `$(…)`, and the script of every login-shell wrapper the
2615
+ * classifier unwraps (fix round 1, M-A), walked the same way from the
2616
+ * directory it runs in. A known shell handed an inline script in any other
2617
+ * shape is a credential read, since what it reads cannot be judged;
2618
+ * - past {@link BRACE_WORD_CAP} brace words, or {@link SUBSTITUTION_DEPTH_CAP}
2619
+ * levels of nesting, a credential read (fix round 1, M-D).
2620
+ *
2621
+ * A word opening with a tilde prefix this hook does not expand (`~+`, `~-`,
2622
+ * `~name`) is a credential read (fix round 1, M-C). A relative word read after an
2623
+ * unresolvable `cd` is a credential read even when it holds a `$` this hook
2624
+ * cannot expand (fix round 1, S-2). Otherwise a word left with such a `$` (a
2625
+ * variable, or the substitution sentinel a `$(…)` leaves) is judged here for
2626
+ * every adapter (APRV-524): by its text, by its literal prefix landed from
2627
+ * each working directory, and, when its last component is the unresolved
2628
+ * one, as a read of whatever that prefix holds, so a home or a directory that
2629
+ * holds credentials makes it a credential read. A word holding the sentinel is
2630
+ * judged once more with every sentinel removed, as a resolved path (the
2631
+ * reading before APRV-511), so an empty substitution is judged too. The Hermes
2632
+ * disk pass also fails closed on every recursive reader of such a word; this
2633
+ * pass does not. Beyond that, a path held in a variable (which may be empty)
2634
+ * or produced by a substitution's output is the accepted limit.
2635
+ *
2636
+ * `cwd` may name several directories (fix round 1, S-1): every one is a place
2637
+ * the command may start in, and each is judged.
2638
+ */
2639
+ export function credentialReadLandings(command, cwd, env = process.env) {
2640
+ const found = [];
2641
+ const record = (segment, path) => {
2642
+ if (!found.some((entry) => entry.segment === segment && entry.path === path)) {
2643
+ found.push({ segment, path });
2644
+ }
2645
+ };
2646
+ // `substituted`: the text is a `$(…)` body, whose output becomes words of the
2647
+ // command around it, so a printed word is as good as a piped one.
2648
+ // Fix round 2: the basenames of a configured Hermes home, for the steered rule.
2649
+ const hermesNames = configuredHermesNames(env);
2650
+ // APRV-524: the configured home, for an unresolved word whose prefix lands in
2651
+ // a home not named `.hermes` (as {@link resolvedPathClasses} computes it).
2652
+ const configuredHermesHome = env["APPROVAL_HERMES_HOME"] ?? env["HERMES_HOME"] ?? null;
2653
+ const configuredHome = configuredHermesHome !== null && configuredHermesHome.length > 0 ? canonicalPath(configuredHermesHome) : null;
2654
+ const walk = (text, startCwds, startUnknown, startSteered, depth, substituted) => {
2655
+ // M-D: past the cap the body is not walked, so what it reads is unknown.
2656
+ if (depth > SUBSTITUTION_DEPTH_CAP) {
2657
+ record(text, text);
2658
+ return;
2659
+ }
2660
+ const shapes = commandSegmentShapes(text);
2661
+ if (shapes === null) {
2662
+ // The whole line is refused before this pass runs; a nested body the
2663
+ // lexer cannot read is a read this pass cannot judge.
2664
+ if (depth > 0)
2665
+ record(text, text);
2666
+ return;
2667
+ }
2668
+ let cwds = [...startCwds];
2669
+ let cwdUnknown = startUnknown;
2670
+ // Fix round 2 (the narrowed M-B rule): the directories a `CDPATH` lookup
2671
+ // may have chosen, each as the segments known below an unknown prefix.
2672
+ let steered = [...startSteered];
2673
+ for (const [index, shape] of shapes.entries()) {
2674
+ const feedsPipe = shapes[index + 1]?.stdinFed === true;
2675
+ for (const inner of shape.substitutions)
2676
+ walk(inner, cwds, cwdUnknown, steered, depth + 1, true);
2677
+ // M-A: a login-shell wrapper runs its script in a child shell, from the
2678
+ // directory the wrapper runs in; its `cd`s stay inside it.
2679
+ if (shape.script !== null) {
2680
+ walk(shape.script, cwds, cwdUnknown, steered, depth + 1, substituted || feedsPipe);
2681
+ continue;
2682
+ }
2683
+ if (shape.opaqueShell) {
2684
+ record(shape.text, shape.text);
2685
+ continue;
2686
+ }
2687
+ const bin = shape.bin === null ? null : basename(shape.bin);
2688
+ if (bin === "cd" || bin === "pushd") {
2689
+ // Fix round 2: the argv read as bash and zsh read it ({@link cdTarget});
2690
+ // an assignment prefix (M-B) is one of its unknown shapes.
2691
+ const target = cdTarget(bin, shape.args, shape.assignments);
2692
+ const home = env["HOME"] ?? null;
2693
+ if (target.kind === "unknown") {
2694
+ cwdUnknown = true;
2695
+ }
2696
+ else if (target.kind === "home") {
2697
+ if (home !== null && home.length > 0) {
2698
+ cwds = [...new Set([...cwds, home, canonicalPath(home)])];
2699
+ }
2700
+ else {
2701
+ cwdUnknown = true;
2702
+ }
2703
+ }
2704
+ else {
2705
+ const expanded = expandHookWord(target.word, env);
2706
+ if (expanded.unresolved || hasGlobChars(expanded.text) || expanded.text.includes("{")) {
2707
+ cwdUnknown = true;
2708
+ }
2709
+ else {
2710
+ // The shell's logical landing (`..` against the directory as
2711
+ // spelled, fix round 2), the same with symlinks resolved after it,
2712
+ // and the kernel's physical landing: all three are judged.
2713
+ const next = cwds.flatMap((from) => {
2714
+ const lexical = resolvePathSegments(from, expanded.text);
2715
+ return [lexical, canonicalPath(lexical), physicalLanding(from, expanded.text)];
2716
+ });
2717
+ cwds = [...new Set([...cwds, ...next])];
2718
+ // M-B, narrowed in fix round 2: a relative operand not opening with
2719
+ // `./` or `../` may be found through a `CDPATH` this hook cannot
2720
+ // see, so it may also land at `<unknown prefix>/<operand>`; later
2721
+ // relative words are judged there by {@link steeredReadCouldNameCredential}.
2722
+ // A `cd` below such a directory extends it. A `..` under an
2723
+ // unknown prefix, or more such directories than the cap, leaves
2724
+ // the directory wholly unknown.
2725
+ const steerable = !cdOperandLandsAsSpelled(expanded.text);
2726
+ if (!isAbsolute(expanded.text) && (steerable || steered.length > 0)) {
2727
+ const operand = steeredSegments(expanded.text);
2728
+ if (operand === null) {
2729
+ cwdUnknown = true;
2730
+ }
2731
+ else {
2732
+ const next = [...steered];
2733
+ for (const segments of operand) {
2734
+ for (const tail of steered)
2735
+ next.push([...tail, ...segments]);
2736
+ if (steerable)
2737
+ next.push(segments);
2738
+ }
2739
+ const seen = new Set();
2740
+ steered = next.filter((tail) => {
2741
+ const key = tail.join("/");
2742
+ if (seen.has(key))
2743
+ return false;
2744
+ seen.add(key);
2745
+ return true;
2746
+ });
2747
+ if (steered.length > STEERED_TAIL_CAP)
2748
+ cwdUnknown = true;
2749
+ }
2750
+ }
2751
+ }
2752
+ }
2753
+ continue;
2754
+ }
2755
+ if (bin === "popd") {
2756
+ cwdUnknown = true;
2757
+ continue;
2758
+ }
2759
+ const hit = (path) => record(shape.text, path);
2760
+ const lands = (path, recursive) => {
2761
+ const canonical = canonicalPath(path);
2762
+ return (isCredentialPath(path) ||
2763
+ isCredentialPath(canonical) ||
2764
+ (recursive && (holdsCredentials(canonical) || holdsCredentials(path))));
2765
+ };
2766
+ // Fix round 2 (the narrowed M-B rule): is a relative word read under a
2767
+ // directory a `CDPATH` lookup may have chosen a credential read? Every
2768
+ // recursive reader is (a recursive read of an unknown directory could
2769
+ // reach anything), and so is a word holding `..` or a recursive `**`.
2770
+ const steeredRead = (text, recursive) => {
2771
+ if (recursive || text.includes("**"))
2772
+ return true;
2773
+ const words = steeredSegments(text);
2774
+ if (words === null)
2775
+ return true;
2776
+ return steered.some((tail) => words.some((segments) => steeredReadCouldNameCredential(tail, segments, hermesNames)));
2777
+ };
2778
+ // `steeredToo` is false only for git's verb word, which git never opens.
2779
+ // `homeSpelled` is false only for a curl `--url` value that opens with an
2780
+ // unexpanded span ({@link curlValuesNotRead}); see its use below.
2781
+ const judgeOne = (word, recursive, steeredToo = true, homeSpelled = true) => {
2782
+ // `$PWD` is the directory the segment runs in, which is each of `cwds`.
2783
+ const pwdRef = /^\$(?:\{PWD\}|PWD)(?=\/|$)/u;
2784
+ if (pwdRef.test(word)) {
2785
+ if (cwdUnknown) {
2786
+ hit(word);
2787
+ return;
2788
+ }
2789
+ for (const from of cwds)
2790
+ judgeOne(word.replace(pwdRef, from), recursive, steeredToo, homeSpelled);
2791
+ if (steeredToo && steered.length > 0 && steeredRead(word.replace(pwdRef, "."), recursive))
2792
+ hit(word);
2793
+ return;
2794
+ }
2795
+ const target = expandHookWord(word, env);
2796
+ if (target.hermesUnknown) {
2797
+ // A Hermes home this process cannot name is judged as the home it
2798
+ // spells: its credential files, and the home itself to a recursive
2799
+ // reader.
2800
+ const spelled = target.text.replace(/^\$(?:\{HERMES_HOME\}|HERMES_HOME)/u, "/.hermes");
2801
+ const kind = gateRootKind(spelled);
2802
+ if (isCredentialPath(spelled) || (recursive && kind !== null && kind !== "scripts")) {
2803
+ hit(word);
2804
+ return;
2805
+ }
2806
+ // APRV-524, the same judgement as an unresolved word below: the
2807
+ // unknown home is one more unknown segment to the text rule, and an
2808
+ // unresolved LAST component directly in the home, the approval home
2809
+ // or a home's `approval/` reads whatever that directory holds.
2810
+ const { prefix, rest } = unresolvedPrefix(spelled);
2811
+ const holder = gateRootKind(prefix);
2812
+ if (unresolvedReadCouldNameCredential(target.text) ||
2813
+ (lastComponentUnresolved(rest) && holder !== null && holder !== "scripts")) {
2814
+ hit(word);
2815
+ }
2816
+ return;
2817
+ }
2818
+ // M-C: a tilde prefix this hook does not expand lands somewhere unknown.
2819
+ if (target.landingUnknown) {
2820
+ hit(word);
2821
+ return;
2822
+ }
2823
+ // S-2: the unknown directory first, so an unexpandable relative word
2824
+ // after an unresolvable `cd` fails closed, as the Hermes pass does.
2825
+ if (!isAbsolute(target.text) && cwdUnknown) {
2826
+ hit(word);
2827
+ return;
2828
+ }
2829
+ if (steeredToo && steered.length > 0 && !isAbsolute(target.text) && steeredRead(target.text, recursive)) {
2830
+ hit(word);
2831
+ return;
2832
+ }
2833
+ if (target.unresolved) {
2834
+ // APRV-524. Since APRV-511 a `$(…)` stays in the word as the
2835
+ // substitution sentinel, so a word whose last component is a
2836
+ // substitution is unresolved here, where before it was read as the
2837
+ // directory the substitution sits in (and a directory reader of a
2838
+ // home took this class from that). The Hermes disk pass judges such
2839
+ // a word again, but only for an adapter that states a per-call
2840
+ // directory; this pass runs for every adapter, so the judgement is
2841
+ // made here too. It fails closed: the word is a credential read when
2842
+ // its text could name one ({@link unresolvedReadCouldNameCredential}),
2843
+ // when its literal prefix, landed from each working directory in all
2844
+ // three readings, makes it one, or when its LAST component is the
2845
+ // unresolved one and that prefix lands on a credential path or a
2846
+ // directory that holds credentials ({@link holdsCredentials}).
2847
+ if (unresolvedReadCouldNameCredential(target.text)) {
2848
+ hit(word);
2849
+ return;
2850
+ }
2851
+ const { prefix, rest } = unresolvedPrefix(target.text);
2852
+ const lastComponent = lastComponentUnresolved(rest);
2853
+ for (const from of cwds) {
2854
+ const lexical = resolvePathSegments(from, prefix);
2855
+ for (const landed of new Set([lexical, canonicalPath(lexical), physicalLanding(from, prefix)])) {
2856
+ if ((lastComponent && lands(landed, true)) ||
2857
+ landedSpellings(landed, rest, homeSpelled ? configuredHome : null).some(unresolvedReadCouldNameCredential)) {
2858
+ hit(word);
2859
+ return;
2860
+ }
2861
+ }
2862
+ }
2863
+ return;
2864
+ }
2865
+ for (const from of cwds) {
2866
+ const lexical = resolvePathSegments(from, target.text);
2867
+ if (hasGlobChars(target.text)) {
2868
+ // Fix round 2 (path-parser differential): zsh's `**/` (and bash's
2869
+ // under globstar) matches any depth, which the one-level listing
2870
+ // below does not, so the directory it starts from is judged as a
2871
+ // recursive read. A file whose name holds the pattern characters
2872
+ // literally (a quoted word, which the shell does not expand) is
2873
+ // judged as well.
2874
+ const deep = recursiveGlobBase(lexical);
2875
+ if (deep !== null && lands(deep, true))
2876
+ hit(deep);
2877
+ if (lands(lexical, recursive))
2878
+ hit(lexical);
2879
+ const literal = physicalLanding(from, target.text);
2880
+ if (lands(literal, recursive))
2881
+ hit(literal);
2882
+ const candidates = expandGlobPath(lexical);
2883
+ if (candidates === null) {
2884
+ if (globCouldNameCredential(lexical))
2885
+ hit(lexical);
2886
+ }
2887
+ else {
2888
+ for (const candidate of candidates) {
2889
+ if (lands(candidate, recursive))
2890
+ hit(candidate);
2891
+ const physical = physicalLanding(sep, candidate);
2892
+ if (lands(physical, recursive))
2893
+ hit(physical);
2894
+ }
2895
+ }
2896
+ continue;
2897
+ }
2898
+ // The path reported is the resolved one: a start or `cd` kept as
2899
+ // spelled (fix round 2) may hold a symlinked alias.
2900
+ if (lands(lexical, recursive))
2901
+ hit(canonicalPath(lexical));
2902
+ const physical = physicalLanding(from, target.text);
2903
+ if (lands(physical, recursive))
2904
+ hit(physical);
2905
+ }
2906
+ };
2907
+ const judge = (word, recursive, steeredToo = true, homeSpelled = true) => {
2908
+ // APRV-524 (review-b M1): a word holding the substitution sentinel is
2909
+ // judged a second time with every sentinel removed. That is how the
2910
+ // release before APRV-511 read it, its lexer adding nothing for a
2911
+ // `$(…)`: the literal text left, landed, its globs expanded on disk,
2912
+ // and a directory reader of a directory that holds credentials a
2913
+ // credential read. The unresolved branch below models a substitution
2914
+ // as one segment of any name; this reading covers the empty one, so a
2915
+ // substitution in a middle component, one glued to a directory's own
2916
+ // name, and one after a glob that matches such a directory on disk are
2917
+ // judged too. Both judgements only record, so the stricter stands.
2918
+ if (word.includes(SUBSTITUTION_SENTINEL)) {
2919
+ judge(word.split(SUBSTITUTION_SENTINEL).join(""), recursive, steeredToo, homeSpelled);
2920
+ }
2921
+ const spellings = new Set();
2922
+ for (const parameter of parameterSpellings(word)) {
2923
+ spellings.add(parameter);
2924
+ const braced = braceWords(parameter);
2925
+ // M-D: a brace list past the cap is a read this pass cannot list.
2926
+ if (braced === null) {
2927
+ hit(word);
2928
+ continue;
2929
+ }
2930
+ for (const spelling of braced)
2931
+ spellings.add(spelling);
2932
+ }
2933
+ for (const spelling of spellings)
2934
+ judgeOne(spelling, recursive, steeredToo, homeSpelled);
2935
+ };
2936
+ for (const target of shape.reads)
2937
+ judge(target, false);
2938
+ if (bin === null)
2939
+ continue;
2940
+ // A builtin that never opens its words, unless what it prints becomes
2941
+ // another command's input or words (a pipe into a reader, or the output
2942
+ // of a substitution); see {@link NON_OPENING_BINS}.
2943
+ if (NON_OPENING_BINS.has(bin) && !(PRINTING_BINS.has(bin) && (feedsPipe || substituted)))
2944
+ continue;
2945
+ const recursive = RECURSIVE_READERS.has(bin) || DIRECTORY_COPIERS.has(bin) || DIRECTORY_READING_BINS.has(bin);
2946
+ // EDGEOS-BLOCK (APRV-533): the curl and wget option values the tool sends
2947
+ // and never opens are not read words ({@link curlValuesNotRead}).
2948
+ const sent = curlValuesNotRead(bin, shape.args);
2949
+ const words = credentialReadWords(bin, sent.args);
2950
+ // A copy's destination is WRITTEN, and the write half (the protected
2951
+ // scan, the Hermes organ pass) prices it; judged as a read it would make
2952
+ // `cp /tmp/x .` in a home a credential read of the home.
2953
+ if (WRITE_LAST_OPERAND.has(bin)) {
2954
+ const operands = shape.args.filter((arg) => !arg.startsWith("-"));
2955
+ // `-t` names the target directory for the GNU copiers; for rsync it
2956
+ // preserves times and names nothing.
2957
+ const targetDir = bin === "rsync" ? null : flagValue(shape.args, "-t", "--target-directory");
2958
+ // M-F: with one operand and no `-t`, that operand is the SOURCE (a
2959
+ // one-operand `ln` links it into the working directory), so it stays.
2960
+ if (targetDir !== null || operands.length > 1) {
2961
+ const destination = targetDir ?? operands[operands.length - 1];
2962
+ const at = destination === undefined ? -1 : words.indexOf(destination);
2963
+ if (at !== -1)
2964
+ words.splice(at, 1);
2965
+ }
2966
+ }
2967
+ // Fix round 2: git's verb is never a path, so it is not judged under a
2968
+ // directory a `CDPATH` lookup may have chosen (it is still judged from
2969
+ // every known directory, as before).
2970
+ const verbAt = bin === "git" ? gitVerbIndex(shape.args) : null;
2971
+ const verb = verbAt === null ? undefined : shape.args[verbAt];
2972
+ for (const [at, word] of words.entries())
2973
+ judge(word, recursive, !(at === 0 && verb !== undefined && word === verb));
2974
+ // A `--url` value opening with an unexpanded span may expand to `@FILE`
2975
+ // (curl 8.13+ reads a URL list from FILE) or to a `file:` URL, and either
2976
+ // reads the path the rest of the word spells. It is judged as that path
2977
+ // from every working directory, the configured home not re-spelled as
2978
+ // `$HERMES_HOME`: that spelling makes the known home one more unknown
2979
+ // segment, and two unknowns in a row then read as an approval home's
2980
+ // `vault*`, which the literal landing already rules in or out.
2981
+ for (const word of sent.spannedUrls)
2982
+ judge(word, false, true, false);
2983
+ // M-E: a recursive search reads the working directory.
2984
+ if (readsWorkingDirectory(bin, shape.args, words))
2985
+ judge(".", true);
2986
+ }
2987
+ };
2988
+ const starts = typeof cwd === "string" ? [cwd] : [...cwd];
2989
+ // Fix round 2 (path-parser differential): each start as given AND with its
2990
+ // symlinks resolved. The shell's `cd ..` climbs the directory as spelled; a
2991
+ // start resolved first would climb from the link's target instead.
2992
+ walk(command, [...new Set(starts.flatMap((start) => (isAbsolute(start) ? [start, canonicalPath(start)] : [canonicalPath(start)])))], false, [], 0, false);
2993
+ return found;
2994
+ }
2995
+ /**
2996
+ * The directory a recursive `**` glob component starts from (APRV-522 fix
2997
+ * round 2), or `null` when the path holds none.
2998
+ */
2999
+ function recursiveGlobBase(path) {
3000
+ const components = path.split(KERNEL_SEPARATORS);
3001
+ const at = components.findIndex((component) => component.includes("**"));
3002
+ if (at === -1)
3003
+ return null;
3004
+ const base = components.slice(0, at).join(sep);
3005
+ return base.length === 0 ? sep : base;
3006
+ }
3007
+ /**
3008
+ * Binaries the credential passes read as recursive although
3009
+ * {@link RECURSIVE_READERS} does not list them (APRV-522 fix round 1, S): git
3010
+ * (its no-index grep and diff modes walk a directory they are handed; every
3011
+ * verb that reads at all is read this way, the closed reading) and diff (a
3012
+ * directory operand compares the directory's files, and `-r` walks it).
3013
+ */
3014
+ const DIRECTORY_READING_BINS = new Set(["git", "diff"]);
3015
+ /** Searches that read the working directory when they name no path (M-E). */
3016
+ const WORKING_DIRECTORY_SEARCHERS = new Set([
3017
+ "grep", "egrep", "fgrep", "rg", "ag", "ack", "find", "tree", "du",
3018
+ ]);
3019
+ /**
3020
+ * Does this reader read the working directory? (APRV-522 fix round 1, M-E.)
3021
+ * A recursive search with no path operand searches the working directory, and
3022
+ * which of its words are option values is not known for every reader, so the
3023
+ * closed reading is that every search does; `git grep` too, which reads the
3024
+ * repository (or, outside one, the directory) it runs in. `words` is what the
3025
+ * credential rule reads of the argv: empty for a git verb that only writes.
3026
+ */
3027
+ function readsWorkingDirectory(bin, args, words) {
3028
+ if (WORKING_DIRECTORY_SEARCHERS.has(bin))
3029
+ return true;
3030
+ return bin === "git" && words.length > 0 && args.includes("grep");
3031
+ }
3032
+ /**
3033
+ * Add an `account.credential` segment for every credential read
3034
+ * {@link credentialReadLandings} finds that the text did not already name
3035
+ * (APRV-522). Only ever adds: the segment's own class stays, and the
3036
+ * credential read rides beside it in the shape the classifier's `also` has.
3037
+ */
3038
+ export function refineCredentialReads(result, command, cwd, env = process.env) {
3039
+ if (!result.ok)
3040
+ return { result, notes: [] };
3041
+ const landings = credentialReadLandings(command, cwd, env);
3042
+ if (landings.length === 0)
3043
+ return { result, notes: [] };
3044
+ const segments = [...result.segments];
3045
+ const notes = [];
3046
+ for (const landing of landings) {
3047
+ if (segments.some((segment) => segment.text === landing.segment && segment.class === "account.credential")) {
3048
+ continue;
3049
+ }
3050
+ segments.push({
3051
+ text: landing.segment,
3052
+ class: "account.credential",
3053
+ rule: CREDENTIAL_RESOLVED_RULE,
3054
+ path: landing.path,
3055
+ });
3056
+ notes.push(`${CREDENTIAL_RESOLVED_RULE}: \`${landing.segment}\` reads ${landing.path}, which is credential material once its path is resolved`);
3057
+ }
3058
+ if (notes.length === 0)
3059
+ return { result, notes };
3060
+ const classes = [...result.classes];
3061
+ if (!classes.includes("account.credential"))
3062
+ classes.push("account.credential");
3063
+ return { result: { ok: true, segments, classes }, notes };
3064
+ }
3065
+ // ===========================================================================
1971
3066
  // Write scope, the disk half (APRV-402)
1972
3067
  // ===========================================================================
1973
3068
  /**
@@ -2439,11 +3534,25 @@ export function refineSkillScripts(result, cwd, protectedPaths) {
2439
3534
  * resolved gate scope wants and what this classifier did before the field
2440
3535
  * existed.
2441
3536
  */
2442
- export function classifyForHook(command, protectedPaths, cwd, readRoots = []) {
3537
+ export function classifyForHook(command, protectedPaths, cwd, readRoots = [],
3538
+ /**
3539
+ * May an opaque program classify `exec.code`? (APRV-510.) `true` only when
3540
+ * the loaded policy opens the class ({@link execCodeGate}); the default is
3541
+ * today's answer, a refusal.
3542
+ */
3543
+ execCode = false,
3544
+ /**
3545
+ * Further directories the command may start in, judged by the credential
3546
+ * pass only (APRV-522 fix round 1, S-1): the envelope's `cwd` where the
3547
+ * harness sends no per-call directory. It only ever adds reads, so a stale
3548
+ * or absent directory costs nothing.
3549
+ */
3550
+ credentialCwds = []) {
2443
3551
  const roots = resolveScratchRoots(cwd);
2444
3552
  const classified = classifyCommand(command, protectedPaths, {
2445
3553
  scratchRoots: roots,
2446
3554
  ...(readRoots.length === 0 ? {} : { readRoots }),
3555
+ ...(execCode ? { execCode: true } : {}),
2447
3556
  });
2448
3557
  const rewritten = refineRewrite(classified, cwd);
2449
3558
  const scratched = refineScratchDelete(rewritten.result, roots);
@@ -2457,14 +3566,18 @@ export function classifyForHook(command, protectedPaths, cwd, readRoots = []) {
2457
3566
  // APRV-502. Touches only the two skill-script rules, which no pass above
2458
3567
  // reads, so its place in the chain changes nothing for any other segment.
2459
3568
  const scripted = refineSkillScripts(written.result, cwd, protectedPaths);
3569
+ // APRV-522. Last, and it only ever adds: every path a segment reads, judged
3570
+ // where the disk lands it, against the same credential rule the text was.
3571
+ const credentialed = refineCredentialReads(scripted.result, command, [cwd, ...credentialCwds]);
2460
3572
  return {
2461
- result: scripted.result,
3573
+ result: credentialed.result,
2462
3574
  notes: [
2463
3575
  ...rewritten.notes,
2464
3576
  ...scratched.notes,
2465
3577
  ...scoped.notes,
2466
3578
  ...written.notes,
2467
3579
  ...scripted.notes,
3580
+ ...credentialed.notes,
2468
3581
  ],
2469
3582
  };
2470
3583
  }
@@ -2536,7 +3649,9 @@ function commandClassify(argv, streams, cwd) {
2536
3649
  // contributes no `read_scope`, so the scope is the built-in roots alone,
2537
3650
  // which is the narrower answer and is what the hook itself would refuse on.
2538
3651
  const readRoots = resolveReadRoots(cwd, policyRootOf(load, root), load.ok ? load.policy.read_scope?.roots : undefined);
2539
- streams.out(renderClassification(classifyForHook(command, protectedPaths, cwd, readRoots).result, boolFlag(parsed.flags, "--json")));
3652
+ streams.out(renderClassification(classifyForHook(command, protectedPaths, cwd, readRoots,
3653
+ // APRV-510: the class the policy opens is the class the hook decides.
3654
+ execCodeGate(load.ok ? load.policy : null).shell).result, boolFlag(parsed.flags, "--json")));
2540
3655
  return EXIT_OK;
2541
3656
  }
2542
3657
  function sleepSync(ms) {
@@ -3059,8 +4174,23 @@ function fileToolGate(toolName, toolInput, protectedPaths, cwd) {
3059
4174
  // `.approval/log/` is `log.mutate`, and only the prose-and-configuration
3060
4175
  // surface stays `policy.edit`. Editing through the Edit tool must not be a
3061
4176
  // cheaper way to touch the gate than editing through a shell redirect.
3062
- const surface = protectedPathClass(declared, protectedPaths);
4177
+ const spelled = protectedPathClass(declared, protectedPaths);
3063
4178
  const file = absolute(declared, cwd);
4179
+ // APRV-511: judged where the write LANDS as well as where it is spelled, the
4180
+ // way the read tools and the shell pass already judge a path: through `..`
4181
+ // and the realpath of the deepest existing ancestor, so a symlink from the
4182
+ // workspace into the Hermes home (`ws/p -> ~/.hermes/plugins`) or a climb out
4183
+ // of a directory under it (`~/.hermes/workspace/../plugins/x`) cannot launder
4184
+ // an organ write into a workspace write. Only ever stricter: the landing
4185
+ // path's class replaces the spelled one when it is `log.mutate` or
4186
+ // `policy.core` and outranks it. Fix round 3 (M(L5c-1)): the landing is
4187
+ // taken in both readings, lexical and kernel (`<ws>/p/../plugins/x` with `p`
4188
+ // a symlink lands beside `p`'s TARGET), and an unknowable one is
4189
+ // `policy.core`.
4190
+ const landing = landingPaths(spelledAbsolute(cwd, declared));
4191
+ let surface = landing.unknown ? stricterOrganSurface(spelled, "policy.core") : spelled;
4192
+ for (const path of landing.paths)
4193
+ surface = stricterOrganSurface(surface, protectedPathClass(path, protectedPaths));
3064
4194
  const tier = tierOf(file, cwd);
3065
4195
  const rule = tier.rule;
3066
4196
  const head = { tool: toolName, rule, file };
@@ -3097,6 +4227,16 @@ function fileToolGate(toolName, toolInput, protectedPaths, cwd) {
3097
4227
  summary: summaryFor(tier, toolName, file),
3098
4228
  };
3099
4229
  }
4230
+ /**
4231
+ * The stricter of a file tool's spelled and landing classes (APRV-511): the
4232
+ * landing class wins only when it is a gate organ (`log.mutate`, then
4233
+ * `policy.core`) that outranks the spelled one, so resolving a path can move a
4234
+ * write to a stricter class and never to a looser one.
4235
+ */
4236
+ function stricterOrganSurface(spelled, landed) {
4237
+ const rank = (surface) => surface === "log.mutate" ? 0 : surface === "policy.core" ? 1 : surface === null ? 3 : 2;
4238
+ return rank(landed) <= 1 && rank(landed) < rank(spelled) ? landed : spelled;
4239
+ }
3100
4240
  /**
3101
4241
  * The first entry of a `paths` array that falls outside the read scope, or
3102
4242
  * `null` when every entry is inside it (APRV-350).
@@ -3169,11 +4309,16 @@ function credentialReadGate(toolName, toolInput, cwd) {
3169
4309
  // APRV-445 recheck SF2(e, f): judged through realpath, so a symlinked
3170
4310
  // parent cannot launder the path, and a directory of credentials handed to a
3171
4311
  // read tool (a search, a glob, a grep) is gated as the credentials are.
3172
- const file = canonicalPath(absolute(declared, cwd));
4312
+ // APRV-511 fix round 3 (M(L5c-1)): in both readings, lexical and kernel, so
4313
+ // `<ws>/p/../.op.env` with `p` a symlink into the home is judged where the
4314
+ // read lands; an unknowable landing (`/proc/self/cwd/…`) is a credential read.
4315
+ const landing = landingPaths(spelledAbsolute(cwd, declared));
3173
4316
  // Recheck 3 (SF1a): only a directory that holds credentials (a home root, an
3174
4317
  // approval home, a home's `approval/`, or a directory under a home holding a
3175
4318
  // credential file); `skills/`, `workspace/` and the like are ordinary.
3176
- if (!isCredentialPath(file) && !holdsCredentials(file))
4319
+ const file = landing.paths.find((path) => isCredentialPath(path) || holdsCredentials(path)) ??
4320
+ (landing.unknown ? absolute(declared, cwd) : null);
4321
+ if (file === null)
3177
4322
  return null;
3178
4323
  const input = { ...toolInput };
3179
4324
  delete input["description"];
@@ -4699,7 +5844,37 @@ toolPolicy = null,
4699
5844
  * `harness.tool.unmapped`, so the bypass RECORDS it rather than letting it
4700
5845
  * run unrecorded on the word of a file nobody vouched for.
4701
5846
  */
4702
- ungatedAllowed = true) {
5847
+ ungatedAllowed = true,
5848
+ /**
5849
+ * What the policy opens of `exec.code` (APRV-510). Closed by default, which
5850
+ * is every answer this function gave before the class existed. The closed
5851
+ * path passes {@link execGateOf} the loaded policy; the open window passes
5852
+ * it closed, because opening the class is a policy statement and a window
5853
+ * suspends the policy.
5854
+ */
5855
+ execGate = closedExecCodeGate("silent")) {
5856
+ // APRV-510. A Hermes `execute_code` call, first, because it is claimed by the
5857
+ // rule table below and must never be described by it: the rule table binds
5858
+ // the whole `tool_input`, and this call's input is a program.
5859
+ if (adapter.kind === "hermes" && input.toolName === HERMES_EXECUTE_CODE_TOOL) {
5860
+ if (!execGate.executeCode) {
5861
+ return {
5862
+ kind: "deny",
5863
+ code: HERMES_EXECUTE_CODE_REFUSAL,
5864
+ detail: executeCodeRefusal(execGate.closed === "window" ? "window" : "unhooked"),
5865
+ };
5866
+ }
5867
+ const bound = bindExecuteCode(input.toolInput, payloadHash(input.toolInput));
5868
+ if (!bound.ok)
5869
+ return { kind: "deny", code: bound.code, detail: bound.detail };
5870
+ return {
5871
+ kind: "gated",
5872
+ classes: ["exec.code"],
5873
+ payload: bound.payload,
5874
+ headline: bound.headline,
5875
+ notes: bound.notes,
5876
+ };
5877
+ }
4703
5878
  if (adapter.kind === "codex" && input.toolName === "apply_patch") {
4704
5879
  // APRV-363. The app-server's LEGACY `applyPatchApproval` carries its change
4705
5880
  // as a MAP of path to change, inline on the request, and never as an
@@ -4799,15 +5974,63 @@ ungatedAllowed = true) {
4799
5974
  // hook's own, so a relative path in the command resolves the way the shell
4800
5975
  // will resolve it. For every adapter without a per-call working directory
4801
5976
  // this is `input.cwd` or the hook's own cwd exactly as before.
4802
- const refined = classifyForHook(raw, protectedPaths, shellCwd ?? cwd, readRoots);
5977
+ // Refuter S3. A Hermes `terminal` call with `background` or `pty` set keeps
5978
+ // a process whose stdin a later `process` call writes, outside any hash, so
5979
+ // the call is not the `exec.code` shape: any value of either key but
5980
+ // absent or `false` closes the class for it, and it keeps today's answer.
5981
+ const laterStdin = adapter.kind === "hermes" &&
5982
+ [input.toolInput["background"], input.toolInput["pty"]].some((value) => value !== undefined && value !== false);
5983
+ const callGate = laterStdin ? closedExecCodeGate("silent") : execGate;
5984
+ // APRV-522 fix round 1 (S-1). Where the harness sends no per-call
5985
+ // directory (Claude Code), the classification stays on the hook process's
5986
+ // own cwd as above, and the credential pass ALSO judges from the
5987
+ // envelope's `cwd`: a `cd` made in an earlier Bash call can leave the
5988
+ // session's directory somewhere the hook process is not. It only adds.
5989
+ const credentialCwds = shellCwd === null && isAbsolute(input.cwd) && input.cwd !== cwd ? [input.cwd] : [];
5990
+ const refined = classifyForHook(raw, protectedPaths, shellCwd ?? cwd, readRoots, callGate.shell, credentialCwds);
4803
5991
  const classified = refined.result;
4804
5992
  if (!classified.ok) {
5993
+ // APRV-510. A policy that NAMES `exec.code` without attesting custody is
5994
+ // told which line is missing, where the same command would have been an
5995
+ // `exec.code` segment with it. Every other policy keeps today's refusal,
5996
+ // byte for byte.
5997
+ if (callGate.closed === "unattested" && classified.code !== "unparseable") {
5998
+ const opened = classifyForHook(raw, protectedPaths, shellCwd ?? cwd, readRoots, true, credentialCwds).result;
5999
+ const program = opened.ok
6000
+ ? opened.segments.find((segment) => segment.execCode !== undefined)
6001
+ : undefined;
6002
+ if (program !== undefined) {
6003
+ return {
6004
+ kind: "deny",
6005
+ code: "hook-exec-code-custody-unattested",
6006
+ detail: `${program.execCode === undefined ? "the command" : describeProgram(program.execCode)} runs a program (exec.code), and this policy names exec.code without declaring \`${EXEC_CODE_CUSTODY_KEY}\`. Opaque code is not run before the operator attests that credential custody is in place; the operator adds that line to the policy and attests it. Nothing was appended.`,
6007
+ };
6008
+ }
6009
+ }
4805
6010
  return {
4806
6011
  kind: "deny",
4807
6012
  code: `hook-${classified.code}`,
4808
6013
  detail: `${classified.detail} (segment: ${classified.segment}). Rewrite it as a command the classifier can read, or run the effect through \`approval run\` with a granted token.`,
4809
6014
  };
4810
6015
  }
6016
+ // APRV-510: a program segment is bound by its hash and length, never its
6017
+ // text, so the payload is the exec.code shape rather than `{command, cwd}`.
6018
+ // A FILE program is resolved against the directory the harness states
6019
+ // (the per-call key, else the envelope's `cwd`), never the hook process's
6020
+ // own cwd, and gets no file mode when the harness states none (fix round
6021
+ // 2, refuter N8: Claude Code declares no per-call key, and the hook's cwd
6022
+ // is a guess at where its Bash tool runs). Where the adapter declares a
6023
+ // per-call key and the call CARRIES it, that value is the only statement:
6024
+ // a relative or non-string value gets no file mode at all, never the
6025
+ // envelope's `cwd` (fix round 3, refuter S1: Muse runs `workdir: "a"` in
6026
+ // `<cwd>/a`, and the fallback hashed `<cwd>/x.py` while bash ran
6027
+ // `<cwd>/a/x.py`). The envelope's `cwd` stands only when the key is absent.
6028
+ const perCallKeyCarried = adapter.shellCwdKey !== undefined && Object.hasOwn(input.toolInput, adapter.shellCwdKey);
6029
+ const fileWorkdir = shellCwd ?? (perCallKeyCarried ? null : isAbsolute(input.cwd) ? input.cwd : null);
6030
+ const execBound = bindExecCodeCommand(raw, classified.segments, shellCwd ?? cwd, shellCwd ?? input.cwd, protectedPaths, fileWorkdir);
6031
+ if (execBound !== null && !execBound.ok) {
6032
+ return { kind: "deny", code: execBound.code, detail: execBound.detail };
6033
+ }
4811
6034
  const classes = classified.classes.filter((cls) => cls !== GATE_SELF_CLASS);
4812
6035
  // APRV-445 refutation (B2, L10). The classifier matches protected paths by
4813
6036
  // their SEGMENTS, and a relative word carries only the segments it was
@@ -4818,7 +6041,7 @@ ungatedAllowed = true) {
4818
6041
  // lands on an organ takes the organ's class, and a read that lands on a
4819
6042
  // credential takes `account.credential`. It only ever adds a class.
4820
6043
  if (shellCwd !== null) {
4821
- for (const cls of resolvedPathClasses(raw, classified.segments, shellCwd, protectedPaths)) {
6044
+ for (const cls of resolvedPathClasses(raw, classified.segments, shellCwd, protectedPaths, readRoots)) {
4822
6045
  if (!classes.includes(cls))
4823
6046
  classes.push(cls);
4824
6047
  }
@@ -4895,6 +6118,16 @@ ungatedAllowed = true) {
4895
6118
  }
4896
6119
  }
4897
6120
  }
6121
+ if (execBound !== null) {
6122
+ return {
6123
+ kind: "gated",
6124
+ classes,
6125
+ payload: execBound.payload,
6126
+ headline: execBound.headline,
6127
+ notes: [...refined.notes, ...execBound.notes],
6128
+ segments: classified.segments,
6129
+ };
6130
+ }
4898
6131
  return {
4899
6132
  kind: "gated",
4900
6133
  classes,
@@ -5025,6 +6258,17 @@ ungatedAllowed = true) {
5025
6258
  * resolve to a credential take `account.credential`, and so does a directory
5026
6259
  * that holds credentials ({@link holdsCredentials}) handed to a binary that
5027
6260
  * reads directories recursively (`grep -r`, `rg`, `find`, `tar`, ...).
6261
+ * Such a reader of a word this hook cannot expand, or of a pattern it cannot
6262
+ * expand, is `account.credential` outright (APRV-511 cred-asymmetry round
6263
+ * 2): the word may land on, or above, a home. A reader of a recursive kind
6264
+ * that names no path walks the working directory, which is judged as its
6265
+ * operand (round 3, N2). Any other read of such a word is judged by
6266
+ * {@link unresolvedReadCouldNameCredential}, as spelled and with its
6267
+ * literal prefix landed from each working directory (round 3, S1).
6268
+ * The files a `curl`/`wget` upload or body option reads (`-T F`, `-d @F`,
6269
+ * `--post-file F`) are read paths too, and also take the class of a
6270
+ * protected path or organ they land on and, under a read scope, the
6271
+ * read-scope refusal outside it (APRV-511 fix round 5).
5028
6272
  * - After a `cd` this hook cannot resolve, every later relative write is
5029
6273
  * `policy.core` and every later relative read `account.credential`.
5030
6274
  * - `cd` moves the directory for the segments after it. Like the Codex arm, a
@@ -5034,7 +6278,10 @@ ungatedAllowed = true) {
5034
6278
  * so a symlinked parent or working directory is judged by where it lands.
5035
6279
  * - `$HERMES_HOME` / `${HERMES_HOME}` expand to `APPROVAL_HERMES_HOME`, else
5036
6280
  * `HERMES_HOME`, from this hook process's environment; `~`, `$HOME` and
5037
- * `${HOME}` to `HOME`. Under `approval serve` the process is the daemon, so a
6281
+ * `${HOME}` to `HOME`; `~+` to each working directory (and, as a quoted
6282
+ * one, to the literal directory it names), and bash's other tilde prefixes
6283
+ * (`~-`, the directory stack, `~name`) to the unknown variable each stands
6284
+ * for (round 3, S2). Under `approval serve` the process is the daemon, so a
5038
6285
  * hosted operator sets `APPROVAL_HERMES_HOME` there; without it, the
5039
6286
  * conservative rules above apply to the unexpanded spelling.
5040
6287
  *
@@ -5045,39 +6292,76 @@ ungatedAllowed = true) {
5045
6292
  *
5046
6293
  * It only ever adds classes.
5047
6294
  */
5048
- function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.env) {
6295
+ function resolvedPathClasses(raw, segments, cwd, protectedPaths, readRoots = [], env = process.env) {
5049
6296
  const found = [];
5050
6297
  const add = (cls) => {
5051
6298
  if (cls !== null && !found.includes(cls))
5052
6299
  found.push(cls);
5053
6300
  };
5054
- const shapes = commandSegmentShapes(raw) ?? [];
6301
+ // APRV-522 fix round 1 (M-A): a login-shell wrapper is classified by the
6302
+ // segments of its script, spliced in place of the wrapper, so the shapes are
6303
+ // spliced the same way and line up with the classified segments again.
6304
+ const shapes = (commandSegmentShapes(raw) ?? []).flatMap((shape) => shape.script === null ? [shape] : (commandSegmentShapes(shape.script) ?? [shape]));
5055
6305
  // Trusted as given: the operator sets it (under co-location, the control
5056
6306
  // plane sets it from the tenant's home), and nothing the agent writes reaches
5057
6307
  // this process's environment.
5058
6308
  const hermesHome = env["APPROVAL_HERMES_HOME"] ?? env["HERMES_HOME"] ?? null;
5059
6309
  const home = env["HOME"] ?? null;
5060
- /** The word with the variables this hook can know expanded. */
6310
+ const configuredHome = hermesHome !== null && hermesHome.length > 0 ? canonicalPath(hermesHome) : null;
6311
+ /**
6312
+ * The word with the variables this hook can know expanded.
6313
+ *
6314
+ * Merge of APRV-511 and APRV-522: APRV-522's shared expansion (and its
6315
+ * `landingUnknown` for every tilde prefix it does not expand), with
6316
+ * APRV-511's spelling of bash's other tilde prefixes as the variable each
6317
+ * stands for (round 3, S2), so a caller that reads the text sees a variable.
6318
+ */
5061
6319
  const expand = (word) => {
5062
- let text = word;
5063
- let hermesUnknown = false;
5064
- const hermesRef = /^\$(?:\{HERMES_HOME\}|HERMES_HOME)(?=\/|$)/u;
5065
- if (hermesRef.test(text)) {
5066
- if (hermesHome !== null && hermesHome.length > 0)
5067
- text = text.replace(hermesRef, hermesHome);
5068
- else
5069
- hermesUnknown = true;
5070
- }
5071
- const homeRef = /^(?:~|\$\{HOME\}|\$HOME)(?=\/|$)/u;
5072
- if (homeRef.test(text) && home !== null && home.length > 0)
5073
- text = text.replace(homeRef, home);
5074
- return { text, hermesUnknown, unresolved: hermesUnknown || /[$`]/u.test(text) };
6320
+ const expanded = expandHookWord(word, env);
6321
+ const text = expanded.text.startsWith("~") ? otherTildePrefix(expanded.text) : expanded.text;
6322
+ return { ...expanded, text, unresolved: expanded.unresolved || /[$`]/u.test(text) };
5075
6323
  };
5076
6324
  // After a `cd` this hook cannot resolve (`cd $X`, `cd "$D"`, `cd -`), the
5077
6325
  // directory every later relative path lands in is unknown (recheck 3, SF2).
5078
6326
  let cwdUnknown = false;
5079
- let cwds = [canonicalPath(cwd)];
6327
+ // APRV-511 fix round 3 (M(L5c-1)): what earlier segments of this command
6328
+ // write, link, move or delete (`changed`, the path and everything under it)
6329
+ // and extract into (`extracted`, everything under it). A `..` out of one of
6330
+ // them is unknowable, because by the time it runs it may be a symlink this
6331
+ // hook never saw (`ln -s ~/.hermes/skills k && cat k/../.op.env`).
6332
+ // After a program whose file effects this pass does not model (`git
6333
+ // checkout`, `git clone`, `npm install`, a script), any directory but a
6334
+ // working directory already reached may have become a link, so a `..` out of
6335
+ // one is unknowable too.
6336
+ const changed = [];
6337
+ const extracted = [];
6338
+ let unmodeled = false;
6339
+ const volatile = (path) => changed.some((each) => path === each || path.startsWith(`${each}/`)) ||
6340
+ extracted.some((each) => path.startsWith(`${each}/`)) ||
6341
+ (unmodeled && !landed.includes(path));
6342
+ const segmentChanged = [];
6343
+ const segmentExtracted = [];
6344
+ let segmentUnmodeled = false;
6345
+ const land = (from, text) => landingPaths(spelledAbsolute(from, text), volatile);
6346
+ // APRV-511 fix round 3 (M(L5c-1)): every directory is kept in BOTH readings,
6347
+ // lexical and kernel, so a `cd k/..` through a symlink is followed to where
6348
+ // the shell actually goes as well as to where the text says.
6349
+ const cwdLanding = landingPaths(cwd);
6350
+ // `landed`: the directories reached in a reading this pass placed itself
6351
+ // (APRV-511), which is what `volatile` exempts after an unmodeled program.
6352
+ let landed = cwdLanding.paths;
6353
+ if (cwdLanding.unknown)
6354
+ cwdUnknown = true;
6355
+ // APRV-522 fix round 2 (path-parser differential): the directory as given,
6356
+ // too, so a later `cd ..` climbs it the way the shell's logical `cd` does.
6357
+ // Every path is judged from every entry, so the merge keeps both sides' set.
6358
+ let cwds = [...new Set([...(isAbsolute(cwd) ? [cwd] : []), ...landed])];
5080
6359
  for (const [index, segment] of segments.entries()) {
6360
+ // What the previous segment changed is in place before this one runs.
6361
+ changed.push(...segmentChanged.splice(0));
6362
+ extracted.push(...segmentExtracted.splice(0));
6363
+ if (segmentUnmodeled)
6364
+ unmodeled = true;
5081
6365
  const shape = shapes.length === segments.length
5082
6366
  ? shapes[index]
5083
6367
  : shapes.find((candidate) => candidate.text === segment.text);
@@ -5085,26 +6369,56 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.e
5085
6369
  continue;
5086
6370
  const bin = shape.bin === null ? null : basename(shape.bin);
5087
6371
  const operands = shape.args.filter((arg) => !arg.startsWith("-"));
6372
+ segmentUnmodeled =
6373
+ bin !== null &&
6374
+ !EFFECT_MODELED_BINS.has(bin) &&
6375
+ (!segment.class.startsWith("read.") || segment.class === "read.vcs.remote");
5088
6376
  if (bin === "cd" || bin === "pushd") {
5089
- const target = operands[0];
5090
- if (target === undefined) {
5091
- // A bare `cd` goes HOME; `cd -` and `pushd` with no operand go to a
5092
- // directory recorded earlier in a shell this hook never saw.
5093
- if (bin === "cd" && !shape.args.includes("-") && home !== null) {
5094
- cwds = [...new Set([...cwds, canonicalPath(home)])];
6377
+ // Fix round 2: the argv read as the shell reads it ({@link cdTarget}).
6378
+ // An assignment prefix (fix round 1, M-B), `cd -`, `pushd` with no
6379
+ // operand, zsh's stack and two-operand forms, and an operand opening
6380
+ // with `-` or `+` all leave the directory unknown.
6381
+ const target = cdTarget(bin, shape.args, shape.assignments);
6382
+ if (target.kind === "unknown") {
6383
+ cwdUnknown = true;
6384
+ }
6385
+ else if (target.kind === "home") {
6386
+ // A bare `cd` goes HOME.
6387
+ if (home !== null) {
6388
+ cwds = [...new Set([...cwds, home, canonicalPath(home)])];
6389
+ landed = [...new Set([...landed, canonicalPath(home)])];
5095
6390
  }
5096
6391
  else {
5097
6392
  cwdUnknown = true;
5098
6393
  }
5099
6394
  }
5100
6395
  else {
5101
- const expanded = expand(target);
5102
- if (expanded.unresolved || hasGlobChars(expanded.text)) {
6396
+ // Round 3 (S2): `cd ~+/x` in both its readings, as a path word is.
6397
+ const spellings = tildeReadings(target.word).map((spelling) => expand(spelling));
6398
+ // Fix round 1 (M-B): an inherited CDPATH is NOT assumed here. Every
6399
+ // READ this pass judges is judged again, fail-closed on CDPATH, by
6400
+ // credentialReadLandings (classifyForHook runs it for every adapter);
6401
+ // assuming it for this pass's WRITES would make every later relative
6402
+ // write after `cd sub` `policy.core`. That write-side reading is a
6403
+ // stated limit (docs/hermes-hook.md), not a claim of this pass.
6404
+ if (spellings.some((expanded) => expanded.unresolved || hasGlobChars(expanded.text))) {
5103
6405
  cwdUnknown = true;
5104
6406
  }
5105
6407
  else {
5106
- const next = cwds.map((from) => canonicalPath(resolvePathSegments(from, expanded.text)));
5107
- cwds = [...new Set([...cwds, ...next])];
6408
+ const next = spellings.flatMap((expanded) => cwds.map((from) => land(from, expanded.text)));
6409
+ if (next.some((landing) => landing.unknown))
6410
+ cwdUnknown = true;
6411
+ const reached = next.flatMap((landing) => landing.paths);
6412
+ // A directory this command creates or replaces before the `cd`
6413
+ // runs: a later `..` out of it is unknowable as well.
6414
+ for (const path of reached)
6415
+ if (volatile(path) || !existsSync(path))
6416
+ changed.push(path);
6417
+ // APRV-522 fix round 2: the logical landing as spelled, beside the
6418
+ // lexical and kernel ones, so a later `cd ..` climbs it logically.
6419
+ const logical = spellings.flatMap((expanded) => cwds.map((from) => resolvePathSegments(from, expanded.text)));
6420
+ landed = [...new Set([...landed, ...reached])];
6421
+ cwds = [...new Set([...cwds, ...logical, ...reached])];
5108
6422
  }
5109
6423
  }
5110
6424
  continue;
@@ -5112,8 +6426,19 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.e
5112
6426
  if (bin === null && shape.writes.length === 0 && shape.reads.length === 0)
5113
6427
  continue;
5114
6428
  const judgeWrite = (word) => {
6429
+ // APRV-511 refuter M2: each word bash's brace expansion makes is a target.
6430
+ const braced = braceCandidates(word);
6431
+ if (braced === null) {
6432
+ add("policy.core");
6433
+ return;
6434
+ }
6435
+ for (const each of braced)
6436
+ for (const spelling of tildeReadings(each))
6437
+ judgeWriteWord(spelling);
6438
+ };
6439
+ const judgeWriteWord = (word) => {
5115
6440
  const target = expand(word);
5116
- if (target.hermesUnknown) {
6441
+ if (target.hermesUnknown || target.landingUnknown) {
5117
6442
  add("policy.core");
5118
6443
  return;
5119
6444
  }
@@ -5121,20 +6446,39 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.e
5121
6446
  add("policy.core");
5122
6447
  return;
5123
6448
  }
6449
+ // APRV-511 (the CUSTODY recheck's M1'): a variable this hook cannot
6450
+ // expand that decides the DIRECTORY (`>> "$X/.env"`, `> "$OUT"`, `cp f
6451
+ // $D/`) may land anywhere, the home's organs included, so the write is
6452
+ // `policy.core`. A variable inside a final name after a literal prefix
6453
+ // (`out_$N.txt`) leaves the directory known and is judged below.
6454
+ if (variableDecidesDirectory(target.text)) {
6455
+ add("policy.core");
6456
+ return;
6457
+ }
5124
6458
  const globbed = target.unresolved || hasGlobChars(target.text);
5125
6459
  for (const from of cwds) {
5126
- const absoluteTarget = resolvePathSegments(from, target.text);
5127
- if (globbed) {
5128
- // SF3a: a pattern in any component that could name the home, the
5129
- // approval home or an organ is an organ write; a pattern written
5130
- // into one of the gate's own directories (SF1b) may be any file
5131
- // there. Anywhere else it is a workspace write.
5132
- if (globCouldNameOrgan(absoluteTarget) || writesIntoGateRoot(canonicalPath(dirname(absoluteTarget)))) {
5133
- add("policy.core");
6460
+ // APRV-511 fix round 3 (M(L5c-1)): the lexical landing and the kernel
6461
+ // one (`k/../plugins/x` with `k` a symlink lands under `k`'s target's
6462
+ // parent), each judged; the stricter class wins because both are added.
6463
+ const spelled = spelledAbsolute(from, target.text);
6464
+ const landing = landingPaths(spelled, volatile);
6465
+ if (landing.unknown)
6466
+ add("policy.core");
6467
+ if (!globbed)
6468
+ segmentChanged.push(...landing.paths);
6469
+ for (const absoluteTarget of [resolvePathSegments(spelled), ...landing.paths]) {
6470
+ if (globbed) {
6471
+ // SF3a: a pattern in any component that could name the home, the
6472
+ // approval home or an organ is an organ write; a pattern written
6473
+ // into one of the gate's own directories (SF1b) may be any file
6474
+ // there. Anywhere else it is a workspace write.
6475
+ if (globCouldNameOrgan(absoluteTarget) || writesIntoGateRoot(canonicalPath(dirname(absoluteTarget)))) {
6476
+ add("policy.core");
6477
+ }
6478
+ continue;
5134
6479
  }
5135
- continue;
6480
+ add(protectedPathClass(canonicalPath(absoluteTarget), protectedPaths));
5136
6481
  }
5137
- add(protectedPathClass(canonicalPath(absoluteTarget), protectedPaths));
5138
6482
  }
5139
6483
  };
5140
6484
  /** A copy or move INTO a directory writes `<dir>/<basename of source>`. */
@@ -5152,20 +6496,43 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.e
5152
6496
  /** An extraction or a contents-copy writes unknown names into `directory`. */
5153
6497
  const judgeExtractInto = (directory) => {
5154
6498
  const target = expand(directory);
5155
- if (target.hermesUnknown || (cwdUnknown && !isAbsolute(target.text))) {
6499
+ // Merge of APRV-511 and APRV-522: a tilde prefix this hook does not
6500
+ // expand (APRV-522's `landingUnknown`, `~+` and a `~` with no HOME
6501
+ // included) lands the extraction somewhere unknown, as it does a write.
6502
+ if (target.hermesUnknown || target.landingUnknown || (cwdUnknown && !isAbsolute(target.text))) {
5156
6503
  add("policy.core");
5157
6504
  return;
5158
6505
  }
5159
- if (target.unresolved)
6506
+ if (target.unresolved) {
6507
+ // APRV-511: an extraction into a directory a variable names.
6508
+ if (/[$`]/u.test(target.text))
6509
+ add("policy.core");
5160
6510
  return;
6511
+ }
5161
6512
  for (const from of cwds) {
5162
- if (writesIntoGateRoot(canonicalPath(resolvePathSegments(from, target.text))))
6513
+ const landing = land(from, target.text);
6514
+ segmentExtracted.push(...landing.paths);
6515
+ if (landing.unknown || landing.paths.some((path) => writesIntoGateRoot(path)))
5163
6516
  add("policy.core");
5164
6517
  }
5165
6518
  };
5166
- const judgeRead = (word, recursive) => {
6519
+ // `homeSpelled` is false only for a curl `--url` value that opens with an
6520
+ // unexpanded span (EDGEOS-BLOCK, APRV-533; {@link curlValuesNotRead}).
6521
+ const judgeRead = (word, recursive, homeSpelled = true) => {
6522
+ const braced = braceCandidates(word);
6523
+ if (braced === null) {
6524
+ add("account.credential");
6525
+ return;
6526
+ }
6527
+ for (const each of braced) {
6528
+ for (const spelling of tildeReadings(each))
6529
+ judgeReadWord(spelling, recursive, homeSpelled);
6530
+ }
6531
+ };
6532
+ const judgeReadWord = (word, recursive, homeSpelled = true) => {
5167
6533
  const target = expand(word);
5168
- if (target.hermesUnknown) {
6534
+ // Fix round 1 (M-C): an unexpanded tilde prefix lands somewhere unknown.
6535
+ if (target.hermesUnknown || target.landingUnknown) {
5169
6536
  add("account.credential");
5170
6537
  return;
5171
6538
  }
@@ -5173,70 +6540,262 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.e
5173
6540
  add("account.credential");
5174
6541
  return;
5175
6542
  }
5176
- if (target.unresolved)
5177
- return;
5178
- for (const from of cwds) {
5179
- const absoluteTarget = resolvePathSegments(from, target.text);
5180
- if (hasGlobChars(target.text)) {
5181
- // SF3b: expand against the directory when it is known; otherwise a
5182
- // pattern that could name a credential is read as one.
5183
- const candidates = expandGlob(absoluteTarget);
5184
- if (candidates === null) {
5185
- if (globCouldNameCredential(absoluteTarget))
5186
- add("account.credential");
5187
- }
5188
- else {
5189
- for (const candidate of candidates) {
5190
- if (isCredentialPath(canonicalPath(candidate)))
6543
+ if (target.unresolved) {
6544
+ // APRV-511 (cred-asymmetry M1): a word this hook cannot expand is still
6545
+ // judged by what it spells. `git` is exempt from the classifier's text
6546
+ // credential rule, so this pass is git's only credential check, and
6547
+ // `git diff --no-index /dev/null $(echo ~)/.hermes/.env` (whose `$(...)`
6548
+ // the lexer leaves as the substitution sentinel) passed as `read.shell`.
6549
+ if (unresolvedReadCouldNameCredential(target.text))
6550
+ add("account.credential");
6551
+ // APRV-511 (cred-asymmetry round 3, S1): the helper reads the text
6552
+ // alone, so a literal prefix that is relative (`./`, `../`, or no
6553
+ // directory at all, from a working directory inside a home) or runs
6554
+ // through a symlink to a home was never placed, although a resolved
6555
+ // word's is. The prefix is landed here from each working directory in
6556
+ // both readings, and the word is judged again with the prefix replaced
6557
+ // by each landing (and by `$HERMES_HOME` when it is the configured home).
6558
+ const { prefix, rest } = unresolvedPrefix(target.text);
6559
+ for (const from of cwds) {
6560
+ const spelled = spelledAbsolute(from, prefix);
6561
+ const landing = landingPaths(spelled, volatile);
6562
+ if (landing.unknown)
6563
+ add("account.credential");
6564
+ for (const landed of [resolvePathSegments(spelled), ...landing.paths]) {
6565
+ for (const spelling of landedSpellings(landed, rest, homeSpelled ? configuredHome : null)) {
6566
+ if (unresolvedReadCouldNameCredential(spelling))
5191
6567
  add("account.credential");
5192
6568
  }
5193
6569
  }
5194
- continue;
5195
6570
  }
5196
- const resolved = canonicalPath(absoluteTarget);
5197
- if (isCredentialPath(resolved))
6571
+ // APRV-511 (cred-asymmetry round 2): a RECURSIVE reader (a recursive
6572
+ // search, a copy or move of a directory, an archive, a walk) of a word
6573
+ // this hook cannot expand reads everything under wherever the word
6574
+ // lands. That may be a Hermes home, a profile home, an approval home or
6575
+ // a directory above one, and the shape of the text cannot rule any of
6576
+ // them out: an unexpanded span can always hold a `/` or a `..`. So it
6577
+ // fails closed, whatever the word spells.
6578
+ if (recursive)
5198
6579
  add("account.credential");
5199
- else if (recursive && holdsCredentials(resolved))
6580
+ return;
6581
+ }
6582
+ for (const from of cwds) {
6583
+ // APRV-511 fix round 3 (M(L5c-1)): both readings, as for a write.
6584
+ const spelled = spelledAbsolute(from, target.text);
6585
+ const landing = landingPaths(spelled, volatile);
6586
+ if (landing.unknown)
5200
6587
  add("account.credential");
6588
+ for (const absoluteTarget of [resolvePathSegments(spelled), ...landing.paths]) {
6589
+ if (hasGlobChars(target.text)) {
6590
+ // SF3b: expand against the directory when it is known; otherwise a
6591
+ // pattern that could name a credential is read as one.
6592
+ // Round 2: a recursive reader of a matched directory is judged as
6593
+ // the plain spelling is (a home, or a directory holding credential
6594
+ // files), and one whose pattern cannot be expanded here fails
6595
+ // closed, as an unresolved word does.
6596
+ const candidates = expandGlob(absoluteTarget);
6597
+ if (candidates === null) {
6598
+ if (recursive || globCouldNameCredential(absoluteTarget))
6599
+ add("account.credential");
6600
+ }
6601
+ else {
6602
+ for (const candidate of candidates) {
6603
+ const resolvedCandidate = canonicalPath(candidate);
6604
+ if (isCredentialPath(resolvedCandidate))
6605
+ add("account.credential");
6606
+ else if (recursive && holdsCredentials(resolvedCandidate))
6607
+ add("account.credential");
6608
+ }
6609
+ }
6610
+ continue;
6611
+ }
6612
+ const resolved = canonicalPath(absoluteTarget);
6613
+ if (isCredentialPath(resolved))
6614
+ add("account.credential");
6615
+ else if (recursive && holdsCredentials(resolved))
6616
+ add("account.credential");
6617
+ }
6618
+ }
6619
+ };
6620
+ /**
6621
+ * APRV-511 fix round 5 (last): a file an upload or request-body option
6622
+ * reads (`curl -T F`, `--data @F`, `-F f=@F`, `wget --post-file F`) is a
6623
+ * READ, whatever the segment's class: `curl -T ../.op.env URL` sends the
6624
+ * file `cat ../.op.env` would print. It takes the read pass's answer
6625
+ * ({@link judgeRead}: a credential, `cd`, `~`, `$HERMES_HOME`, both
6626
+ * landings), the class of a protected path or organ it lands on (as the
6627
+ * classifier's protected scan judges an absolute source), and, under a
6628
+ * read scope, the read-scope refusal for a file outside it.
6629
+ */
6630
+ const judgeUploadSource = (word) => {
6631
+ judgeRead(word, false);
6632
+ const target = expand(word);
6633
+ // The read pass has already answered these with the stricter class.
6634
+ // Merge of APRV-511 and APRV-522: a word whose only unknown is a tilde
6635
+ // landing (APRV-522's `landingUnknown`) is still judged by its literal
6636
+ // spelling here, as it was before the merge; the read pass has added
6637
+ // the credential class for it already.
6638
+ if (target.hermesUnknown || /[$`]/u.test(target.text) || (!isAbsolute(target.text) && cwdUnknown))
6639
+ return;
6640
+ for (const from of cwds) {
6641
+ const spelled = spelledAbsolute(from, target.text);
6642
+ const landing = landingPaths(spelled, volatile);
6643
+ for (const absoluteTarget of [resolvePathSegments(spelled), ...landing.paths]) {
6644
+ const resolved = canonicalPath(absoluteTarget);
6645
+ add(protectedPathClass(resolved, protectedPaths));
6646
+ if (readRoots.length > 0 && !isInReadScope(resolved, readRoots))
6647
+ add(READ_OUT_OF_SCOPE_CLASS);
6648
+ }
6649
+ }
6650
+ };
6651
+ /** A link source: the organ or credential a planted link would be a handle on. */
6652
+ const judgeLinkSource = (source, linkPath) => {
6653
+ const target = expand(source);
6654
+ if (target.hermesUnknown || target.unresolved) {
6655
+ add("policy.core");
6656
+ return;
6657
+ }
6658
+ for (const from of cwds) {
6659
+ const linkDir = dirname(resolvePathSegments(from, expand(linkPath).text));
6660
+ for (const base of [from, linkDir]) {
6661
+ const landing = resolvePathSegments(base, target.text);
6662
+ if (hasGlobChars(target.text)) {
6663
+ if (globCouldNameOrgan(landing) || globCouldNameCredential(landing))
6664
+ add("policy.core");
6665
+ continue;
6666
+ }
6667
+ // APRV-511 fix round 3 (M(L5c-1)): and where the kernel lands it.
6668
+ const kernel = landingPaths(spelledAbsolute(base, target.text), volatile);
6669
+ if (kernel.unknown)
6670
+ add("policy.core");
6671
+ for (const candidate of [landing, canonicalPath(landing), ...kernel.paths]) {
6672
+ const cls = protectedPathClass(candidate, protectedPaths);
6673
+ if (cls === "policy.core" || cls === "log.mutate")
6674
+ add(cls);
6675
+ if (isCredentialPath(candidate))
6676
+ add("account.credential");
6677
+ }
6678
+ }
5201
6679
  }
5202
6680
  };
5203
6681
  for (const target of shape.writes)
5204
6682
  judgeWrite(target);
5205
6683
  for (const source of shape.reads)
5206
6684
  judgeRead(source, false);
5207
- if (bin === null)
5208
- continue;
5209
- // `-t DIR` / `--target-directory=DIR` names the destination up front.
5210
- const targetDirFlag = () => {
6685
+ // APRV-511 refuter S2: a side-effecting command run FROM inside an organ
6686
+ // directory (`git pull`, `git apply`, `npm install` in hermes-agent/; `git
6687
+ // clone x` in plugins/) rewrites the organ, as `hermes update` does. The
6688
+ // home root itself is ordinary ground (its own rules judge each path).
6689
+ if (!segment.class.startsWith("read.") && segment.class !== GATE_SELF_CLASS && !cwdUnknown) {
6690
+ for (const from of cwds)
6691
+ if (isOrganDirectory(from))
6692
+ add("policy.core");
6693
+ }
6694
+ if (bin === "git") {
5211
6695
  for (const [at, arg] of shape.args.entries()) {
5212
- if (arg === "-t")
5213
- return shape.args[at + 1] ?? null;
5214
- if (arg.startsWith("--target-directory="))
5215
- return arg.slice("--target-directory=".length);
6696
+ for (const flag of ["--work-tree", "--git-dir"]) {
6697
+ const value = arg === flag ? shape.args[at + 1] : arg.startsWith(`${flag}=`) ? arg.slice(flag.length + 1) : undefined;
6698
+ if (value === undefined)
6699
+ continue;
6700
+ const dir = expand(value);
6701
+ if (dir.unresolved || dir.hermesUnknown) {
6702
+ add("policy.core");
6703
+ continue;
6704
+ }
6705
+ for (const from of cwds) {
6706
+ const landing = land(from, dir.text);
6707
+ if (landing.unknown || landing.paths.some((path) => isOrganDirectory(path)))
6708
+ add("policy.core");
6709
+ }
6710
+ }
5216
6711
  }
5217
- return null;
5218
- };
6712
+ }
6713
+ // APRV-511 refuter S1: a download's output is a write target.
6714
+ if (bin === "curl" || bin === "wget") {
6715
+ const outputs = fetchOutputs(bin, shape.args, shape.argFlags);
6716
+ // Fix round 3 (S(open)): an output this argv cannot place (a `#1` glob
6717
+ // name, an option word the shell may still make) may be an organ.
6718
+ if (outputs.unknown !== null)
6719
+ add("policy.core");
6720
+ for (const file of outputs.files)
6721
+ judgeWrite(file);
6722
+ for (const directory of outputs.directories)
6723
+ judgeExtractInto(directory);
6724
+ // Fix round 5 (last): what an upload or body option reads is a read
6725
+ // (`-` is curl's standard input, no file); a source word the text cannot
6726
+ // read is the stricter class, as the classifier already answers it.
6727
+ if (outputs.sourceUnknown !== null)
6728
+ add("account.credential");
6729
+ for (const source of outputs.sources) {
6730
+ if (bin === "curl" && source === "-")
6731
+ continue;
6732
+ judgeUploadSource(source);
6733
+ }
6734
+ }
6735
+ if ((bin === "cp" || bin === "mv" || bin === "ln" || bin === "install") && copyArgvUnknown(shape.args, shape.argFlags) !== null) {
6736
+ add("policy.core");
6737
+ }
6738
+ if (bin === null)
6739
+ continue;
6740
+ // `-t DIR` / `--target-directory=DIR` names the destination up front; so
6741
+ // do `-tDIR`, `-vtDIR` and `--target-dir=DIR` (APRV-511 refuter S3, recheck
6742
+ // S(open)). Every one named is judged; the first is the destination.
6743
+ // rsync's `-t` keeps times instead, so there the last operand stays the
6744
+ // destination and a word after `-t` is still judged as a write (as before).
6745
+ const flaggedDirs = copyTargetDirectories(shape.args);
6746
+ const targetDirs = bin === "rsync" ? [] : flaggedDirs;
6747
+ const targetDirFlag = () => targetDirs[0] ?? null;
5219
6748
  if (WRITE_EVERY_OPERAND.has(bin)) {
5220
6749
  for (const operand of operands)
5221
6750
  judgeWrite(operand);
5222
6751
  }
5223
6752
  else if (WRITE_LAST_OPERAND.has(bin)) {
5224
6753
  const targetDir = targetDirFlag();
5225
- const sources = targetDir === null ? operands.slice(0, -1) : operands.filter((operand) => operand !== targetDir);
5226
- const destination = targetDir ?? operands[operands.length - 1];
6754
+ // Fix round 1 (M-F): one operand and no `-t` is a SOURCE, landed in the
6755
+ // working directory under its own name (a one-operand `ln`).
6756
+ const single = targetDir === null && operands.length === 1;
6757
+ const sources = single
6758
+ ? operands
6759
+ : targetDir === null
6760
+ ? operands.slice(0, -1)
6761
+ : operands.filter((operand) => operand !== targetDir);
6762
+ const destination = single ? "." : (targetDir ?? operands[operands.length - 1]);
5227
6763
  for (const source of sources)
5228
6764
  judgeRead(source, true);
6765
+ // A move takes its sources away, so a later `..` out of one is unknowable.
6766
+ if (bin === "mv") {
6767
+ for (const source of sources) {
6768
+ const text = expand(source).text;
6769
+ for (const from of cwds)
6770
+ segmentChanged.push(...land(from, text).paths);
6771
+ }
6772
+ }
6773
+ // APRV-511 refuter S4: a link is a handle on its source. `ln` (soft or
6774
+ // hard) and `cp -s`/`cp -l` naming an organ or a credential as the
6775
+ // source plant that handle, and the plant is the act, because a later
6776
+ // write or read through it can be blind (co-location: the daemon cannot
6777
+ // realpath the 0700 home). A relative symlink source resolves against
6778
+ // the link's own directory, so both that and the working directory are
6779
+ // tried.
6780
+ if (bin === "ln" ||
6781
+ (bin === "cp" && shape.args.some((arg) => arg === "--symbolic-link" || arg === "--link" || /^-[a-zA-Z]*[sl]/u.test(arg)))) {
6782
+ for (const source of sources)
6783
+ judgeLinkSource(source, destination ?? ".");
6784
+ }
5229
6785
  if (destination !== undefined) {
5230
6786
  const expanded = expand(destination);
5231
- const intoDirectory = targetDir !== null ||
6787
+ const intoDirectory = single ||
6788
+ targetDir !== null ||
5232
6789
  destination.endsWith("/") ||
5233
6790
  (!expanded.unresolved &&
5234
- cwds.some((from) => isDirectoryPath(canonicalPath(resolvePathSegments(from, expanded.text)))));
6791
+ cwds.some((from) => land(from, expanded.text).paths.some((path) => isDirectoryPath(path))));
5235
6792
  if (intoDirectory)
5236
6793
  judgeWriteInto(destination, sources);
5237
6794
  else
5238
6795
  judgeWrite(destination);
5239
6796
  }
6797
+ for (const extra of bin === "rsync" ? flaggedDirs : targetDirs.slice(1))
6798
+ judgeWriteInto(extra, sources);
5240
6799
  }
5241
6800
  else if (WRITE_AFTER_FIRST_OPERAND.has(bin)) {
5242
6801
  for (const operand of operands.slice(1))
@@ -5263,13 +6822,40 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.e
5263
6822
  else if (bin === "unzip") {
5264
6823
  judgeExtractInto(flagValue(shape.args, "-d", null) ?? ".");
5265
6824
  }
5266
- if (segment.class.startsWith("read.") || RECURSIVE_READERS.has(bin)) {
5267
- for (const operand of operands)
5268
- judgeRead(operand, RECURSIVE_READERS.has(bin));
6825
+ // Fix round 1 (S): git and diff walk a directory they are handed, unless
6826
+ // git only writes the paths it names. (M-E): a search reads the working
6827
+ // directory.
6828
+ const readWords = credentialReadWords(bin, shape.args);
6829
+ const recursive = RECURSIVE_READERS.has(bin) || (DIRECTORY_READING_BINS.has(bin) && readWords.length > 0);
6830
+ if (segment.class.startsWith("read.") || recursive) {
6831
+ // EDGEOS-BLOCK (APRV-533): the curl and wget option values the tool
6832
+ // sends and never opens are not read here either, and a span-led curl
6833
+ // `--url` value is judged as the path an `@` or `file:` expansion would
6834
+ // read, as the credential pass judges it ({@link curlValuesNotRead}).
6835
+ const sent = curlValuesNotRead(bin, shape.args);
6836
+ const readOperands = sent.args === shape.args ? operands : sent.args.filter((arg) => !arg.startsWith("-"));
6837
+ for (const operand of readOperands)
6838
+ judgeRead(operand, recursive);
6839
+ for (const url of sent.spannedUrls)
6840
+ judgeRead(url, recursive, false);
6841
+ // APRV-511 (cred-asymmetry round 3, N2): a recursive reader that no
6842
+ // argument proves names a path walks the working directory.
6843
+ if (walksWorkingDirectory(bin, shape.args))
6844
+ judgeRead(".", true);
5269
6845
  }
6846
+ if (readsWorkingDirectory(bin, shape.args, readWords))
6847
+ judgeRead(".", true);
5270
6848
  }
5271
6849
  return found;
5272
6850
  }
6851
+ /**
6852
+ * Does an unexpanded variable (or a backtick) decide which directory `text`
6853
+ * lands in (APRV-511)? True when one sits in any directory component, or opens
6854
+ * the final one: either can expand to a path with slashes in it.
6855
+ */
6856
+ function variableDecidesDirectory(text) {
6857
+ return /[$`]/u.test(dirname(text)) || /^[$`]/u.test(basename(text));
6858
+ }
5273
6859
  /** `*`, `?` or `[` in a path word. */
5274
6860
  function hasGlobChars(text) {
5275
6861
  return /[*?[]/u.test(text);
@@ -5295,7 +6881,7 @@ function globRegex(component) {
5295
6881
  }
5296
6882
  }
5297
6883
  /** Names a credential file goes by, for a pattern to be tested against. */
5298
- const CREDENTIAL_NAMES = [".env", ".env.local", "auth.json", "env", "vault.enc", "keys"];
6884
+ const CREDENTIAL_NAMES = [".env", ".env.local", ".op.env", "auth.json", "env", "vault.enc", "keys"];
5299
6885
  /** Names an organ goes by, beyond the credentials. */
5300
6886
  const ORGAN_NAMES = [
5301
6887
  ...CREDENTIAL_NAMES,
@@ -5306,6 +6892,9 @@ const ORGAN_NAMES = [
5306
6892
  "agent-hooks",
5307
6893
  "approval",
5308
6894
  "scripts",
6895
+ // APRV-511: the runtime's source install and the plugin directory.
6896
+ "hermes-agent",
6897
+ "plugins",
5309
6898
  ];
5310
6899
  /** Could some component of `path`'s pattern name the home or the approval home? */
5311
6900
  function globCouldNameGateHome(path) {
@@ -5317,6 +6906,21 @@ function globCouldNameGateHome(path) {
5317
6906
  function globCouldNameOrgan(path) {
5318
6907
  if (globCouldNameGateHome(path))
5319
6908
  return true;
6909
+ // APRV-511: a pattern in a DIRECTORY component right under a home could name
6910
+ // an organ directory the write lands inside (`~/.hermes/plu*/av-approval/x`,
6911
+ // `~/.hermes/agent-h*/shim.sh`); the last component is judged below.
6912
+ const components = path.split(/[/\\]+/u);
6913
+ for (let index = 1; index < components.length - 1; index += 1) {
6914
+ const component = components[index];
6915
+ if (!hasGlobChars(component))
6916
+ continue;
6917
+ const parent = components[index - 1];
6918
+ const underHome = parent === ".hermes" ||
6919
+ hasGlobChars(parent) ||
6920
+ (components[index - 3] === ".hermes" && components[index - 2] === "profiles");
6921
+ if (underHome && ORGAN_NAMES.some((name) => globRegex(component).test(name)))
6922
+ return true;
6923
+ }
5320
6924
  const last = basename(path);
5321
6925
  if (!hasGlobChars(last))
5322
6926
  return false;
@@ -5326,13 +6930,437 @@ function globCouldNameOrgan(path) {
5326
6930
  hasGlobChars(parent);
5327
6931
  return parentIsHome && ORGAN_NAMES.some((name) => pattern.test(name));
5328
6932
  }
5329
- /** SF3b: a read pattern whose directory is unknown, that could name a credential. */
6933
+ /**
6934
+ * SF3b: a read pattern whose directory is unknown, that could name a credential.
6935
+ *
6936
+ * Fix round 4 (review-b r3 S2): a pattern in a DIRECTORY component is judged
6937
+ * too, as {@link globCouldNameOrgan} judges one for a write. Each such
6938
+ * component that could name `hermes-agent` or `profiles` (in any case) is
6939
+ * replaced by that name, and the path is judged again by the credential rows
6940
+ * ({@link isCredentialPath}), by this test for its other patterns, and, when
6941
+ * it holds an unexpanded span, by {@link unresolvedReadCouldNameCredential}.
6942
+ * So a pattern for the install directory before its env file, or for the
6943
+ * profiles directory before a profile's credential, is a credential read; the
6944
+ * same pattern before an ordinary file is not.
6945
+ *
6946
+ * Fix round 5 (review-b r4 M1): no recursion. Round 4 judged each replacement
6947
+ * by calling this test again (and, through
6948
+ * {@link unresolvedReadCouldNameCredential}, again), with no cap, so the work
6949
+ * grew factorially in the count of such components. A hook stopped by its
6950
+ * harness's timeout lets the call through on Claude Code. The same choices
6951
+ * are now walked once each, in a loop: every pattern component in a directory
6952
+ * position that could name either directory is kept as written or replaced by
6953
+ * a name it could stand for, and every combination but "all kept" is judged
6954
+ * by the credential rows, by this test's own name tests, and, where a span
6955
+ * remains, by {@link spanReadingsCouldNameCredential}. That is the set round 4
6956
+ * reached, so up to the cap the answer is round 4's. Past
6957
+ * {@link GLOB_DIRECTORY_NAMING_CAP} such components the word is a credential
6958
+ * read without judging it: fail closed, at the cost of one approval.
6959
+ */
5330
6960
  function globCouldNameCredential(path) {
6961
+ if (globNamesCredentialOrHome(path))
6962
+ return true;
6963
+ const components = path.split(/[/\\]+/u);
6964
+ const choices = [];
6965
+ for (let index = 0; index < components.length - 1; index += 1) {
6966
+ const component = components[index];
6967
+ if (!hasGlobChars(component))
6968
+ continue;
6969
+ const names = GLOB_DIRECTORY_NAMES.filter((name) => globRegex(component).test(name) || globRegex(component.toLowerCase()).test(name));
6970
+ if (names.length > 0)
6971
+ choices.push({ index, names });
6972
+ }
6973
+ if (choices.length === 0)
6974
+ return false;
6975
+ if (choices.length > GLOB_DIRECTORY_NAMING_CAP)
6976
+ return true;
6977
+ // An odometer over the choices: 0 keeps the pattern, n > 0 names names[n - 1].
6978
+ // All zeros (every pattern kept) is the word itself, which the caller judges.
6979
+ const picks = choices.map(() => 0);
6980
+ for (;;) {
6981
+ let at = 0;
6982
+ while (at < picks.length && picks[at] === choices[at].names.length) {
6983
+ picks[at] = 0;
6984
+ at += 1;
6985
+ }
6986
+ if (at === picks.length)
6987
+ return false;
6988
+ picks[at] = picks[at] + 1;
6989
+ const named = [...components];
6990
+ for (const [position, choice] of choices.entries()) {
6991
+ const pick = picks[position];
6992
+ if (pick > 0)
6993
+ named[choice.index] = choice.names[pick - 1];
6994
+ }
6995
+ const text = named.join("/");
6996
+ if (globNamesCredentialOrHome(text) || isCredentialPath(text))
6997
+ return true;
6998
+ if (/[$`]/u.test(text) && spanReadingsCouldNameCredential(text))
6999
+ return true;
7000
+ }
7001
+ }
7002
+ /** The directory names {@link globCouldNameCredential} reads a directory-component pattern as. */
7003
+ const GLOB_DIRECTORY_NAMES = ["hermes-agent", "profiles"];
7004
+ /**
7005
+ * How many directory-component patterns that could name the install or
7006
+ * profiles directory {@link globCouldNameCredential} judges (fix round 5,
7007
+ * review-b r4 M1). Each is kept or named, so k of them cost at most 3^k - 1
7008
+ * judgements (80 at this cap); a word with more is a credential read. A `*`
7009
+ * component is already one through {@link globCouldNameGateHome}, so the cap
7010
+ * only ever meets narrower patterns (`h*`, `p*`, `[hp]*`), and rarely.
7011
+ */
7012
+ const GLOB_DIRECTORY_NAMING_CAP = 4;
7013
+ /** A last-component pattern that could name a credential file, or any pattern that could name a home. */
7014
+ function globNamesCredentialOrHome(path) {
5331
7015
  const last = basename(path);
5332
- if (!hasGlobChars(last))
5333
- return globCouldNameGateHome(path);
5334
- const pattern = globRegex(last);
5335
- return CREDENTIAL_NAMES.some((name) => pattern.test(name)) || globCouldNameGateHome(path);
7016
+ if (hasGlobChars(last) && CREDENTIAL_NAMES.some((name) => globRegex(last).test(name)))
7017
+ return true;
7018
+ return globCouldNameGateHome(path);
7019
+ }
7020
+ /**
7021
+ * APRV-511 (cred-asymmetry M1, round 3): could a read word this hook cannot
7022
+ * expand (a variable, `${X:-default}`, the `$(...)` substitution sentinel, or
7023
+ * a tilde prefix {@link otherTildePrefix} turned into the variable it stands
7024
+ * for) name a credential? It reads the text only. {@link resolvedPathClasses}
7025
+ * also asks it about the word with its literal prefix replaced by each place
7026
+ * that prefix lands (round 3, S1), which is how a relative or symlinked prefix
7027
+ * is covered.
7028
+ *
7029
+ * The model, as in {@link isCredentialPath}: an UNKNOWN segment (one holding a
7030
+ * `$` or a backtick, or a leading `~` the hook did not expand) stands for
7031
+ * exactly one path segment of any name. Within that model it fails closed: it
7032
+ * answers false only when no choice of names makes the word a credential
7033
+ * path. It answers true when the classifier's text rule holds
7034
+ * ({@link isCredentialPath}), when a pattern in it could name a credential
7035
+ * ({@link globCouldNameCredential}), or when, in any of three readings of the
7036
+ * segments (`.` dropped with every `..` kept; `.` dropped with every `..`
7037
+ * resolved lexically, where a `..` after an unknown segment leaves an unknown
7038
+ * segment; and the same with that `..` cancelling the unknown segment, as
7039
+ * the model says it does; see {@link segmentReadings}), any of these holds:
7040
+ *
7041
+ * - the word ENDS in a Hermes credential row, with any unknown segment
7042
+ * standing for any element: a home (`.hermes` in any case, `$HERMES_HOME`)
7043
+ * then a credential file (`.env`, `.env.*`, `.op.env`, `.op.env.*`,
7044
+ * `auth.json`, any case), or a home then `hermes-agent` then the install's
7045
+ * `.env` or `.env.*` (not `.env.example`), each also under a home's
7046
+ * `profiles/<p>`. So an unknown last segment right under a literal home, a
7047
+ * profile home or the `hermes-agent/` of either is a credential, and so are
7048
+ * two unknown segments in a row at the end;
7049
+ * - ANYWHERE, an approval home (`.approval`, exact case) or an unknown segment
7050
+ * is followed by `env`, `env.*`, `vault*`, `keys` or an unknown segment;
7051
+ * - a credential file name appears at or after the first unknown segment
7052
+ * (which may have stood for a home, a profile home or a profiles directory).
7053
+ *
7054
+ * Not covered, by the model: in a single-file read, an unknown segment that
7055
+ * expands to several segments (a `/` or `..` inside it) and so moves the
7056
+ * literal names after it; a recursive reader of any unresolved word is
7057
+ * `account.credential` regardless. `.approval` and its file names in another
7058
+ * case are not matched, as in the classifier's text rule.
7059
+ */
7060
+ function unresolvedReadCouldNameCredential(text) {
7061
+ if (isCredentialPath(text) || globCouldNameCredential(text))
7062
+ return true;
7063
+ return spanReadingsCouldNameCredential(text);
7064
+ }
7065
+ /**
7066
+ * The segment rows of {@link unresolvedReadCouldNameCredential}, in its three
7067
+ * readings, without its pattern test. {@link globCouldNameCredential} calls
7068
+ * this one, so neither test calls back into the other (fix round 5).
7069
+ */
7070
+ function spanReadingsCouldNameCredential(text) {
7071
+ // One segment per braced variable, so a default's `/` (`${H:-$HOME/.hermes}`) splits nothing.
7072
+ const collapsed = text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)[^}]*\}/gu, "${$1}");
7073
+ const segments = collapsed.split(/[/\\]+/u).filter((segment) => segment.length > 0);
7074
+ // A leading `~` this hook did not expand stands for an unknown directory.
7075
+ if (!collapsed.startsWith("/") && segments[0]?.startsWith("~") === true)
7076
+ segments[0] = "$~";
7077
+ return segmentReadings(segments).some(segmentsCouldNameCredential);
7078
+ }
7079
+ /** A path segment holding an unexpanded span. */
7080
+ function unknownSegment(segment) {
7081
+ return /[$`]/u.test(segment);
7082
+ }
7083
+ /**
7084
+ * The three readings {@link unresolvedReadCouldNameCredential} judges: `.`
7085
+ * dropped with `..` kept; `.` dropped with each `..` resolved lexically, where
7086
+ * a `..` after an unknown segment leaves an unknown segment (the span may name
7087
+ * a symlink, so its parent is somewhere unknown too); and the same with that
7088
+ * `..` CANCELLING the unknown segment, as it cancels a literal one, which is
7089
+ * the one-segment model itself (fix round 4, review-b r3 M). Without the
7090
+ * third, the unknown left in place shifted every literal after it one segment
7091
+ * deeper, so a round trip through a span back to a home's `profiles/`, then a
7092
+ * literal profile name, then a span, matched no row. A `..` above the first
7093
+ * segment is kept in every reading.
7094
+ */
7095
+ function segmentReadings(segments) {
7096
+ const dropped = segments.filter((segment) => segment !== ".");
7097
+ const resolve = (unknownParent) => {
7098
+ const resolved = [];
7099
+ for (const segment of dropped) {
7100
+ const last = resolved[resolved.length - 1];
7101
+ if (segment !== "..")
7102
+ resolved.push(segment);
7103
+ else if (last === undefined || last === "..")
7104
+ resolved.push(segment);
7105
+ else if (unknownParent && unknownSegment(last))
7106
+ resolved[resolved.length - 1] = "$..";
7107
+ else
7108
+ resolved.pop();
7109
+ }
7110
+ return resolved;
7111
+ };
7112
+ return [dropped, resolve(true), resolve(false)];
7113
+ }
7114
+ /** One reading's segments against the rows {@link unresolvedReadCouldNameCredential} lists. */
7115
+ function segmentsCouldNameCredential(segments) {
7116
+ const named = (name) => (segment) => unknownSegment(segment) || segment.toLowerCase() === name;
7117
+ const credentialFile = (segment) => {
7118
+ const name = segment.toLowerCase();
7119
+ return name === ".env" || name.startsWith(".env.") || name === ".op.env" || name.startsWith(".op.env.") || name === "auth.json";
7120
+ };
7121
+ const hermesHome = (segment) => unknownSegment(segment) || segment.toLowerCase() === ".hermes";
7122
+ const homeCredential = (segment) => unknownSegment(segment) || credentialFile(segment);
7123
+ const installEnv = (segment) => {
7124
+ const name = segment.toLowerCase();
7125
+ return unknownSegment(segment) || name === ".env" || (name.startsWith(".env.") && segment !== ".env.example");
7126
+ };
7127
+ const anyName = () => true;
7128
+ const approvalHome = (segment) => unknownSegment(segment) || segment === ".approval";
7129
+ const approvalFile = (segment) => unknownSegment(segment) || segment === "env" || segment.startsWith("env.") || segment.startsWith("vault") || segment === "keys";
7130
+ const endsWith = (row) => {
7131
+ const start = segments.length - row.length;
7132
+ return start >= 0 && row.every((element, at) => element(segments[start + at]));
7133
+ };
7134
+ for (const tail of [[homeCredential], [named("hermes-agent"), installEnv]]) {
7135
+ if (endsWith([hermesHome, ...tail]))
7136
+ return true;
7137
+ if (endsWith([hermesHome, named("profiles"), anyName, ...tail]))
7138
+ return true;
7139
+ }
7140
+ for (let index = 0; index + 1 < segments.length; index += 1) {
7141
+ if (approvalHome(segments[index]) && approvalFile(segments[index + 1]))
7142
+ return true;
7143
+ }
7144
+ let afterUnknown = false;
7145
+ for (const segment of segments) {
7146
+ if (afterUnknown && credentialFile(segment))
7147
+ return true;
7148
+ if (unknownSegment(segment))
7149
+ afterUnknown = true;
7150
+ }
7151
+ return false;
7152
+ }
7153
+ /**
7154
+ * APRV-524: is the unexpanded span in the LAST component of the word? `rest`
7155
+ * is {@link unresolvedPrefix}'s, which starts at the span's component. Split
7156
+ * on `/` alone, the kernel's separator (a backslash is a character of a name),
7157
+ * with empty and `.` components dropped, so a trailing `/` or `/.` still
7158
+ * leaves the span last. The stricter reading on every count.
7159
+ */
7160
+ function lastComponentUnresolved(rest) {
7161
+ const components = rest.split("/").filter((component) => component.length > 0 && component !== ".");
7162
+ return components.length === 1 && /[$`]/u.test(components[0]);
7163
+ }
7164
+ /**
7165
+ * APRV-511 (cred-asymmetry round 3, S1): an unresolved word split at the last
7166
+ * `/` before its first unexpanded span: the literal directory the span sits
7167
+ * in (`.` when the word names none) and the rest, from the span's segment on.
7168
+ */
7169
+ function unresolvedPrefix(text) {
7170
+ const collapsed = text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)[^}]*\}/gu, "${$1}");
7171
+ const span = collapsed.search(/[$`]/u);
7172
+ const cut = span < 0 ? collapsed.lastIndexOf("/") : collapsed.lastIndexOf("/", span);
7173
+ if (cut < 0)
7174
+ return { prefix: ".", rest: collapsed };
7175
+ return { prefix: cut === 0 ? "/" : collapsed.slice(0, cut), rest: collapsed.slice(cut + 1) };
7176
+ }
7177
+ /**
7178
+ * The spellings an unresolved word's rest is judged in once its prefix has
7179
+ * landed (round 3, S1): under the landing itself, and under `$HERMES_HOME`
7180
+ * when the landing is the configured home or below it (a home whose directory
7181
+ * is not named `.hermes`).
7182
+ */
7183
+ function landedSpellings(landed, rest, configuredHome) {
7184
+ const spellings = [`${landed.replace(/\/+$/u, "")}/${rest}`];
7185
+ if (configuredHome !== null && (landed === configuredHome || landed.startsWith(`${configuredHome}/`))) {
7186
+ spellings.push(`$HERMES_HOME${landed.slice(configuredHome.length)}/${rest}`);
7187
+ }
7188
+ return spellings;
7189
+ }
7190
+ /**
7191
+ * APRV-511 (cred-asymmetry round 3, S2): bash's tilde prefixes other than a
7192
+ * bare `~` (which the caller expands to `HOME`) and `~+` (which
7193
+ * {@link tildeReadings} answers). `~-` is `$OLDPWD`, `~N`, `~+N` and `~-N` are
7194
+ * entries of the directory stack, and `~name` is that user's home. None of
7195
+ * those is known here, so each becomes the variable it stands for and the word
7196
+ * is unresolved.
7197
+ */
7198
+ function otherTildePrefix(text) {
7199
+ const match = /^~([^/]*)(?=\/|$)/u.exec(text);
7200
+ if (match === null)
7201
+ return text;
7202
+ const name = match[1];
7203
+ const rest = text.slice(match[0].length);
7204
+ if (name === "" || name === "+")
7205
+ return text;
7206
+ if (name === "-")
7207
+ return `$OLDPWD${rest}`;
7208
+ if (/^[+-]?\d+$/u.test(name))
7209
+ return `$DIRSTACK${rest}`;
7210
+ return `$TILDE_HOME${rest}`;
7211
+ }
7212
+ /**
7213
+ * The words a path word is judged as (round 3, S2). Unquoted, bash expands
7214
+ * `~+` to `$PWD`, which is spelled `.` here so it is judged from each working
7215
+ * directory; quoted, it is a literal directory named `~+`, and the lexer has
7216
+ * already removed the quotes. So a word that opens with `~+` is judged both
7217
+ * ways, and the stricter class wins.
7218
+ */
7219
+ function tildeReadings(word) {
7220
+ return /^~\+(?=\/|$)/u.test(word) ? [word, `.${word.slice(2)}`] : [word];
7221
+ }
7222
+ const GREP_WALK = {
7223
+ recursiveShort: "rR",
7224
+ recursiveLong: ["--recursive", "--dereference-recursive"],
7225
+ patternOperand: true,
7226
+ noPatternShort: "ef",
7227
+ noPatternLong: ["--regexp", "--file"],
7228
+ valueShort: "ABCDdefm",
7229
+ flagShort: "abcEFGHhIiLlnoPqRrsTUuVvwxZz",
7230
+ flagLong: [
7231
+ "--ignore-case", "--line-number", "--files-with-matches", "--files-without-match", "--count", "--invert-match",
7232
+ "--word-regexp", "--line-regexp", "--fixed-strings", "--extended-regexp", "--perl-regexp", "--basic-regexp",
7233
+ "--only-matching", "--with-filename", "--no-filename", "--quiet", "--silent", "--no-messages", "--null", "--text",
7234
+ ],
7235
+ };
7236
+ const WALK_SPECS = new Map([
7237
+ ["grep", GREP_WALK],
7238
+ ["egrep", GREP_WALK],
7239
+ ["fgrep", GREP_WALK],
7240
+ [
7241
+ "rg",
7242
+ {
7243
+ recursiveShort: null,
7244
+ recursiveLong: [],
7245
+ patternOperand: true,
7246
+ noPatternShort: "ef",
7247
+ noPatternLong: ["--regexp", "--file", "--files", "--type-list"],
7248
+ // Fix round 4 (review-b r3 S1): `d`, ripgrep's maximum-depth option.
7249
+ valueShort: "ABCdEefgjMmrTt",
7250
+ flagShort: "abcFHhIiLlNnopqSsUuVvwxz0",
7251
+ flagLong: [
7252
+ "--files", "--type-list", "--hidden", "--no-ignore", "--ignore-case", "--smart-case", "--line-number",
7253
+ "--follow", "--fixed-strings", "--files-with-matches", "--files-without-match", "--count", "--no-heading",
7254
+ "--with-filename", "--no-filename", "--json", "--text", "--word-regexp", "--invert-match", "--multiline",
7255
+ "--only-matching", "--vimgrep", "--null", "--unrestricted", "--quiet",
7256
+ ],
7257
+ },
7258
+ ],
7259
+ [
7260
+ "du",
7261
+ {
7262
+ recursiveShort: null,
7263
+ recursiveLong: [],
7264
+ patternOperand: false,
7265
+ noPatternShort: "",
7266
+ noPatternLong: [],
7267
+ valueShort: "BdtX",
7268
+ flagShort: "aAbcghHklLmnPsSx0",
7269
+ flagLong: ["--all", "--summarize", "--human-readable", "--apparent-size", "--bytes", "--total", "--si"],
7270
+ },
7271
+ ],
7272
+ [
7273
+ "tree",
7274
+ {
7275
+ recursiveShort: null,
7276
+ recursiveLong: [],
7277
+ patternOperand: false,
7278
+ noPatternShort: "",
7279
+ noPatternLong: [],
7280
+ valueShort: "LPIoHT",
7281
+ flagShort: "aAcCdDfFghiJlNnpqQrsStuUvx",
7282
+ flagLong: ["--dirsfirst", "--noreport", "--prune", "--du", "--si"],
7283
+ },
7284
+ ],
7285
+ ]);
7286
+ /**
7287
+ * APRV-511 (cred-asymmetry round 3, N2): does this reader walk the working
7288
+ * directory because no argument is proven to name a path? `grep` with a
7289
+ * recursive flag, `rg`, `du` and `tree` with no path operand, and `find` with
7290
+ * no path before its expression, each walk `.`. Fails closed: an option this
7291
+ * table does not know, long or short (a short cluster holding a letter in
7292
+ * neither of its lists; fix round 4), may take the next word as its value, so
7293
+ * that word is not counted as a path. (`ag`, `ack` and `zip` have no classifier rule and are
7294
+ * refused as unclassified; `tar` with no file operand reads none.)
7295
+ */
7296
+ function walksWorkingDirectory(bin, args) {
7297
+ if (bin === "find") {
7298
+ let index = 0;
7299
+ while (index < args.length) {
7300
+ const arg = args[index];
7301
+ if (arg === "-H" || arg === "-L" || arg === "-P" || /^-O\d*$/u.test(arg))
7302
+ index += 1;
7303
+ else if (arg === "-D")
7304
+ index += 2;
7305
+ else
7306
+ break;
7307
+ }
7308
+ const first = args[index];
7309
+ return first === undefined || first.startsWith("-") || first === "(" || first === "!";
7310
+ }
7311
+ const spec = bin === null ? undefined : WALK_SPECS.get(bin);
7312
+ if (spec === undefined)
7313
+ return false;
7314
+ let recursive = spec.recursiveShort === null;
7315
+ let patternOperand = spec.patternOperand;
7316
+ let words = 0;
7317
+ let endOfOptions = false;
7318
+ for (let index = 0; index < args.length; index += 1) {
7319
+ const arg = args[index];
7320
+ if (endOfOptions || arg === "-" || !arg.startsWith("-")) {
7321
+ words += 1;
7322
+ continue;
7323
+ }
7324
+ if (arg === "--") {
7325
+ endOfOptions = true;
7326
+ continue;
7327
+ }
7328
+ const next = args[index + 1];
7329
+ if (arg.startsWith("--")) {
7330
+ const name = arg.split("=", 1)[0];
7331
+ if (spec.recursiveLong.includes(name))
7332
+ recursive = true;
7333
+ if (spec.noPatternLong.includes(name))
7334
+ patternOperand = false;
7335
+ const flag = spec.flagLong.includes(name) || spec.recursiveLong.includes(name);
7336
+ if (!arg.includes("=") && !flag && next !== undefined && !next.startsWith("-"))
7337
+ index += 1;
7338
+ continue;
7339
+ }
7340
+ let unknownLetter = false;
7341
+ for (let at = 1; at < arg.length; at += 1) {
7342
+ const letter = arg[at];
7343
+ if (spec.recursiveShort?.includes(letter) === true)
7344
+ recursive = true;
7345
+ if (spec.noPatternShort.includes(letter))
7346
+ patternOperand = false;
7347
+ if (spec.valueShort.includes(letter)) {
7348
+ if (at === arg.length - 1 && next !== undefined)
7349
+ index += 1;
7350
+ unknownLetter = false;
7351
+ break;
7352
+ }
7353
+ if (!spec.flagShort.includes(letter))
7354
+ unknownLetter = true;
7355
+ }
7356
+ // Fix round 4 (review-b r3 S1): fails closed for a short option too. A
7357
+ // cluster holding a letter this table does not know may take the next word
7358
+ // as its value, so that word is not counted as a path (as the long branch
7359
+ // above already reads an unknown long option).
7360
+ if (unknownLetter && next !== undefined && !next.startsWith("-"))
7361
+ index += 1;
7362
+ }
7363
+ return recursive && words - (patternOperand ? 1 : 0) <= 0;
5336
7364
  }
5337
7365
  /** The entries a final-component glob matches, or `null` when the directory is unknown. */
5338
7366
  function expandGlob(path) {
@@ -5365,7 +7393,25 @@ function isDirectoryPath(path) {
5365
7393
  * `$HERMES_HOME`.
5366
7394
  */
5367
7395
  function gateRootKind(directory) {
5368
- const segments = directory.split(/[/\\]+/u).filter((segment) => segment.length > 0);
7396
+ // Fix round 2 (path-parser differential): a backslash is a character of a
7397
+ // name on macOS and Linux, so a path holding one is judged on the kernel's
7398
+ // `/`-only split as well (a profile name with a backslash in it).
7399
+ const splittings = [directory.split(/[/\\]+/u)];
7400
+ if (process.platform !== "win32" && directory.includes("\\"))
7401
+ splittings.push(directory.split(/\/+/u));
7402
+ for (const split of splittings) {
7403
+ const kind = gateRootKindOf(split);
7404
+ if (kind !== null)
7405
+ return kind;
7406
+ }
7407
+ return null;
7408
+ }
7409
+ function gateRootKindOf(split) {
7410
+ // APRV-522: case folded, as the credential rule is, for a case-insensitive
7411
+ // volume (`grep -r . .APPROVAL`).
7412
+ const segments = split
7413
+ .filter((segment) => segment.length > 0)
7414
+ .map((segment) => segment.toLowerCase());
5369
7415
  const last = segments[segments.length - 1];
5370
7416
  const isHome = (at) => segments[at] === ".hermes" ||
5371
7417
  (segments[at - 2] === ".hermes" && segments[at - 1] === "profiles" && segments[at] !== undefined);
@@ -5381,9 +7427,23 @@ function gateRootKind(directory) {
5381
7427
  return "scripts";
5382
7428
  return null;
5383
7429
  }
5384
- /** A glob or an unknown name written here may be an organ (SF1b). */
7430
+ /**
7431
+ * A directory every write inside which is `policy.core` (APRV-511 refuter S2):
7432
+ * a `policy.core` directory other than a home root, whose own paths are judged
7433
+ * one by one.
7434
+ */
7435
+ function isOrganDirectory(directory) {
7436
+ const kind = gateRootKind(directory);
7437
+ return kind !== "home" && protectedPathClass(directory) === "policy.core";
7438
+ }
7439
+ /**
7440
+ * A glob or an unknown name written here may be an organ (SF1b). Since
7441
+ * APRV-511 that includes any directory that is itself `policy.core`
7442
+ * (`agent-hooks/`, `hermes-agent/`, `plugins/` and everything under them),
7443
+ * since every name written into one is.
7444
+ */
5385
7445
  function writesIntoGateRoot(directory) {
5386
- return gateRootKind(directory) !== null;
7446
+ return gateRootKind(directory) !== null || protectedPathClass(directory) === "policy.core";
5387
7447
  }
5388
7448
  /**
5389
7449
  * A directory a recursive read of which reads credentials (SF1a): the home
@@ -5396,12 +7456,15 @@ function holdsCredentials(directory) {
5396
7456
  const kind = gateRootKind(directory);
5397
7457
  if (kind === "home" || kind === "approval-home" || kind === "home-approval")
5398
7458
  return true;
5399
- if (!directory.split(/[/\\]+/u).some((segment) => segment === ".hermes" || segment === ".approval"))
7459
+ if (!directory
7460
+ .split(/[/\\]+/u)
7461
+ .some((segment) => [".hermes", ".approval"].includes(segment.toLowerCase())))
5400
7462
  return false;
5401
7463
  return CREDENTIAL_FILE_NAMES.some((name) => existsSync(join(directory, name)));
5402
7464
  }
5403
7465
  const CREDENTIAL_FILE_NAMES = [
5404
7466
  ".env",
7467
+ ".op.env",
5405
7468
  "auth.json",
5406
7469
  "config.yaml",
5407
7470
  "config.yml",
@@ -5434,6 +7497,19 @@ const WRITE_EVERY_OPERAND = new Set([
5434
7497
  const WRITE_LAST_OPERAND = new Set(["cp", "mv", "ln", "install", "rsync"]);
5435
7498
  /** Binaries whose first operand is a mode or owner and the rest are paths. */
5436
7499
  const WRITE_AFTER_FIRST_OPERAND = new Set(["chmod", "chown", "chgrp"]);
7500
+ /**
7501
+ * Binaries whose effect on the file system this pass models, or which have
7502
+ * none beyond their redirections (APRV-511 fix round 3, M(L5c-1)): after any
7503
+ * other non-read segment, a later `..` out of a directory is unknowable.
7504
+ */
7505
+ const EFFECT_MODELED_BINS = new Set([
7506
+ "touch", "truncate", "mkdir", "rm", "rmdir", "shred", "tee", "unlink",
7507
+ "cp", "mv", "ln", "install", "rsync", "chmod", "chown", "chgrp", "sed", "dd", "tar", "unzip", "curl", "wget",
7508
+ "cd", "pushd", "popd", "echo", "printf", "cat", "true", "false", ":", "test", "[", "sleep", "date", "head",
7509
+ "tail", "grep", "egrep", "fgrep", "rg", "sort", "uniq", "wc", "jq", "cut", "tr", "ls", "pwd", "basename",
7510
+ "dirname", "realpath", "readlink", "stat", "file", "diff", "cmp", "seq", "base64", "xxd", "od", "sha256sum",
7511
+ "shasum", "md5sum", "nl", "column", "rev", "tac", "paste", "comm",
7512
+ ]);
5437
7513
  /** Readers that walk a directory they are handed. */
5438
7514
  const RECURSIVE_READERS = new Set([
5439
7515
  "grep", "egrep", "fgrep", "rg", "ag", "ack", "find", "tar", "zip", "du", "tree",
@@ -5459,8 +7535,143 @@ function canonicalPath(path) {
5459
7535
  }
5460
7536
  }
5461
7537
  }
7538
+ /** The symlink hops the kernel follows before it answers ELOOP (Linux and macOS: 40 / 32). */
7539
+ const KERNEL_LINK_HOPS = 40;
7540
+ /**
7541
+ * `from` and `text` as ONE absolute spelling, with every `..` kept (APRV-511
7542
+ * fix round 3, M(L5c-1)). Joined by string, never by `path.resolve` or `join`,
7543
+ * because both cancel `k/..` before anything can ask what `k` is.
7544
+ */
7545
+ function spelledAbsolute(from, text) {
7546
+ return isAbsolute(text) ? text : `${from.replace(/\/+$/u, "")}/${text}`;
7547
+ }
7548
+ /**
7549
+ * Where the KERNEL lands an absolute spelling (APRV-511 fix round 3, M(L5c-1)).
7550
+ *
7551
+ * Node's `realpathSync` (and {@link canonicalPath}, built on it) normalises
7552
+ * `..` textually first, so `k/../.op.env` with `k -> ~/.hermes/skills` is judged
7553
+ * as `./.op.env` while the shell opens `~/.hermes/.op.env`. Here each component
7554
+ * is looked up in order as the kernel does: a symlink is replaced by its
7555
+ * target (a relative one resolved against the link's own directory), and `..`
7556
+ * steps to the parent of what has been RESOLVED so far. Past the first
7557
+ * component that does not exist (or cannot be read), the rest is applied
7558
+ * lexically. `..` after a link to a FILE (where the kernel says ENOTDIR) steps
7559
+ * to the file's directory: an answer for a path the shell refuses to open can
7560
+ * only be stricter than none.
7561
+ *
7562
+ * `null` means the landing is unknowable: a link loop past
7563
+ * {@link KERNEL_LINK_HOPS}, or a `/proc/<pid>/{cwd,root,fd,…}` handle, which
7564
+ * names a directory of the process that RUNS the command (the agent's shell),
7565
+ * not of this hook. With `volatile` (the shell pass), a `..` is unknowable too
7566
+ * when what it climbs out of is something an earlier segment of the same
7567
+ * command writes, links, moves, deletes or extracts into (whether or not it
7568
+ * exists yet), or follows a program whose effects that pass does not model:
7569
+ * `ln -s ~/.hermes/skills k && cat k/../.op.env` plants the link this hook
7570
+ * cannot see, and `..` out of it lands wherever the link will point.
7571
+ */
7572
+ function kernelLanding(spelled, volatile = null) {
7573
+ const queue = spelled.split("/").filter((part) => part.length > 0 && part !== ".");
7574
+ if (queue[0] === "proc" &&
7575
+ queue[1] !== undefined &&
7576
+ /^(?:self|thread-self|\d+)$/u.test(queue[1]) &&
7577
+ (queue[2] === undefined || /^(?:cwd|root|fd|fdinfo|map_files|task|exe)$/u.test(queue[2]))) {
7578
+ return null;
7579
+ }
7580
+ let resolved = "/";
7581
+ let missing = false;
7582
+ let hops = 0;
7583
+ while (queue.length > 0) {
7584
+ const part = queue.shift();
7585
+ if (part === "..") {
7586
+ if (volatile !== null && volatile(resolved))
7587
+ return null;
7588
+ resolved = dirname(resolved);
7589
+ continue;
7590
+ }
7591
+ const next = resolved === "/" ? `/${part}` : `${resolved}/${part}`;
7592
+ if (missing) {
7593
+ resolved = next;
7594
+ continue;
7595
+ }
7596
+ let link = null;
7597
+ try {
7598
+ const stat = lstatSync(next);
7599
+ if (stat.isSymbolicLink())
7600
+ link = readlinkSync(next);
7601
+ }
7602
+ catch {
7603
+ // Absent, unreadable (a 0700 home under co-location), or under a file:
7604
+ // the rest of the spelling is applied lexically.
7605
+ missing = true;
7606
+ resolved = next;
7607
+ continue;
7608
+ }
7609
+ if (link === null) {
7610
+ resolved = next;
7611
+ continue;
7612
+ }
7613
+ hops += 1;
7614
+ if (hops > KERNEL_LINK_HOPS)
7615
+ return null;
7616
+ const parts = link.split("/").filter((piece) => piece.length > 0 && piece !== ".");
7617
+ if (link.startsWith("/"))
7618
+ resolved = "/";
7619
+ queue.unshift(...parts);
7620
+ }
7621
+ return resolved;
7622
+ }
7623
+ /**
7624
+ * Both readings of where an absolute spelling lands (APRV-511 fix round 3,
7625
+ * M(L5c-1)): the LEXICAL one this hook has always judged (`..` cancelled
7626
+ * first, then the realpath of the deepest existing ancestor) and the KERNEL one
7627
+ * ({@link kernelLanding}). Every caller judges each and keeps the stricter
7628
+ * class; `unknown` says the kernel reading could not be placed, which the
7629
+ * caller answers with the stricter class outright.
7630
+ */
7631
+ function landingPaths(spelled, volatile = null) {
7632
+ const lexical = canonicalPath(resolvePathSegments(spelled));
7633
+ const kernel = kernelLanding(spelled, volatile);
7634
+ if (kernel === null)
7635
+ return { paths: [lexical], unknown: true };
7636
+ return { paths: kernel === lexical ? [lexical] : [lexical, kernel], unknown: false };
7637
+ }
5462
7638
  /** The environment variable that turns the sandbox requirement on (APRV-193). */
5463
7639
  export const REQUIRE_SANDBOX_ENV = "APPROVAL_HOOK_REQUIRE_SANDBOX";
7640
+ /**
7641
+ * The fixed remedy a `fetch-output-unknown` refusal carries when a `$`-first
7642
+ * word in curl's or wget's option slot caused it (0.4.7, EDGEOS-BLOCK).
7643
+ *
7644
+ * Core 0.4.6 (APRV-511) holds `curl "${BASE:-https://h}/x"`: the expansion
7645
+ * could be `-o <organ path>`, so the download's output is unknown, the class is
7646
+ * `policy.core`, and a policy that reserves that class denies outright. The
7647
+ * refusal said why and nothing about the shape that runs. The same word as
7648
+ * `--url`'s value sits in a value slot, where curl reads it as the URL whatever
7649
+ * it starts with, and classifies by its method. wget has no `--url`, so the
7650
+ * line names curl for both.
7651
+ *
7652
+ * Text only: the class, the rule and the code are unchanged, and nothing here
7653
+ * widens a verdict.
7654
+ */
7655
+ export const FETCH_DOLLAR_WORD_REMEDY = 'Remedy: a word that starts with `$` stands where curl and wget read options, so its value could turn it into `-o <path>` and the download\'s output cannot be known. If that word is the URL, pass it as the value of `--url` (`curl --url "$VAR/..."`), which keeps the output known; wget has no `--url`, so use `curl --url` instead.';
7656
+ /**
7657
+ * {@link FETCH_DOLLAR_WORD_REMEDY}, prefixed with a space, when a segment that
7658
+ * earned `reserved` did so by rule `fetch-output-unknown` on a word that
7659
+ * starts with `$`; otherwise the empty string, so every other cause of the rule
7660
+ * (an unknown option, a config source, an ambiguous prefix, an unplaceable
7661
+ * output value) keeps its refusal byte for byte.
7662
+ *
7663
+ * `fetchOutputs` reports the first unknowable word verbatim, and only its
7664
+ * option-slot test reports a positional word; every other cause reports the
7665
+ * option as spelled, which starts with `-`. So a bound path starting with `$`
7666
+ * is exactly the `$`-first option-slot cause.
7667
+ */
7668
+ export function fetchOutputRemedy(segments, reserved) {
7669
+ const fired = (segments ?? []).some((segment) => segment.class === reserved &&
7670
+ segment.rule === "fetch-output-unknown" &&
7671
+ segment.path !== undefined &&
7672
+ segment.path.startsWith("$"));
7673
+ return fired ? ` ${FETCH_DOLLAR_WORD_REMEDY}` : "";
7674
+ }
5464
7675
  /**
5465
7676
  * Must this command have been written `approval sandbox -- …`? (APRV-193.)
5466
7677
  *
@@ -5496,7 +7707,10 @@ export function sandboxRequirement(segments, autonomies, env = process.env) {
5496
7707
  return null;
5497
7708
  const first = unwrapped[0];
5498
7709
  const external = first.sandbox === "external";
5499
- return `${REQUIRE_SANDBOX_ENV}=1, and this command runs code the runtime did not author: ${JSON.stringify(first.text)} (rule ${first.rule}), ${external ? "under a profile this runtime did not write, which is a permission you granted yourself" : "with the session's own network"}. A command like this executes whatever is in the files it names, so its class describes what was typed rather than what will happen. Re-run it as \`approval sandbox -- <command>\`: it classifies the same, it is allowed the same, and it runs with no way out to the network (docs/sandboxed-exec.md). Nothing was appended.`;
7710
+ // Refuter S1: an `exec.code` segment is named by its program's hash, never
7711
+ // by its text (the segment text IS the program for an inline form).
7712
+ const named = first.execCode === undefined ? JSON.stringify(first.text) : describeProgram(first.execCode);
7713
+ return `${REQUIRE_SANDBOX_ENV}=1, and this command runs code the runtime did not author: ${named} (rule ${first.rule}), ${external ? "under a profile this runtime did not write, which is a permission you granted yourself" : "with the session's own network"}. A command like this executes whatever is in the files it names, so its class describes what was typed rather than what will happen. Re-run it as \`approval sandbox -- <command>\`: it classifies the same, it is allowed the same, and it runs with no way out to the network (docs/sandboxed-exec.md). Nothing was appended.`;
5500
7714
  }
5501
7715
  /**
5502
7716
  * Is a window open over this log?
@@ -5606,7 +7820,11 @@ decidedOn) {
5606
7820
  // described under harness.tool.unmapped and the bypass records it: a
5607
7821
  // window being open does not let a call run unrecorded.
5608
7822
  unclaimedStanding(scope.options, cwd, input.toolName, decidedOn?.records ?? null).kind ===
5609
- "ungated");
7823
+ "ungated",
7824
+ // APRV-510: the window does not reach `exec.code`. Opening the class is a
7825
+ // policy statement, and the window suspends the policy; an opaque program is
7826
+ // the case this path already names as one nothing here can vouch for.
7827
+ closedExecCodeGate("window"));
5610
7828
  if (described.kind === "deny") {
5611
7829
  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);
5612
7830
  }
@@ -5637,7 +7855,7 @@ decidedOn) {
5637
7855
  }
5638
7856
  const reserved = classes.find((cls) => resolvePolicy(load, cls).autonomy === "human-only");
5639
7857
  if (reserved !== undefined) {
5640
- 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);
7858
+ 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).${fetchOutputRemedy(described.segments, reserved)}`, adapter.kind);
5641
7859
  }
5642
7860
  }
5643
7861
  // APRV-227, resolved here because here is where a record is written. The
@@ -5902,8 +8120,23 @@ function* runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5902
8120
  // appends on this path and no gate lifecycle opens. An OPEN WINDOW does not
5903
8121
  // reach it either, which is the strict reading a window deserves here: a
5904
8122
  // window suspends the policy, and this refusal is not a policy question.
8123
+ //
8124
+ // APRV-510: the ONE exception is a policy declaring both `exec.code` keys
8125
+ // (`custody: attested` and `execute_code: hooked`). That call goes on to be
8126
+ // classified `exec.code` with its program's hash bound; the policy is read
8127
+ // here for this one tool, and a policy that does not load opens nothing.
5905
8128
  if (adapter.kind === "hermes" && input.toolName === HERMES_EXECUTE_CODE_TOOL) {
5906
- return deny(streams, HERMES_EXECUTE_CODE_REFUSAL, `Hermes ${HERMES_EXECUTE_CODE_TOOL} is refused because the call carries a program and nothing else: no path, no argv and no working directory, so no class can be resolved and no payload can bind what it would do. Run the work through the \`${adapter.shellTool}\` tool, where the words are visible to the classifier, or through \`approval run\` with a granted token. No policy or open window authorizes bytes the hook cannot read`, adapter.kind);
8129
+ const early = hookScope(parsed.flags, cwd);
8130
+ const loaded = loadPolicy(early.options.policy?.file === undefined
8131
+ ? { dir: early.options.policy?.dir ?? cwd }
8132
+ : { file: early.options.policy.file });
8133
+ const gate = execGateOf(adapter, loaded.ok ? loaded.policy : null);
8134
+ if (!gate.executeCode) {
8135
+ if (gate.closed === "unattested") {
8136
+ return deny(streams, "hook-exec-code-custody-unattested", `Hermes ${HERMES_EXECUTE_CODE_TOOL} runs a program (exec.code), and this policy names exec.code without declaring \`${EXEC_CODE_CUSTODY_KEY}\`. Opaque code is not run before the operator attests that credential custody is in place, and \`${HERMES_EXECUTE_CODE_TOOL}\` additionally needs \`${EXEC_CODE_EXECUTE_KEY}\`. Nothing was appended`, adapter.kind);
8137
+ }
8138
+ return deny(streams, HERMES_EXECUTE_CODE_REFUSAL, executeCodeRefusal(gate.shell ? "unhooked-attested" : "unhooked", adapter.shellTool), adapter.kind);
8139
+ }
5907
8140
  }
5908
8141
  // APRV-415, and placed here for the reason the two refusals above are: before
5909
8142
  // the open window, the gate-self path, the carry and the registration, because
@@ -6075,7 +8308,7 @@ function* decideHarnessSteps(decide) {
6075
8308
  // both paths since APRV-214 (see `describeToolCall`): the open window
6076
8309
  // classifies exactly as the closed one does, and a second copy of this would
6077
8310
  // be a second answer to "what is this command".
6078
- const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots, load.policy);
8311
+ const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots, load.policy, true, execGateOf(adapter, load.policy));
6079
8312
  if (described.kind === "deny") {
6080
8313
  return { permission: "deny", code: described.code, detail: described.detail };
6081
8314
  }
@@ -6206,7 +8439,7 @@ function* decideHarnessSteps(decide) {
6206
8439
  return {
6207
8440
  permission: "deny",
6208
8441
  code: "hook-class-human-only",
6209
- detail: `${humanOnlyRefusal(reserved, "this command may not run under an agent")} The gate's own code for this fact is \`class-human-only\`.`,
8442
+ detail: `${humanOnlyRefusal(reserved, "this command may not run under an agent")} The gate's own code for this fact is \`class-human-only\`.${fetchOutputRemedy(described.segments, reserved)}`,
6210
8443
  };
6211
8444
  }
6212
8445
  // APRV-193, and BELOW the human-only deny for the same reason that one sits