approval-md 0.4.5 → 0.4.6

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 (44) hide show
  1. package/SPEC.md +13 -2
  2. package/dist/src/channels/telegram.d.ts +105 -31
  3. package/dist/src/channels/telegram.js +157 -81
  4. package/dist/src/channels/telegram.js.map +1 -1
  5. package/dist/src/cli/channel-telegram.d.ts +100 -35
  6. package/dist/src/cli/channel-telegram.js +216 -119
  7. package/dist/src/cli/channel-telegram.js.map +1 -1
  8. package/dist/src/cli/doctor.js +33 -13
  9. package/dist/src/cli/doctor.js.map +1 -1
  10. package/dist/src/cli/help.js +3 -3
  11. package/dist/src/cli/hook-exec-code.d.ts +150 -0
  12. package/dist/src/cli/hook-exec-code.js +357 -0
  13. package/dist/src/cli/hook-exec-code.js.map +1 -0
  14. package/dist/src/cli/hook.d.ts +125 -1
  15. package/dist/src/cli/hook.js +2177 -107
  16. package/dist/src/cli/hook.js.map +1 -1
  17. package/dist/src/cli/wordmark.d.ts +2 -2
  18. package/dist/src/cli/wordmark.js +2 -2
  19. package/dist/src/core/command-class.d.ts +349 -1
  20. package/dist/src/core/command-class.js +2347 -87
  21. package/dist/src/core/command-class.js.map +1 -1
  22. package/dist/src/core/exec-code.d.ts +77 -0
  23. package/dist/src/core/exec-code.js +60 -0
  24. package/dist/src/core/exec-code.js.map +1 -0
  25. package/dist/src/core/git-push-argv.d.ts +111 -0
  26. package/dist/src/core/git-push-argv.js +375 -0
  27. package/dist/src/core/git-push-argv.js.map +1 -0
  28. package/dist/src/core/harness-version.d.ts +71 -30
  29. package/dist/src/core/harness-version.js +101 -34
  30. package/dist/src/core/harness-version.js.map +1 -1
  31. package/dist/src/core/policy-load.d.ts +8 -0
  32. package/dist/src/core/policy-load.js.map +1 -1
  33. package/dist/src/daemon/git-evidence.d.ts +2 -2
  34. package/dist/src/daemon/git-evidence.js +1 -1
  35. package/docs/README-extended.md +3 -2
  36. package/docs/cli-reference.md +20 -7
  37. package/package.json +2 -2
  38. package/schema/event.schema.json +1 -1
  39. package/schema/fixtures/policy/invalid/exec-code-custody-boolean.json +11 -0
  40. package/schema/fixtures/policy/invalid/exec-code-execute-code-value.json +11 -0
  41. package/schema/fixtures/policy/invalid/exec-code-unknown-key.json +12 -0
  42. package/schema/fixtures/policy/valid/exec-code-both.json +17 -0
  43. package/schema/fixtures/policy/valid/exec-code-custody.json +11 -0
  44. 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,917 @@ 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
+ * Every existing path a glob matches, any component of it a pattern
2414
+ * (APRV-522), or `null` when a directory on the way cannot be listed or the
2415
+ * matches run past 256, so the caller falls back to asking whether the
2416
+ * pattern could name a credential at all. Over-matches rather than under:
2417
+ * `*` matches a leading dot here, which the shell's glob does not.
2418
+ */
2419
+ function expandGlobPath(path) {
2420
+ // Fix round 2 (path-parser differential): the shell's glob splits on `/`
2421
+ // alone and reads a backslash as an escape, so a pattern holding one is not
2422
+ // listed here (the caller judges whether it could name a credential).
2423
+ if (process.platform !== "win32" && path.includes("\\"))
2424
+ return null;
2425
+ const components = path.split(KERNEL_SEPARATORS).filter((component) => component.length > 0);
2426
+ let current = [sep];
2427
+ for (const component of components) {
2428
+ const next = [];
2429
+ for (const base of current) {
2430
+ if (component === "..") {
2431
+ next.push(dirname(base));
2432
+ continue;
2433
+ }
2434
+ if (!hasGlobChars(component)) {
2435
+ next.push(join(base, component));
2436
+ continue;
2437
+ }
2438
+ let names;
2439
+ try {
2440
+ names = readdirSync(base);
2441
+ }
2442
+ catch {
2443
+ if (!existsSync(base))
2444
+ continue;
2445
+ return null;
2446
+ }
2447
+ const pattern = globRegex(component);
2448
+ for (const name of names)
2449
+ if (pattern.test(name))
2450
+ next.push(join(base, name));
2451
+ }
2452
+ if (next.length > 256)
2453
+ return null;
2454
+ current = next;
2455
+ }
2456
+ return current.filter((candidate) => existsSync(candidate));
2457
+ }
2458
+ /** Binaries that copy a directory they are handed, contents and all. */
2459
+ const DIRECTORY_COPIERS = new Set(["cp", "rsync", "scp"]);
2460
+ /**
2461
+ * Every credential file a command reads once its paths are resolved the way
2462
+ * the shell and the filesystem will resolve them (APRV-522, M4 and M5).
2463
+ *
2464
+ * The pure classifier judges the words as written. A word written relative
2465
+ * after a `cd`, through a symlinked parent, as a glob, or inside a `$(…)` does
2466
+ * not carry the segments the credential rule matches, so it was `read.shell`
2467
+ * through the Claude Code hook (a relative read after a `cd` into the approval
2468
+ * home, a read through a symlinked alias of it, a glob or `<` target naming a
2469
+ * file in it), and through the Hermes hook too when the reader's segment also
2470
+ * wrote, or the reader sat inside a substitution.
2471
+ *
2472
+ * Every adapter and `hook classify` reach this through {@link classifyForHook}.
2473
+ * What is judged, per segment, whatever class the segment took:
2474
+ *
2475
+ * - the words {@link credentialReadWords} says the binary reads (the pure
2476
+ * rule's own list: positionals, option-embedded values, git `<rev>:<path>`),
2477
+ * and every `<` target;
2478
+ * - each word expanded ({@link expandHookWord}, brace lists included), then
2479
+ * landed from every directory the command may be in: lexically
2480
+ * ({@link canonicalPath}) and physically ({@link physicalLanding}), a glob
2481
+ * expanded against its directory (or, where that is unknown, judged by
2482
+ * whether its pattern could name a credential);
2483
+ * - a directory handed to a recursive reader or a directory copier, when it
2484
+ * {@link holdsCredentials}. git and diff count as recursive readers here
2485
+ * (fix round 1, S): git's no-index grep and diff walk a directory, and diff
2486
+ * compares a directory's files. A recursive search also reads the working
2487
+ * directory (fix round 1, M-E), whether or not it names a path, because
2488
+ * which of its words are option values cannot be known for every reader;
2489
+ * - a one-operand link or copy keeps its only operand as a read, because the
2490
+ * destination is the working directory (fix round 1, M-F);
2491
+ * - `cd` and `pushd` move the directory for later segments, the directories
2492
+ * before and after both kept (a `cd` may fail, or sit behind `||`), each as
2493
+ * spelled and with its symlinks resolved (fix round 2). After a `cd` this
2494
+ * hook cannot resolve, every later RELATIVE read word is a credential read:
2495
+ * where it lands is unknown, and it may be the vault. A `cd` is
2496
+ * unresolvable when its operand is a variable, `-`, a glob or a brace list,
2497
+ * or a tilde prefix this hook does not expand; when it carries an
2498
+ * assignment prefix; and in the argv shapes {@link cdTarget} lists. `popd`
2499
+ * is always unresolvable. (A segment that reassigns `HOME` or `CDPATH` on
2500
+ * its own, `export`, `unset` or `read` of either included, is refused by the
2501
+ * classifier before this pass runs.)
2502
+ * - a relative operand that does not open with `./` or `../` may be found
2503
+ * through a `CDPATH` this hook cannot see (fix round 1, M-B). Since fix
2504
+ * round 2 that does not make the directory wholly unknown: the `cd` also
2505
+ * lands at `<unknown prefix>/<operand>`, and each later relative word is a
2506
+ * credential read there only when {@link steeredReadCouldNameCredential}
2507
+ * says that path could name credential material or a gate home. A
2508
+ * recursive reader, a word holding `..` and a `**` glob are credential
2509
+ * reads there, since what they reach below an unknown directory cannot be
2510
+ * bounded.
2511
+ * - the body of every `$(…)`, and the script of every login-shell wrapper the
2512
+ * classifier unwraps (fix round 1, M-A), walked the same way from the
2513
+ * directory it runs in. A known shell handed an inline script in any other
2514
+ * shape is a credential read, since what it reads cannot be judged;
2515
+ * - past {@link BRACE_WORD_CAP} brace words, or {@link SUBSTITUTION_DEPTH_CAP}
2516
+ * levels of nesting, a credential read (fix round 1, M-D).
2517
+ *
2518
+ * A word opening with a tilde prefix this hook does not expand (`~+`, `~-`,
2519
+ * `~name`) is a credential read (fix round 1, M-C). A relative word read after an
2520
+ * unresolvable `cd` is a credential read even when it holds a `$` this hook
2521
+ * cannot expand (fix round 1, S-2). Otherwise a word left with such a `$` (a
2522
+ * variable, or the substitution sentinel a `$(…)` leaves) is judged here for
2523
+ * every adapter (APRV-524): by its text, by its literal prefix landed from
2524
+ * each working directory, and, when its last component is the unresolved
2525
+ * one, as a read of whatever that prefix holds, so a home or a directory that
2526
+ * holds credentials makes it a credential read. A word holding the sentinel is
2527
+ * judged once more with every sentinel removed, as a resolved path (the
2528
+ * reading before APRV-511), so an empty substitution is judged too. The Hermes
2529
+ * disk pass also fails closed on every recursive reader of such a word; this
2530
+ * pass does not. Beyond that, a path held in a variable (which may be empty)
2531
+ * or produced by a substitution's output is the accepted limit.
2532
+ *
2533
+ * `cwd` may name several directories (fix round 1, S-1): every one is a place
2534
+ * the command may start in, and each is judged.
2535
+ */
2536
+ export function credentialReadLandings(command, cwd, env = process.env) {
2537
+ const found = [];
2538
+ const record = (segment, path) => {
2539
+ if (!found.some((entry) => entry.segment === segment && entry.path === path)) {
2540
+ found.push({ segment, path });
2541
+ }
2542
+ };
2543
+ // `substituted`: the text is a `$(…)` body, whose output becomes words of the
2544
+ // command around it, so a printed word is as good as a piped one.
2545
+ // Fix round 2: the basenames of a configured Hermes home, for the steered rule.
2546
+ const hermesNames = configuredHermesNames(env);
2547
+ // APRV-524: the configured home, for an unresolved word whose prefix lands in
2548
+ // a home not named `.hermes` (as {@link resolvedPathClasses} computes it).
2549
+ const configuredHermesHome = env["APPROVAL_HERMES_HOME"] ?? env["HERMES_HOME"] ?? null;
2550
+ const configuredHome = configuredHermesHome !== null && configuredHermesHome.length > 0 ? canonicalPath(configuredHermesHome) : null;
2551
+ const walk = (text, startCwds, startUnknown, startSteered, depth, substituted) => {
2552
+ // M-D: past the cap the body is not walked, so what it reads is unknown.
2553
+ if (depth > SUBSTITUTION_DEPTH_CAP) {
2554
+ record(text, text);
2555
+ return;
2556
+ }
2557
+ const shapes = commandSegmentShapes(text);
2558
+ if (shapes === null) {
2559
+ // The whole line is refused before this pass runs; a nested body the
2560
+ // lexer cannot read is a read this pass cannot judge.
2561
+ if (depth > 0)
2562
+ record(text, text);
2563
+ return;
2564
+ }
2565
+ let cwds = [...startCwds];
2566
+ let cwdUnknown = startUnknown;
2567
+ // Fix round 2 (the narrowed M-B rule): the directories a `CDPATH` lookup
2568
+ // may have chosen, each as the segments known below an unknown prefix.
2569
+ let steered = [...startSteered];
2570
+ for (const [index, shape] of shapes.entries()) {
2571
+ const feedsPipe = shapes[index + 1]?.stdinFed === true;
2572
+ for (const inner of shape.substitutions)
2573
+ walk(inner, cwds, cwdUnknown, steered, depth + 1, true);
2574
+ // M-A: a login-shell wrapper runs its script in a child shell, from the
2575
+ // directory the wrapper runs in; its `cd`s stay inside it.
2576
+ if (shape.script !== null) {
2577
+ walk(shape.script, cwds, cwdUnknown, steered, depth + 1, substituted || feedsPipe);
2578
+ continue;
2579
+ }
2580
+ if (shape.opaqueShell) {
2581
+ record(shape.text, shape.text);
2582
+ continue;
2583
+ }
2584
+ const bin = shape.bin === null ? null : basename(shape.bin);
2585
+ if (bin === "cd" || bin === "pushd") {
2586
+ // Fix round 2: the argv read as bash and zsh read it ({@link cdTarget});
2587
+ // an assignment prefix (M-B) is one of its unknown shapes.
2588
+ const target = cdTarget(bin, shape.args, shape.assignments);
2589
+ const home = env["HOME"] ?? null;
2590
+ if (target.kind === "unknown") {
2591
+ cwdUnknown = true;
2592
+ }
2593
+ else if (target.kind === "home") {
2594
+ if (home !== null && home.length > 0) {
2595
+ cwds = [...new Set([...cwds, home, canonicalPath(home)])];
2596
+ }
2597
+ else {
2598
+ cwdUnknown = true;
2599
+ }
2600
+ }
2601
+ else {
2602
+ const expanded = expandHookWord(target.word, env);
2603
+ if (expanded.unresolved || hasGlobChars(expanded.text) || expanded.text.includes("{")) {
2604
+ cwdUnknown = true;
2605
+ }
2606
+ else {
2607
+ // The shell's logical landing (`..` against the directory as
2608
+ // spelled, fix round 2), the same with symlinks resolved after it,
2609
+ // and the kernel's physical landing: all three are judged.
2610
+ const next = cwds.flatMap((from) => {
2611
+ const lexical = resolvePathSegments(from, expanded.text);
2612
+ return [lexical, canonicalPath(lexical), physicalLanding(from, expanded.text)];
2613
+ });
2614
+ cwds = [...new Set([...cwds, ...next])];
2615
+ // M-B, narrowed in fix round 2: a relative operand not opening with
2616
+ // `./` or `../` may be found through a `CDPATH` this hook cannot
2617
+ // see, so it may also land at `<unknown prefix>/<operand>`; later
2618
+ // relative words are judged there by {@link steeredReadCouldNameCredential}.
2619
+ // A `cd` below such a directory extends it. A `..` under an
2620
+ // unknown prefix, or more such directories than the cap, leaves
2621
+ // the directory wholly unknown.
2622
+ const steerable = !cdOperandLandsAsSpelled(expanded.text);
2623
+ if (!isAbsolute(expanded.text) && (steerable || steered.length > 0)) {
2624
+ const operand = steeredSegments(expanded.text);
2625
+ if (operand === null) {
2626
+ cwdUnknown = true;
2627
+ }
2628
+ else {
2629
+ const next = [...steered];
2630
+ for (const segments of operand) {
2631
+ for (const tail of steered)
2632
+ next.push([...tail, ...segments]);
2633
+ if (steerable)
2634
+ next.push(segments);
2635
+ }
2636
+ const seen = new Set();
2637
+ steered = next.filter((tail) => {
2638
+ const key = tail.join("/");
2639
+ if (seen.has(key))
2640
+ return false;
2641
+ seen.add(key);
2642
+ return true;
2643
+ });
2644
+ if (steered.length > STEERED_TAIL_CAP)
2645
+ cwdUnknown = true;
2646
+ }
2647
+ }
2648
+ }
2649
+ }
2650
+ continue;
2651
+ }
2652
+ if (bin === "popd") {
2653
+ cwdUnknown = true;
2654
+ continue;
2655
+ }
2656
+ const hit = (path) => record(shape.text, path);
2657
+ const lands = (path, recursive) => {
2658
+ const canonical = canonicalPath(path);
2659
+ return (isCredentialPath(path) ||
2660
+ isCredentialPath(canonical) ||
2661
+ (recursive && (holdsCredentials(canonical) || holdsCredentials(path))));
2662
+ };
2663
+ // Fix round 2 (the narrowed M-B rule): is a relative word read under a
2664
+ // directory a `CDPATH` lookup may have chosen a credential read? Every
2665
+ // recursive reader is (a recursive read of an unknown directory could
2666
+ // reach anything), and so is a word holding `..` or a recursive `**`.
2667
+ const steeredRead = (text, recursive) => {
2668
+ if (recursive || text.includes("**"))
2669
+ return true;
2670
+ const words = steeredSegments(text);
2671
+ if (words === null)
2672
+ return true;
2673
+ return steered.some((tail) => words.some((segments) => steeredReadCouldNameCredential(tail, segments, hermesNames)));
2674
+ };
2675
+ // `steeredToo` is false only for git's verb word, which git never opens.
2676
+ const judgeOne = (word, recursive, steeredToo = true) => {
2677
+ // `$PWD` is the directory the segment runs in, which is each of `cwds`.
2678
+ const pwdRef = /^\$(?:\{PWD\}|PWD)(?=\/|$)/u;
2679
+ if (pwdRef.test(word)) {
2680
+ if (cwdUnknown) {
2681
+ hit(word);
2682
+ return;
2683
+ }
2684
+ for (const from of cwds)
2685
+ judgeOne(word.replace(pwdRef, from), recursive, steeredToo);
2686
+ if (steeredToo && steered.length > 0 && steeredRead(word.replace(pwdRef, "."), recursive))
2687
+ hit(word);
2688
+ return;
2689
+ }
2690
+ const target = expandHookWord(word, env);
2691
+ if (target.hermesUnknown) {
2692
+ // A Hermes home this process cannot name is judged as the home it
2693
+ // spells: its credential files, and the home itself to a recursive
2694
+ // reader.
2695
+ const spelled = target.text.replace(/^\$(?:\{HERMES_HOME\}|HERMES_HOME)/u, "/.hermes");
2696
+ const kind = gateRootKind(spelled);
2697
+ if (isCredentialPath(spelled) || (recursive && kind !== null && kind !== "scripts")) {
2698
+ hit(word);
2699
+ return;
2700
+ }
2701
+ // APRV-524, the same judgement as an unresolved word below: the
2702
+ // unknown home is one more unknown segment to the text rule, and an
2703
+ // unresolved LAST component directly in the home, the approval home
2704
+ // or a home's `approval/` reads whatever that directory holds.
2705
+ const { prefix, rest } = unresolvedPrefix(spelled);
2706
+ const holder = gateRootKind(prefix);
2707
+ if (unresolvedReadCouldNameCredential(target.text) ||
2708
+ (lastComponentUnresolved(rest) && holder !== null && holder !== "scripts")) {
2709
+ hit(word);
2710
+ }
2711
+ return;
2712
+ }
2713
+ // M-C: a tilde prefix this hook does not expand lands somewhere unknown.
2714
+ if (target.landingUnknown) {
2715
+ hit(word);
2716
+ return;
2717
+ }
2718
+ // S-2: the unknown directory first, so an unexpandable relative word
2719
+ // after an unresolvable `cd` fails closed, as the Hermes pass does.
2720
+ if (!isAbsolute(target.text) && cwdUnknown) {
2721
+ hit(word);
2722
+ return;
2723
+ }
2724
+ if (steeredToo && steered.length > 0 && !isAbsolute(target.text) && steeredRead(target.text, recursive)) {
2725
+ hit(word);
2726
+ return;
2727
+ }
2728
+ if (target.unresolved) {
2729
+ // APRV-524. Since APRV-511 a `$(…)` stays in the word as the
2730
+ // substitution sentinel, so a word whose last component is a
2731
+ // substitution is unresolved here, where before it was read as the
2732
+ // directory the substitution sits in (and a directory reader of a
2733
+ // home took this class from that). The Hermes disk pass judges such
2734
+ // a word again, but only for an adapter that states a per-call
2735
+ // directory; this pass runs for every adapter, so the judgement is
2736
+ // made here too. It fails closed: the word is a credential read when
2737
+ // its text could name one ({@link unresolvedReadCouldNameCredential}),
2738
+ // when its literal prefix, landed from each working directory in all
2739
+ // three readings, makes it one, or when its LAST component is the
2740
+ // unresolved one and that prefix lands on a credential path or a
2741
+ // directory that holds credentials ({@link holdsCredentials}).
2742
+ if (unresolvedReadCouldNameCredential(target.text)) {
2743
+ hit(word);
2744
+ return;
2745
+ }
2746
+ const { prefix, rest } = unresolvedPrefix(target.text);
2747
+ const lastComponent = lastComponentUnresolved(rest);
2748
+ for (const from of cwds) {
2749
+ const lexical = resolvePathSegments(from, prefix);
2750
+ for (const landed of new Set([lexical, canonicalPath(lexical), physicalLanding(from, prefix)])) {
2751
+ if ((lastComponent && lands(landed, true)) ||
2752
+ landedSpellings(landed, rest, configuredHome).some(unresolvedReadCouldNameCredential)) {
2753
+ hit(word);
2754
+ return;
2755
+ }
2756
+ }
2757
+ }
2758
+ return;
2759
+ }
2760
+ for (const from of cwds) {
2761
+ const lexical = resolvePathSegments(from, target.text);
2762
+ if (hasGlobChars(target.text)) {
2763
+ // Fix round 2 (path-parser differential): zsh's `**/` (and bash's
2764
+ // under globstar) matches any depth, which the one-level listing
2765
+ // below does not, so the directory it starts from is judged as a
2766
+ // recursive read. A file whose name holds the pattern characters
2767
+ // literally (a quoted word, which the shell does not expand) is
2768
+ // judged as well.
2769
+ const deep = recursiveGlobBase(lexical);
2770
+ if (deep !== null && lands(deep, true))
2771
+ hit(deep);
2772
+ if (lands(lexical, recursive))
2773
+ hit(lexical);
2774
+ const literal = physicalLanding(from, target.text);
2775
+ if (lands(literal, recursive))
2776
+ hit(literal);
2777
+ const candidates = expandGlobPath(lexical);
2778
+ if (candidates === null) {
2779
+ if (globCouldNameCredential(lexical))
2780
+ hit(lexical);
2781
+ }
2782
+ else {
2783
+ for (const candidate of candidates) {
2784
+ if (lands(candidate, recursive))
2785
+ hit(candidate);
2786
+ const physical = physicalLanding(sep, candidate);
2787
+ if (lands(physical, recursive))
2788
+ hit(physical);
2789
+ }
2790
+ }
2791
+ continue;
2792
+ }
2793
+ // The path reported is the resolved one: a start or `cd` kept as
2794
+ // spelled (fix round 2) may hold a symlinked alias.
2795
+ if (lands(lexical, recursive))
2796
+ hit(canonicalPath(lexical));
2797
+ const physical = physicalLanding(from, target.text);
2798
+ if (lands(physical, recursive))
2799
+ hit(physical);
2800
+ }
2801
+ };
2802
+ const judge = (word, recursive, steeredToo = true) => {
2803
+ // APRV-524 (review-b M1): a word holding the substitution sentinel is
2804
+ // judged a second time with every sentinel removed. That is how the
2805
+ // release before APRV-511 read it, its lexer adding nothing for a
2806
+ // `$(…)`: the literal text left, landed, its globs expanded on disk,
2807
+ // and a directory reader of a directory that holds credentials a
2808
+ // credential read. The unresolved branch below models a substitution
2809
+ // as one segment of any name; this reading covers the empty one, so a
2810
+ // substitution in a middle component, one glued to a directory's own
2811
+ // name, and one after a glob that matches such a directory on disk are
2812
+ // judged too. Both judgements only record, so the stricter stands.
2813
+ if (word.includes(SUBSTITUTION_SENTINEL)) {
2814
+ judge(word.split(SUBSTITUTION_SENTINEL).join(""), recursive, steeredToo);
2815
+ }
2816
+ const spellings = new Set();
2817
+ for (const parameter of parameterSpellings(word)) {
2818
+ spellings.add(parameter);
2819
+ const braced = braceWords(parameter);
2820
+ // M-D: a brace list past the cap is a read this pass cannot list.
2821
+ if (braced === null) {
2822
+ hit(word);
2823
+ continue;
2824
+ }
2825
+ for (const spelling of braced)
2826
+ spellings.add(spelling);
2827
+ }
2828
+ for (const spelling of spellings)
2829
+ judgeOne(spelling, recursive, steeredToo);
2830
+ };
2831
+ for (const target of shape.reads)
2832
+ judge(target, false);
2833
+ if (bin === null)
2834
+ continue;
2835
+ // A builtin that never opens its words, unless what it prints becomes
2836
+ // another command's input or words (a pipe into a reader, or the output
2837
+ // of a substitution); see {@link NON_OPENING_BINS}.
2838
+ if (NON_OPENING_BINS.has(bin) && !(PRINTING_BINS.has(bin) && (feedsPipe || substituted)))
2839
+ continue;
2840
+ const recursive = RECURSIVE_READERS.has(bin) || DIRECTORY_COPIERS.has(bin) || DIRECTORY_READING_BINS.has(bin);
2841
+ const words = credentialReadWords(bin, shape.args);
2842
+ // A copy's destination is WRITTEN, and the write half (the protected
2843
+ // scan, the Hermes organ pass) prices it; judged as a read it would make
2844
+ // `cp /tmp/x .` in a home a credential read of the home.
2845
+ if (WRITE_LAST_OPERAND.has(bin)) {
2846
+ const operands = shape.args.filter((arg) => !arg.startsWith("-"));
2847
+ // `-t` names the target directory for the GNU copiers; for rsync it
2848
+ // preserves times and names nothing.
2849
+ const targetDir = bin === "rsync" ? null : flagValue(shape.args, "-t", "--target-directory");
2850
+ // M-F: with one operand and no `-t`, that operand is the SOURCE (a
2851
+ // one-operand `ln` links it into the working directory), so it stays.
2852
+ if (targetDir !== null || operands.length > 1) {
2853
+ const destination = targetDir ?? operands[operands.length - 1];
2854
+ const at = destination === undefined ? -1 : words.indexOf(destination);
2855
+ if (at !== -1)
2856
+ words.splice(at, 1);
2857
+ }
2858
+ }
2859
+ // Fix round 2: git's verb is never a path, so it is not judged under a
2860
+ // directory a `CDPATH` lookup may have chosen (it is still judged from
2861
+ // every known directory, as before).
2862
+ const verbAt = bin === "git" ? gitVerbIndex(shape.args) : null;
2863
+ const verb = verbAt === null ? undefined : shape.args[verbAt];
2864
+ for (const [at, word] of words.entries())
2865
+ judge(word, recursive, !(at === 0 && verb !== undefined && word === verb));
2866
+ // M-E: a recursive search reads the working directory.
2867
+ if (readsWorkingDirectory(bin, shape.args, words))
2868
+ judge(".", true);
2869
+ }
2870
+ };
2871
+ const starts = typeof cwd === "string" ? [cwd] : [...cwd];
2872
+ // Fix round 2 (path-parser differential): each start as given AND with its
2873
+ // symlinks resolved. The shell's `cd ..` climbs the directory as spelled; a
2874
+ // start resolved first would climb from the link's target instead.
2875
+ walk(command, [...new Set(starts.flatMap((start) => (isAbsolute(start) ? [start, canonicalPath(start)] : [canonicalPath(start)])))], false, [], 0, false);
2876
+ return found;
2877
+ }
2878
+ /**
2879
+ * The directory a recursive `**` glob component starts from (APRV-522 fix
2880
+ * round 2), or `null` when the path holds none.
2881
+ */
2882
+ function recursiveGlobBase(path) {
2883
+ const components = path.split(KERNEL_SEPARATORS);
2884
+ const at = components.findIndex((component) => component.includes("**"));
2885
+ if (at === -1)
2886
+ return null;
2887
+ const base = components.slice(0, at).join(sep);
2888
+ return base.length === 0 ? sep : base;
2889
+ }
2890
+ /**
2891
+ * Binaries the credential passes read as recursive although
2892
+ * {@link RECURSIVE_READERS} does not list them (APRV-522 fix round 1, S): git
2893
+ * (its no-index grep and diff modes walk a directory they are handed; every
2894
+ * verb that reads at all is read this way, the closed reading) and diff (a
2895
+ * directory operand compares the directory's files, and `-r` walks it).
2896
+ */
2897
+ const DIRECTORY_READING_BINS = new Set(["git", "diff"]);
2898
+ /** Searches that read the working directory when they name no path (M-E). */
2899
+ const WORKING_DIRECTORY_SEARCHERS = new Set([
2900
+ "grep", "egrep", "fgrep", "rg", "ag", "ack", "find", "tree", "du",
2901
+ ]);
2902
+ /**
2903
+ * Does this reader read the working directory? (APRV-522 fix round 1, M-E.)
2904
+ * A recursive search with no path operand searches the working directory, and
2905
+ * which of its words are option values is not known for every reader, so the
2906
+ * closed reading is that every search does; `git grep` too, which reads the
2907
+ * repository (or, outside one, the directory) it runs in. `words` is what the
2908
+ * credential rule reads of the argv: empty for a git verb that only writes.
2909
+ */
2910
+ function readsWorkingDirectory(bin, args, words) {
2911
+ if (WORKING_DIRECTORY_SEARCHERS.has(bin))
2912
+ return true;
2913
+ return bin === "git" && words.length > 0 && args.includes("grep");
2914
+ }
2915
+ /**
2916
+ * Add an `account.credential` segment for every credential read
2917
+ * {@link credentialReadLandings} finds that the text did not already name
2918
+ * (APRV-522). Only ever adds: the segment's own class stays, and the
2919
+ * credential read rides beside it in the shape the classifier's `also` has.
2920
+ */
2921
+ export function refineCredentialReads(result, command, cwd, env = process.env) {
2922
+ if (!result.ok)
2923
+ return { result, notes: [] };
2924
+ const landings = credentialReadLandings(command, cwd, env);
2925
+ if (landings.length === 0)
2926
+ return { result, notes: [] };
2927
+ const segments = [...result.segments];
2928
+ const notes = [];
2929
+ for (const landing of landings) {
2930
+ if (segments.some((segment) => segment.text === landing.segment && segment.class === "account.credential")) {
2931
+ continue;
2932
+ }
2933
+ segments.push({
2934
+ text: landing.segment,
2935
+ class: "account.credential",
2936
+ rule: CREDENTIAL_RESOLVED_RULE,
2937
+ path: landing.path,
2938
+ });
2939
+ notes.push(`${CREDENTIAL_RESOLVED_RULE}: \`${landing.segment}\` reads ${landing.path}, which is credential material once its path is resolved`);
2940
+ }
2941
+ if (notes.length === 0)
2942
+ return { result, notes };
2943
+ const classes = [...result.classes];
2944
+ if (!classes.includes("account.credential"))
2945
+ classes.push("account.credential");
2946
+ return { result: { ok: true, segments, classes }, notes };
2947
+ }
2948
+ // ===========================================================================
1971
2949
  // Write scope, the disk half (APRV-402)
1972
2950
  // ===========================================================================
1973
2951
  /**
@@ -2439,11 +3417,25 @@ export function refineSkillScripts(result, cwd, protectedPaths) {
2439
3417
  * resolved gate scope wants and what this classifier did before the field
2440
3418
  * existed.
2441
3419
  */
2442
- export function classifyForHook(command, protectedPaths, cwd, readRoots = []) {
3420
+ export function classifyForHook(command, protectedPaths, cwd, readRoots = [],
3421
+ /**
3422
+ * May an opaque program classify `exec.code`? (APRV-510.) `true` only when
3423
+ * the loaded policy opens the class ({@link execCodeGate}); the default is
3424
+ * today's answer, a refusal.
3425
+ */
3426
+ execCode = false,
3427
+ /**
3428
+ * Further directories the command may start in, judged by the credential
3429
+ * pass only (APRV-522 fix round 1, S-1): the envelope's `cwd` where the
3430
+ * harness sends no per-call directory. It only ever adds reads, so a stale
3431
+ * or absent directory costs nothing.
3432
+ */
3433
+ credentialCwds = []) {
2443
3434
  const roots = resolveScratchRoots(cwd);
2444
3435
  const classified = classifyCommand(command, protectedPaths, {
2445
3436
  scratchRoots: roots,
2446
3437
  ...(readRoots.length === 0 ? {} : { readRoots }),
3438
+ ...(execCode ? { execCode: true } : {}),
2447
3439
  });
2448
3440
  const rewritten = refineRewrite(classified, cwd);
2449
3441
  const scratched = refineScratchDelete(rewritten.result, roots);
@@ -2457,14 +3449,18 @@ export function classifyForHook(command, protectedPaths, cwd, readRoots = []) {
2457
3449
  // APRV-502. Touches only the two skill-script rules, which no pass above
2458
3450
  // reads, so its place in the chain changes nothing for any other segment.
2459
3451
  const scripted = refineSkillScripts(written.result, cwd, protectedPaths);
3452
+ // APRV-522. Last, and it only ever adds: every path a segment reads, judged
3453
+ // where the disk lands it, against the same credential rule the text was.
3454
+ const credentialed = refineCredentialReads(scripted.result, command, [cwd, ...credentialCwds]);
2460
3455
  return {
2461
- result: scripted.result,
3456
+ result: credentialed.result,
2462
3457
  notes: [
2463
3458
  ...rewritten.notes,
2464
3459
  ...scratched.notes,
2465
3460
  ...scoped.notes,
2466
3461
  ...written.notes,
2467
3462
  ...scripted.notes,
3463
+ ...credentialed.notes,
2468
3464
  ],
2469
3465
  };
2470
3466
  }
@@ -2536,7 +3532,9 @@ function commandClassify(argv, streams, cwd) {
2536
3532
  // contributes no `read_scope`, so the scope is the built-in roots alone,
2537
3533
  // which is the narrower answer and is what the hook itself would refuse on.
2538
3534
  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")));
3535
+ streams.out(renderClassification(classifyForHook(command, protectedPaths, cwd, readRoots,
3536
+ // APRV-510: the class the policy opens is the class the hook decides.
3537
+ execCodeGate(load.ok ? load.policy : null).shell).result, boolFlag(parsed.flags, "--json")));
2540
3538
  return EXIT_OK;
2541
3539
  }
2542
3540
  function sleepSync(ms) {
@@ -3059,8 +4057,23 @@ function fileToolGate(toolName, toolInput, protectedPaths, cwd) {
3059
4057
  // `.approval/log/` is `log.mutate`, and only the prose-and-configuration
3060
4058
  // surface stays `policy.edit`. Editing through the Edit tool must not be a
3061
4059
  // cheaper way to touch the gate than editing through a shell redirect.
3062
- const surface = protectedPathClass(declared, protectedPaths);
4060
+ const spelled = protectedPathClass(declared, protectedPaths);
3063
4061
  const file = absolute(declared, cwd);
4062
+ // APRV-511: judged where the write LANDS as well as where it is spelled, the
4063
+ // way the read tools and the shell pass already judge a path: through `..`
4064
+ // and the realpath of the deepest existing ancestor, so a symlink from the
4065
+ // workspace into the Hermes home (`ws/p -> ~/.hermes/plugins`) or a climb out
4066
+ // of a directory under it (`~/.hermes/workspace/../plugins/x`) cannot launder
4067
+ // an organ write into a workspace write. Only ever stricter: the landing
4068
+ // path's class replaces the spelled one when it is `log.mutate` or
4069
+ // `policy.core` and outranks it. Fix round 3 (M(L5c-1)): the landing is
4070
+ // taken in both readings, lexical and kernel (`<ws>/p/../plugins/x` with `p`
4071
+ // a symlink lands beside `p`'s TARGET), and an unknowable one is
4072
+ // `policy.core`.
4073
+ const landing = landingPaths(spelledAbsolute(cwd, declared));
4074
+ let surface = landing.unknown ? stricterOrganSurface(spelled, "policy.core") : spelled;
4075
+ for (const path of landing.paths)
4076
+ surface = stricterOrganSurface(surface, protectedPathClass(path, protectedPaths));
3064
4077
  const tier = tierOf(file, cwd);
3065
4078
  const rule = tier.rule;
3066
4079
  const head = { tool: toolName, rule, file };
@@ -3097,6 +4110,16 @@ function fileToolGate(toolName, toolInput, protectedPaths, cwd) {
3097
4110
  summary: summaryFor(tier, toolName, file),
3098
4111
  };
3099
4112
  }
4113
+ /**
4114
+ * The stricter of a file tool's spelled and landing classes (APRV-511): the
4115
+ * landing class wins only when it is a gate organ (`log.mutate`, then
4116
+ * `policy.core`) that outranks the spelled one, so resolving a path can move a
4117
+ * write to a stricter class and never to a looser one.
4118
+ */
4119
+ function stricterOrganSurface(spelled, landed) {
4120
+ const rank = (surface) => surface === "log.mutate" ? 0 : surface === "policy.core" ? 1 : surface === null ? 3 : 2;
4121
+ return rank(landed) <= 1 && rank(landed) < rank(spelled) ? landed : spelled;
4122
+ }
3100
4123
  /**
3101
4124
  * The first entry of a `paths` array that falls outside the read scope, or
3102
4125
  * `null` when every entry is inside it (APRV-350).
@@ -3169,11 +4192,16 @@ function credentialReadGate(toolName, toolInput, cwd) {
3169
4192
  // APRV-445 recheck SF2(e, f): judged through realpath, so a symlinked
3170
4193
  // parent cannot launder the path, and a directory of credentials handed to a
3171
4194
  // read tool (a search, a glob, a grep) is gated as the credentials are.
3172
- const file = canonicalPath(absolute(declared, cwd));
4195
+ // APRV-511 fix round 3 (M(L5c-1)): in both readings, lexical and kernel, so
4196
+ // `<ws>/p/../.op.env` with `p` a symlink into the home is judged where the
4197
+ // read lands; an unknowable landing (`/proc/self/cwd/…`) is a credential read.
4198
+ const landing = landingPaths(spelledAbsolute(cwd, declared));
3173
4199
  // Recheck 3 (SF1a): only a directory that holds credentials (a home root, an
3174
4200
  // approval home, a home's `approval/`, or a directory under a home holding a
3175
4201
  // credential file); `skills/`, `workspace/` and the like are ordinary.
3176
- if (!isCredentialPath(file) && !holdsCredentials(file))
4202
+ const file = landing.paths.find((path) => isCredentialPath(path) || holdsCredentials(path)) ??
4203
+ (landing.unknown ? absolute(declared, cwd) : null);
4204
+ if (file === null)
3177
4205
  return null;
3178
4206
  const input = { ...toolInput };
3179
4207
  delete input["description"];
@@ -4699,7 +5727,37 @@ toolPolicy = null,
4699
5727
  * `harness.tool.unmapped`, so the bypass RECORDS it rather than letting it
4700
5728
  * run unrecorded on the word of a file nobody vouched for.
4701
5729
  */
4702
- ungatedAllowed = true) {
5730
+ ungatedAllowed = true,
5731
+ /**
5732
+ * What the policy opens of `exec.code` (APRV-510). Closed by default, which
5733
+ * is every answer this function gave before the class existed. The closed
5734
+ * path passes {@link execGateOf} the loaded policy; the open window passes
5735
+ * it closed, because opening the class is a policy statement and a window
5736
+ * suspends the policy.
5737
+ */
5738
+ execGate = closedExecCodeGate("silent")) {
5739
+ // APRV-510. A Hermes `execute_code` call, first, because it is claimed by the
5740
+ // rule table below and must never be described by it: the rule table binds
5741
+ // the whole `tool_input`, and this call's input is a program.
5742
+ if (adapter.kind === "hermes" && input.toolName === HERMES_EXECUTE_CODE_TOOL) {
5743
+ if (!execGate.executeCode) {
5744
+ return {
5745
+ kind: "deny",
5746
+ code: HERMES_EXECUTE_CODE_REFUSAL,
5747
+ detail: executeCodeRefusal(execGate.closed === "window" ? "window" : "unhooked"),
5748
+ };
5749
+ }
5750
+ const bound = bindExecuteCode(input.toolInput, payloadHash(input.toolInput));
5751
+ if (!bound.ok)
5752
+ return { kind: "deny", code: bound.code, detail: bound.detail };
5753
+ return {
5754
+ kind: "gated",
5755
+ classes: ["exec.code"],
5756
+ payload: bound.payload,
5757
+ headline: bound.headline,
5758
+ notes: bound.notes,
5759
+ };
5760
+ }
4703
5761
  if (adapter.kind === "codex" && input.toolName === "apply_patch") {
4704
5762
  // APRV-363. The app-server's LEGACY `applyPatchApproval` carries its change
4705
5763
  // as a MAP of path to change, inline on the request, and never as an
@@ -4799,15 +5857,63 @@ ungatedAllowed = true) {
4799
5857
  // hook's own, so a relative path in the command resolves the way the shell
4800
5858
  // will resolve it. For every adapter without a per-call working directory
4801
5859
  // this is `input.cwd` or the hook's own cwd exactly as before.
4802
- const refined = classifyForHook(raw, protectedPaths, shellCwd ?? cwd, readRoots);
5860
+ // Refuter S3. A Hermes `terminal` call with `background` or `pty` set keeps
5861
+ // a process whose stdin a later `process` call writes, outside any hash, so
5862
+ // the call is not the `exec.code` shape: any value of either key but
5863
+ // absent or `false` closes the class for it, and it keeps today's answer.
5864
+ const laterStdin = adapter.kind === "hermes" &&
5865
+ [input.toolInput["background"], input.toolInput["pty"]].some((value) => value !== undefined && value !== false);
5866
+ const callGate = laterStdin ? closedExecCodeGate("silent") : execGate;
5867
+ // APRV-522 fix round 1 (S-1). Where the harness sends no per-call
5868
+ // directory (Claude Code), the classification stays on the hook process's
5869
+ // own cwd as above, and the credential pass ALSO judges from the
5870
+ // envelope's `cwd`: a `cd` made in an earlier Bash call can leave the
5871
+ // session's directory somewhere the hook process is not. It only adds.
5872
+ const credentialCwds = shellCwd === null && isAbsolute(input.cwd) && input.cwd !== cwd ? [input.cwd] : [];
5873
+ const refined = classifyForHook(raw, protectedPaths, shellCwd ?? cwd, readRoots, callGate.shell, credentialCwds);
4803
5874
  const classified = refined.result;
4804
5875
  if (!classified.ok) {
5876
+ // APRV-510. A policy that NAMES `exec.code` without attesting custody is
5877
+ // told which line is missing, where the same command would have been an
5878
+ // `exec.code` segment with it. Every other policy keeps today's refusal,
5879
+ // byte for byte.
5880
+ if (callGate.closed === "unattested" && classified.code !== "unparseable") {
5881
+ const opened = classifyForHook(raw, protectedPaths, shellCwd ?? cwd, readRoots, true, credentialCwds).result;
5882
+ const program = opened.ok
5883
+ ? opened.segments.find((segment) => segment.execCode !== undefined)
5884
+ : undefined;
5885
+ if (program !== undefined) {
5886
+ return {
5887
+ kind: "deny",
5888
+ code: "hook-exec-code-custody-unattested",
5889
+ 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.`,
5890
+ };
5891
+ }
5892
+ }
4805
5893
  return {
4806
5894
  kind: "deny",
4807
5895
  code: `hook-${classified.code}`,
4808
5896
  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
5897
  };
4810
5898
  }
5899
+ // APRV-510: a program segment is bound by its hash and length, never its
5900
+ // text, so the payload is the exec.code shape rather than `{command, cwd}`.
5901
+ // A FILE program is resolved against the directory the harness states
5902
+ // (the per-call key, else the envelope's `cwd`), never the hook process's
5903
+ // own cwd, and gets no file mode when the harness states none (fix round
5904
+ // 2, refuter N8: Claude Code declares no per-call key, and the hook's cwd
5905
+ // is a guess at where its Bash tool runs). Where the adapter declares a
5906
+ // per-call key and the call CARRIES it, that value is the only statement:
5907
+ // a relative or non-string value gets no file mode at all, never the
5908
+ // envelope's `cwd` (fix round 3, refuter S1: Muse runs `workdir: "a"` in
5909
+ // `<cwd>/a`, and the fallback hashed `<cwd>/x.py` while bash ran
5910
+ // `<cwd>/a/x.py`). The envelope's `cwd` stands only when the key is absent.
5911
+ const perCallKeyCarried = adapter.shellCwdKey !== undefined && Object.hasOwn(input.toolInput, adapter.shellCwdKey);
5912
+ const fileWorkdir = shellCwd ?? (perCallKeyCarried ? null : isAbsolute(input.cwd) ? input.cwd : null);
5913
+ const execBound = bindExecCodeCommand(raw, classified.segments, shellCwd ?? cwd, shellCwd ?? input.cwd, protectedPaths, fileWorkdir);
5914
+ if (execBound !== null && !execBound.ok) {
5915
+ return { kind: "deny", code: execBound.code, detail: execBound.detail };
5916
+ }
4811
5917
  const classes = classified.classes.filter((cls) => cls !== GATE_SELF_CLASS);
4812
5918
  // APRV-445 refutation (B2, L10). The classifier matches protected paths by
4813
5919
  // their SEGMENTS, and a relative word carries only the segments it was
@@ -4818,7 +5924,7 @@ ungatedAllowed = true) {
4818
5924
  // lands on an organ takes the organ's class, and a read that lands on a
4819
5925
  // credential takes `account.credential`. It only ever adds a class.
4820
5926
  if (shellCwd !== null) {
4821
- for (const cls of resolvedPathClasses(raw, classified.segments, shellCwd, protectedPaths)) {
5927
+ for (const cls of resolvedPathClasses(raw, classified.segments, shellCwd, protectedPaths, readRoots)) {
4822
5928
  if (!classes.includes(cls))
4823
5929
  classes.push(cls);
4824
5930
  }
@@ -4895,6 +6001,16 @@ ungatedAllowed = true) {
4895
6001
  }
4896
6002
  }
4897
6003
  }
6004
+ if (execBound !== null) {
6005
+ return {
6006
+ kind: "gated",
6007
+ classes,
6008
+ payload: execBound.payload,
6009
+ headline: execBound.headline,
6010
+ notes: [...refined.notes, ...execBound.notes],
6011
+ segments: classified.segments,
6012
+ };
6013
+ }
4898
6014
  return {
4899
6015
  kind: "gated",
4900
6016
  classes,
@@ -5025,6 +6141,17 @@ ungatedAllowed = true) {
5025
6141
  * resolve to a credential take `account.credential`, and so does a directory
5026
6142
  * that holds credentials ({@link holdsCredentials}) handed to a binary that
5027
6143
  * reads directories recursively (`grep -r`, `rg`, `find`, `tar`, ...).
6144
+ * Such a reader of a word this hook cannot expand, or of a pattern it cannot
6145
+ * expand, is `account.credential` outright (APRV-511 cred-asymmetry round
6146
+ * 2): the word may land on, or above, a home. A reader of a recursive kind
6147
+ * that names no path walks the working directory, which is judged as its
6148
+ * operand (round 3, N2). Any other read of such a word is judged by
6149
+ * {@link unresolvedReadCouldNameCredential}, as spelled and with its
6150
+ * literal prefix landed from each working directory (round 3, S1).
6151
+ * The files a `curl`/`wget` upload or body option reads (`-T F`, `-d @F`,
6152
+ * `--post-file F`) are read paths too, and also take the class of a
6153
+ * protected path or organ they land on and, under a read scope, the
6154
+ * read-scope refusal outside it (APRV-511 fix round 5).
5028
6155
  * - After a `cd` this hook cannot resolve, every later relative write is
5029
6156
  * `policy.core` and every later relative read `account.credential`.
5030
6157
  * - `cd` moves the directory for the segments after it. Like the Codex arm, a
@@ -5034,7 +6161,10 @@ ungatedAllowed = true) {
5034
6161
  * so a symlinked parent or working directory is judged by where it lands.
5035
6162
  * - `$HERMES_HOME` / `${HERMES_HOME}` expand to `APPROVAL_HERMES_HOME`, else
5036
6163
  * `HERMES_HOME`, from this hook process's environment; `~`, `$HOME` and
5037
- * `${HOME}` to `HOME`. Under `approval serve` the process is the daemon, so a
6164
+ * `${HOME}` to `HOME`; `~+` to each working directory (and, as a quoted
6165
+ * one, to the literal directory it names), and bash's other tilde prefixes
6166
+ * (`~-`, the directory stack, `~name`) to the unknown variable each stands
6167
+ * for (round 3, S2). Under `approval serve` the process is the daemon, so a
5038
6168
  * hosted operator sets `APPROVAL_HERMES_HOME` there; without it, the
5039
6169
  * conservative rules above apply to the unexpanded spelling.
5040
6170
  *
@@ -5045,39 +6175,76 @@ ungatedAllowed = true) {
5045
6175
  *
5046
6176
  * It only ever adds classes.
5047
6177
  */
5048
- function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.env) {
6178
+ function resolvedPathClasses(raw, segments, cwd, protectedPaths, readRoots = [], env = process.env) {
5049
6179
  const found = [];
5050
6180
  const add = (cls) => {
5051
6181
  if (cls !== null && !found.includes(cls))
5052
6182
  found.push(cls);
5053
6183
  };
5054
- const shapes = commandSegmentShapes(raw) ?? [];
6184
+ // APRV-522 fix round 1 (M-A): a login-shell wrapper is classified by the
6185
+ // segments of its script, spliced in place of the wrapper, so the shapes are
6186
+ // spliced the same way and line up with the classified segments again.
6187
+ const shapes = (commandSegmentShapes(raw) ?? []).flatMap((shape) => shape.script === null ? [shape] : (commandSegmentShapes(shape.script) ?? [shape]));
5055
6188
  // Trusted as given: the operator sets it (under co-location, the control
5056
6189
  // plane sets it from the tenant's home), and nothing the agent writes reaches
5057
6190
  // this process's environment.
5058
6191
  const hermesHome = env["APPROVAL_HERMES_HOME"] ?? env["HERMES_HOME"] ?? null;
5059
6192
  const home = env["HOME"] ?? null;
5060
- /** The word with the variables this hook can know expanded. */
6193
+ const configuredHome = hermesHome !== null && hermesHome.length > 0 ? canonicalPath(hermesHome) : null;
6194
+ /**
6195
+ * The word with the variables this hook can know expanded.
6196
+ *
6197
+ * Merge of APRV-511 and APRV-522: APRV-522's shared expansion (and its
6198
+ * `landingUnknown` for every tilde prefix it does not expand), with
6199
+ * APRV-511's spelling of bash's other tilde prefixes as the variable each
6200
+ * stands for (round 3, S2), so a caller that reads the text sees a variable.
6201
+ */
5061
6202
  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) };
6203
+ const expanded = expandHookWord(word, env);
6204
+ const text = expanded.text.startsWith("~") ? otherTildePrefix(expanded.text) : expanded.text;
6205
+ return { ...expanded, text, unresolved: expanded.unresolved || /[$`]/u.test(text) };
5075
6206
  };
5076
6207
  // After a `cd` this hook cannot resolve (`cd $X`, `cd "$D"`, `cd -`), the
5077
6208
  // directory every later relative path lands in is unknown (recheck 3, SF2).
5078
6209
  let cwdUnknown = false;
5079
- let cwds = [canonicalPath(cwd)];
6210
+ // APRV-511 fix round 3 (M(L5c-1)): what earlier segments of this command
6211
+ // write, link, move or delete (`changed`, the path and everything under it)
6212
+ // and extract into (`extracted`, everything under it). A `..` out of one of
6213
+ // them is unknowable, because by the time it runs it may be a symlink this
6214
+ // hook never saw (`ln -s ~/.hermes/skills k && cat k/../.op.env`).
6215
+ // After a program whose file effects this pass does not model (`git
6216
+ // checkout`, `git clone`, `npm install`, a script), any directory but a
6217
+ // working directory already reached may have become a link, so a `..` out of
6218
+ // one is unknowable too.
6219
+ const changed = [];
6220
+ const extracted = [];
6221
+ let unmodeled = false;
6222
+ const volatile = (path) => changed.some((each) => path === each || path.startsWith(`${each}/`)) ||
6223
+ extracted.some((each) => path.startsWith(`${each}/`)) ||
6224
+ (unmodeled && !landed.includes(path));
6225
+ const segmentChanged = [];
6226
+ const segmentExtracted = [];
6227
+ let segmentUnmodeled = false;
6228
+ const land = (from, text) => landingPaths(spelledAbsolute(from, text), volatile);
6229
+ // APRV-511 fix round 3 (M(L5c-1)): every directory is kept in BOTH readings,
6230
+ // lexical and kernel, so a `cd k/..` through a symlink is followed to where
6231
+ // the shell actually goes as well as to where the text says.
6232
+ const cwdLanding = landingPaths(cwd);
6233
+ // `landed`: the directories reached in a reading this pass placed itself
6234
+ // (APRV-511), which is what `volatile` exempts after an unmodeled program.
6235
+ let landed = cwdLanding.paths;
6236
+ if (cwdLanding.unknown)
6237
+ cwdUnknown = true;
6238
+ // APRV-522 fix round 2 (path-parser differential): the directory as given,
6239
+ // too, so a later `cd ..` climbs it the way the shell's logical `cd` does.
6240
+ // Every path is judged from every entry, so the merge keeps both sides' set.
6241
+ let cwds = [...new Set([...(isAbsolute(cwd) ? [cwd] : []), ...landed])];
5080
6242
  for (const [index, segment] of segments.entries()) {
6243
+ // What the previous segment changed is in place before this one runs.
6244
+ changed.push(...segmentChanged.splice(0));
6245
+ extracted.push(...segmentExtracted.splice(0));
6246
+ if (segmentUnmodeled)
6247
+ unmodeled = true;
5081
6248
  const shape = shapes.length === segments.length
5082
6249
  ? shapes[index]
5083
6250
  : shapes.find((candidate) => candidate.text === segment.text);
@@ -5085,26 +6252,56 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.e
5085
6252
  continue;
5086
6253
  const bin = shape.bin === null ? null : basename(shape.bin);
5087
6254
  const operands = shape.args.filter((arg) => !arg.startsWith("-"));
6255
+ segmentUnmodeled =
6256
+ bin !== null &&
6257
+ !EFFECT_MODELED_BINS.has(bin) &&
6258
+ (!segment.class.startsWith("read.") || segment.class === "read.vcs.remote");
5088
6259
  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)])];
6260
+ // Fix round 2: the argv read as the shell reads it ({@link cdTarget}).
6261
+ // An assignment prefix (fix round 1, M-B), `cd -`, `pushd` with no
6262
+ // operand, zsh's stack and two-operand forms, and an operand opening
6263
+ // with `-` or `+` all leave the directory unknown.
6264
+ const target = cdTarget(bin, shape.args, shape.assignments);
6265
+ if (target.kind === "unknown") {
6266
+ cwdUnknown = true;
6267
+ }
6268
+ else if (target.kind === "home") {
6269
+ // A bare `cd` goes HOME.
6270
+ if (home !== null) {
6271
+ cwds = [...new Set([...cwds, home, canonicalPath(home)])];
6272
+ landed = [...new Set([...landed, canonicalPath(home)])];
5095
6273
  }
5096
6274
  else {
5097
6275
  cwdUnknown = true;
5098
6276
  }
5099
6277
  }
5100
6278
  else {
5101
- const expanded = expand(target);
5102
- if (expanded.unresolved || hasGlobChars(expanded.text)) {
6279
+ // Round 3 (S2): `cd ~+/x` in both its readings, as a path word is.
6280
+ const spellings = tildeReadings(target.word).map((spelling) => expand(spelling));
6281
+ // Fix round 1 (M-B): an inherited CDPATH is NOT assumed here. Every
6282
+ // READ this pass judges is judged again, fail-closed on CDPATH, by
6283
+ // credentialReadLandings (classifyForHook runs it for every adapter);
6284
+ // assuming it for this pass's WRITES would make every later relative
6285
+ // write after `cd sub` `policy.core`. That write-side reading is a
6286
+ // stated limit (docs/hermes-hook.md), not a claim of this pass.
6287
+ if (spellings.some((expanded) => expanded.unresolved || hasGlobChars(expanded.text))) {
5103
6288
  cwdUnknown = true;
5104
6289
  }
5105
6290
  else {
5106
- const next = cwds.map((from) => canonicalPath(resolvePathSegments(from, expanded.text)));
5107
- cwds = [...new Set([...cwds, ...next])];
6291
+ const next = spellings.flatMap((expanded) => cwds.map((from) => land(from, expanded.text)));
6292
+ if (next.some((landing) => landing.unknown))
6293
+ cwdUnknown = true;
6294
+ const reached = next.flatMap((landing) => landing.paths);
6295
+ // A directory this command creates or replaces before the `cd`
6296
+ // runs: a later `..` out of it is unknowable as well.
6297
+ for (const path of reached)
6298
+ if (volatile(path) || !existsSync(path))
6299
+ changed.push(path);
6300
+ // APRV-522 fix round 2: the logical landing as spelled, beside the
6301
+ // lexical and kernel ones, so a later `cd ..` climbs it logically.
6302
+ const logical = spellings.flatMap((expanded) => cwds.map((from) => resolvePathSegments(from, expanded.text)));
6303
+ landed = [...new Set([...landed, ...reached])];
6304
+ cwds = [...new Set([...cwds, ...logical, ...reached])];
5108
6305
  }
5109
6306
  }
5110
6307
  continue;
@@ -5112,8 +6309,19 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.e
5112
6309
  if (bin === null && shape.writes.length === 0 && shape.reads.length === 0)
5113
6310
  continue;
5114
6311
  const judgeWrite = (word) => {
6312
+ // APRV-511 refuter M2: each word bash's brace expansion makes is a target.
6313
+ const braced = braceCandidates(word);
6314
+ if (braced === null) {
6315
+ add("policy.core");
6316
+ return;
6317
+ }
6318
+ for (const each of braced)
6319
+ for (const spelling of tildeReadings(each))
6320
+ judgeWriteWord(spelling);
6321
+ };
6322
+ const judgeWriteWord = (word) => {
5115
6323
  const target = expand(word);
5116
- if (target.hermesUnknown) {
6324
+ if (target.hermesUnknown || target.landingUnknown) {
5117
6325
  add("policy.core");
5118
6326
  return;
5119
6327
  }
@@ -5121,20 +6329,39 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.e
5121
6329
  add("policy.core");
5122
6330
  return;
5123
6331
  }
6332
+ // APRV-511 (the CUSTODY recheck's M1'): a variable this hook cannot
6333
+ // expand that decides the DIRECTORY (`>> "$X/.env"`, `> "$OUT"`, `cp f
6334
+ // $D/`) may land anywhere, the home's organs included, so the write is
6335
+ // `policy.core`. A variable inside a final name after a literal prefix
6336
+ // (`out_$N.txt`) leaves the directory known and is judged below.
6337
+ if (variableDecidesDirectory(target.text)) {
6338
+ add("policy.core");
6339
+ return;
6340
+ }
5124
6341
  const globbed = target.unresolved || hasGlobChars(target.text);
5125
6342
  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");
6343
+ // APRV-511 fix round 3 (M(L5c-1)): the lexical landing and the kernel
6344
+ // one (`k/../plugins/x` with `k` a symlink lands under `k`'s target's
6345
+ // parent), each judged; the stricter class wins because both are added.
6346
+ const spelled = spelledAbsolute(from, target.text);
6347
+ const landing = landingPaths(spelled, volatile);
6348
+ if (landing.unknown)
6349
+ add("policy.core");
6350
+ if (!globbed)
6351
+ segmentChanged.push(...landing.paths);
6352
+ for (const absoluteTarget of [resolvePathSegments(spelled), ...landing.paths]) {
6353
+ if (globbed) {
6354
+ // SF3a: a pattern in any component that could name the home, the
6355
+ // approval home or an organ is an organ write; a pattern written
6356
+ // into one of the gate's own directories (SF1b) may be any file
6357
+ // there. Anywhere else it is a workspace write.
6358
+ if (globCouldNameOrgan(absoluteTarget) || writesIntoGateRoot(canonicalPath(dirname(absoluteTarget)))) {
6359
+ add("policy.core");
6360
+ }
6361
+ continue;
5134
6362
  }
5135
- continue;
6363
+ add(protectedPathClass(canonicalPath(absoluteTarget), protectedPaths));
5136
6364
  }
5137
- add(protectedPathClass(canonicalPath(absoluteTarget), protectedPaths));
5138
6365
  }
5139
6366
  };
5140
6367
  /** A copy or move INTO a directory writes `<dir>/<basename of source>`. */
@@ -5152,20 +6379,40 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.e
5152
6379
  /** An extraction or a contents-copy writes unknown names into `directory`. */
5153
6380
  const judgeExtractInto = (directory) => {
5154
6381
  const target = expand(directory);
5155
- if (target.hermesUnknown || (cwdUnknown && !isAbsolute(target.text))) {
6382
+ // Merge of APRV-511 and APRV-522: a tilde prefix this hook does not
6383
+ // expand (APRV-522's `landingUnknown`, `~+` and a `~` with no HOME
6384
+ // included) lands the extraction somewhere unknown, as it does a write.
6385
+ if (target.hermesUnknown || target.landingUnknown || (cwdUnknown && !isAbsolute(target.text))) {
5156
6386
  add("policy.core");
5157
6387
  return;
5158
6388
  }
5159
- if (target.unresolved)
6389
+ if (target.unresolved) {
6390
+ // APRV-511: an extraction into a directory a variable names.
6391
+ if (/[$`]/u.test(target.text))
6392
+ add("policy.core");
5160
6393
  return;
6394
+ }
5161
6395
  for (const from of cwds) {
5162
- if (writesIntoGateRoot(canonicalPath(resolvePathSegments(from, target.text))))
6396
+ const landing = land(from, target.text);
6397
+ segmentExtracted.push(...landing.paths);
6398
+ if (landing.unknown || landing.paths.some((path) => writesIntoGateRoot(path)))
5163
6399
  add("policy.core");
5164
6400
  }
5165
6401
  };
5166
6402
  const judgeRead = (word, recursive) => {
6403
+ const braced = braceCandidates(word);
6404
+ if (braced === null) {
6405
+ add("account.credential");
6406
+ return;
6407
+ }
6408
+ for (const each of braced)
6409
+ for (const spelling of tildeReadings(each))
6410
+ judgeReadWord(spelling, recursive);
6411
+ };
6412
+ const judgeReadWord = (word, recursive) => {
5167
6413
  const target = expand(word);
5168
- if (target.hermesUnknown) {
6414
+ // Fix round 1 (M-C): an unexpanded tilde prefix lands somewhere unknown.
6415
+ if (target.hermesUnknown || target.landingUnknown) {
5169
6416
  add("account.credential");
5170
6417
  return;
5171
6418
  }
@@ -5173,70 +6420,262 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.e
5173
6420
  add("account.credential");
5174
6421
  return;
5175
6422
  }
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)))
6423
+ if (target.unresolved) {
6424
+ // APRV-511 (cred-asymmetry M1): a word this hook cannot expand is still
6425
+ // judged by what it spells. `git` is exempt from the classifier's text
6426
+ // credential rule, so this pass is git's only credential check, and
6427
+ // `git diff --no-index /dev/null $(echo ~)/.hermes/.env` (whose `$(...)`
6428
+ // the lexer leaves as the substitution sentinel) passed as `read.shell`.
6429
+ if (unresolvedReadCouldNameCredential(target.text))
6430
+ add("account.credential");
6431
+ // APRV-511 (cred-asymmetry round 3, S1): the helper reads the text
6432
+ // alone, so a literal prefix that is relative (`./`, `../`, or no
6433
+ // directory at all, from a working directory inside a home) or runs
6434
+ // through a symlink to a home was never placed, although a resolved
6435
+ // word's is. The prefix is landed here from each working directory in
6436
+ // both readings, and the word is judged again with the prefix replaced
6437
+ // by each landing (and by `$HERMES_HOME` when it is the configured home).
6438
+ const { prefix, rest } = unresolvedPrefix(target.text);
6439
+ for (const from of cwds) {
6440
+ const spelled = spelledAbsolute(from, prefix);
6441
+ const landing = landingPaths(spelled, volatile);
6442
+ if (landing.unknown)
6443
+ add("account.credential");
6444
+ for (const landed of [resolvePathSegments(spelled), ...landing.paths]) {
6445
+ for (const spelling of landedSpellings(landed, rest, configuredHome)) {
6446
+ if (unresolvedReadCouldNameCredential(spelling))
5191
6447
  add("account.credential");
5192
6448
  }
5193
6449
  }
5194
- continue;
5195
6450
  }
5196
- const resolved = canonicalPath(absoluteTarget);
5197
- if (isCredentialPath(resolved))
6451
+ // APRV-511 (cred-asymmetry round 2): a RECURSIVE reader (a recursive
6452
+ // search, a copy or move of a directory, an archive, a walk) of a word
6453
+ // this hook cannot expand reads everything under wherever the word
6454
+ // lands. That may be a Hermes home, a profile home, an approval home or
6455
+ // a directory above one, and the shape of the text cannot rule any of
6456
+ // them out: an unexpanded span can always hold a `/` or a `..`. So it
6457
+ // fails closed, whatever the word spells.
6458
+ if (recursive)
5198
6459
  add("account.credential");
5199
- else if (recursive && holdsCredentials(resolved))
6460
+ return;
6461
+ }
6462
+ for (const from of cwds) {
6463
+ // APRV-511 fix round 3 (M(L5c-1)): both readings, as for a write.
6464
+ const spelled = spelledAbsolute(from, target.text);
6465
+ const landing = landingPaths(spelled, volatile);
6466
+ if (landing.unknown)
5200
6467
  add("account.credential");
6468
+ for (const absoluteTarget of [resolvePathSegments(spelled), ...landing.paths]) {
6469
+ if (hasGlobChars(target.text)) {
6470
+ // SF3b: expand against the directory when it is known; otherwise a
6471
+ // pattern that could name a credential is read as one.
6472
+ // Round 2: a recursive reader of a matched directory is judged as
6473
+ // the plain spelling is (a home, or a directory holding credential
6474
+ // files), and one whose pattern cannot be expanded here fails
6475
+ // closed, as an unresolved word does.
6476
+ const candidates = expandGlob(absoluteTarget);
6477
+ if (candidates === null) {
6478
+ if (recursive || globCouldNameCredential(absoluteTarget))
6479
+ add("account.credential");
6480
+ }
6481
+ else {
6482
+ for (const candidate of candidates) {
6483
+ const resolvedCandidate = canonicalPath(candidate);
6484
+ if (isCredentialPath(resolvedCandidate))
6485
+ add("account.credential");
6486
+ else if (recursive && holdsCredentials(resolvedCandidate))
6487
+ add("account.credential");
6488
+ }
6489
+ }
6490
+ continue;
6491
+ }
6492
+ const resolved = canonicalPath(absoluteTarget);
6493
+ if (isCredentialPath(resolved))
6494
+ add("account.credential");
6495
+ else if (recursive && holdsCredentials(resolved))
6496
+ add("account.credential");
6497
+ }
6498
+ }
6499
+ };
6500
+ /**
6501
+ * APRV-511 fix round 5 (last): a file an upload or request-body option
6502
+ * reads (`curl -T F`, `--data @F`, `-F f=@F`, `wget --post-file F`) is a
6503
+ * READ, whatever the segment's class: `curl -T ../.op.env URL` sends the
6504
+ * file `cat ../.op.env` would print. It takes the read pass's answer
6505
+ * ({@link judgeRead}: a credential, `cd`, `~`, `$HERMES_HOME`, both
6506
+ * landings), the class of a protected path or organ it lands on (as the
6507
+ * classifier's protected scan judges an absolute source), and, under a
6508
+ * read scope, the read-scope refusal for a file outside it.
6509
+ */
6510
+ const judgeUploadSource = (word) => {
6511
+ judgeRead(word, false);
6512
+ const target = expand(word);
6513
+ // The read pass has already answered these with the stricter class.
6514
+ // Merge of APRV-511 and APRV-522: a word whose only unknown is a tilde
6515
+ // landing (APRV-522's `landingUnknown`) is still judged by its literal
6516
+ // spelling here, as it was before the merge; the read pass has added
6517
+ // the credential class for it already.
6518
+ if (target.hermesUnknown || /[$`]/u.test(target.text) || (!isAbsolute(target.text) && cwdUnknown))
6519
+ return;
6520
+ for (const from of cwds) {
6521
+ const spelled = spelledAbsolute(from, target.text);
6522
+ const landing = landingPaths(spelled, volatile);
6523
+ for (const absoluteTarget of [resolvePathSegments(spelled), ...landing.paths]) {
6524
+ const resolved = canonicalPath(absoluteTarget);
6525
+ add(protectedPathClass(resolved, protectedPaths));
6526
+ if (readRoots.length > 0 && !isInReadScope(resolved, readRoots))
6527
+ add(READ_OUT_OF_SCOPE_CLASS);
6528
+ }
6529
+ }
6530
+ };
6531
+ /** A link source: the organ or credential a planted link would be a handle on. */
6532
+ const judgeLinkSource = (source, linkPath) => {
6533
+ const target = expand(source);
6534
+ if (target.hermesUnknown || target.unresolved) {
6535
+ add("policy.core");
6536
+ return;
6537
+ }
6538
+ for (const from of cwds) {
6539
+ const linkDir = dirname(resolvePathSegments(from, expand(linkPath).text));
6540
+ for (const base of [from, linkDir]) {
6541
+ const landing = resolvePathSegments(base, target.text);
6542
+ if (hasGlobChars(target.text)) {
6543
+ if (globCouldNameOrgan(landing) || globCouldNameCredential(landing))
6544
+ add("policy.core");
6545
+ continue;
6546
+ }
6547
+ // APRV-511 fix round 3 (M(L5c-1)): and where the kernel lands it.
6548
+ const kernel = landingPaths(spelledAbsolute(base, target.text), volatile);
6549
+ if (kernel.unknown)
6550
+ add("policy.core");
6551
+ for (const candidate of [landing, canonicalPath(landing), ...kernel.paths]) {
6552
+ const cls = protectedPathClass(candidate, protectedPaths);
6553
+ if (cls === "policy.core" || cls === "log.mutate")
6554
+ add(cls);
6555
+ if (isCredentialPath(candidate))
6556
+ add("account.credential");
6557
+ }
6558
+ }
5201
6559
  }
5202
6560
  };
5203
6561
  for (const target of shape.writes)
5204
6562
  judgeWrite(target);
5205
6563
  for (const source of shape.reads)
5206
6564
  judgeRead(source, false);
5207
- if (bin === null)
5208
- continue;
5209
- // `-t DIR` / `--target-directory=DIR` names the destination up front.
5210
- const targetDirFlag = () => {
6565
+ // APRV-511 refuter S2: a side-effecting command run FROM inside an organ
6566
+ // directory (`git pull`, `git apply`, `npm install` in hermes-agent/; `git
6567
+ // clone x` in plugins/) rewrites the organ, as `hermes update` does. The
6568
+ // home root itself is ordinary ground (its own rules judge each path).
6569
+ if (!segment.class.startsWith("read.") && segment.class !== GATE_SELF_CLASS && !cwdUnknown) {
6570
+ for (const from of cwds)
6571
+ if (isOrganDirectory(from))
6572
+ add("policy.core");
6573
+ }
6574
+ if (bin === "git") {
5211
6575
  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);
6576
+ for (const flag of ["--work-tree", "--git-dir"]) {
6577
+ const value = arg === flag ? shape.args[at + 1] : arg.startsWith(`${flag}=`) ? arg.slice(flag.length + 1) : undefined;
6578
+ if (value === undefined)
6579
+ continue;
6580
+ const dir = expand(value);
6581
+ if (dir.unresolved || dir.hermesUnknown) {
6582
+ add("policy.core");
6583
+ continue;
6584
+ }
6585
+ for (const from of cwds) {
6586
+ const landing = land(from, dir.text);
6587
+ if (landing.unknown || landing.paths.some((path) => isOrganDirectory(path)))
6588
+ add("policy.core");
6589
+ }
6590
+ }
5216
6591
  }
5217
- return null;
5218
- };
6592
+ }
6593
+ // APRV-511 refuter S1: a download's output is a write target.
6594
+ if (bin === "curl" || bin === "wget") {
6595
+ const outputs = fetchOutputs(bin, shape.args, shape.argFlags);
6596
+ // Fix round 3 (S(open)): an output this argv cannot place (a `#1` glob
6597
+ // name, an option word the shell may still make) may be an organ.
6598
+ if (outputs.unknown !== null)
6599
+ add("policy.core");
6600
+ for (const file of outputs.files)
6601
+ judgeWrite(file);
6602
+ for (const directory of outputs.directories)
6603
+ judgeExtractInto(directory);
6604
+ // Fix round 5 (last): what an upload or body option reads is a read
6605
+ // (`-` is curl's standard input, no file); a source word the text cannot
6606
+ // read is the stricter class, as the classifier already answers it.
6607
+ if (outputs.sourceUnknown !== null)
6608
+ add("account.credential");
6609
+ for (const source of outputs.sources) {
6610
+ if (bin === "curl" && source === "-")
6611
+ continue;
6612
+ judgeUploadSource(source);
6613
+ }
6614
+ }
6615
+ if ((bin === "cp" || bin === "mv" || bin === "ln" || bin === "install") && copyArgvUnknown(shape.args, shape.argFlags) !== null) {
6616
+ add("policy.core");
6617
+ }
6618
+ if (bin === null)
6619
+ continue;
6620
+ // `-t DIR` / `--target-directory=DIR` names the destination up front; so
6621
+ // do `-tDIR`, `-vtDIR` and `--target-dir=DIR` (APRV-511 refuter S3, recheck
6622
+ // S(open)). Every one named is judged; the first is the destination.
6623
+ // rsync's `-t` keeps times instead, so there the last operand stays the
6624
+ // destination and a word after `-t` is still judged as a write (as before).
6625
+ const flaggedDirs = copyTargetDirectories(shape.args);
6626
+ const targetDirs = bin === "rsync" ? [] : flaggedDirs;
6627
+ const targetDirFlag = () => targetDirs[0] ?? null;
5219
6628
  if (WRITE_EVERY_OPERAND.has(bin)) {
5220
6629
  for (const operand of operands)
5221
6630
  judgeWrite(operand);
5222
6631
  }
5223
6632
  else if (WRITE_LAST_OPERAND.has(bin)) {
5224
6633
  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];
6634
+ // Fix round 1 (M-F): one operand and no `-t` is a SOURCE, landed in the
6635
+ // working directory under its own name (a one-operand `ln`).
6636
+ const single = targetDir === null && operands.length === 1;
6637
+ const sources = single
6638
+ ? operands
6639
+ : targetDir === null
6640
+ ? operands.slice(0, -1)
6641
+ : operands.filter((operand) => operand !== targetDir);
6642
+ const destination = single ? "." : (targetDir ?? operands[operands.length - 1]);
5227
6643
  for (const source of sources)
5228
6644
  judgeRead(source, true);
6645
+ // A move takes its sources away, so a later `..` out of one is unknowable.
6646
+ if (bin === "mv") {
6647
+ for (const source of sources) {
6648
+ const text = expand(source).text;
6649
+ for (const from of cwds)
6650
+ segmentChanged.push(...land(from, text).paths);
6651
+ }
6652
+ }
6653
+ // APRV-511 refuter S4: a link is a handle on its source. `ln` (soft or
6654
+ // hard) and `cp -s`/`cp -l` naming an organ or a credential as the
6655
+ // source plant that handle, and the plant is the act, because a later
6656
+ // write or read through it can be blind (co-location: the daemon cannot
6657
+ // realpath the 0700 home). A relative symlink source resolves against
6658
+ // the link's own directory, so both that and the working directory are
6659
+ // tried.
6660
+ if (bin === "ln" ||
6661
+ (bin === "cp" && shape.args.some((arg) => arg === "--symbolic-link" || arg === "--link" || /^-[a-zA-Z]*[sl]/u.test(arg)))) {
6662
+ for (const source of sources)
6663
+ judgeLinkSource(source, destination ?? ".");
6664
+ }
5229
6665
  if (destination !== undefined) {
5230
6666
  const expanded = expand(destination);
5231
- const intoDirectory = targetDir !== null ||
6667
+ const intoDirectory = single ||
6668
+ targetDir !== null ||
5232
6669
  destination.endsWith("/") ||
5233
6670
  (!expanded.unresolved &&
5234
- cwds.some((from) => isDirectoryPath(canonicalPath(resolvePathSegments(from, expanded.text)))));
6671
+ cwds.some((from) => land(from, expanded.text).paths.some((path) => isDirectoryPath(path))));
5235
6672
  if (intoDirectory)
5236
6673
  judgeWriteInto(destination, sources);
5237
6674
  else
5238
6675
  judgeWrite(destination);
5239
6676
  }
6677
+ for (const extra of bin === "rsync" ? flaggedDirs : targetDirs.slice(1))
6678
+ judgeWriteInto(extra, sources);
5240
6679
  }
5241
6680
  else if (WRITE_AFTER_FIRST_OPERAND.has(bin)) {
5242
6681
  for (const operand of operands.slice(1))
@@ -5263,13 +6702,32 @@ function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.e
5263
6702
  else if (bin === "unzip") {
5264
6703
  judgeExtractInto(flagValue(shape.args, "-d", null) ?? ".");
5265
6704
  }
5266
- if (segment.class.startsWith("read.") || RECURSIVE_READERS.has(bin)) {
6705
+ // Fix round 1 (S): git and diff walk a directory they are handed, unless
6706
+ // git only writes the paths it names. (M-E): a search reads the working
6707
+ // directory.
6708
+ const readWords = credentialReadWords(bin, shape.args);
6709
+ const recursive = RECURSIVE_READERS.has(bin) || (DIRECTORY_READING_BINS.has(bin) && readWords.length > 0);
6710
+ if (segment.class.startsWith("read.") || recursive) {
5267
6711
  for (const operand of operands)
5268
- judgeRead(operand, RECURSIVE_READERS.has(bin));
6712
+ judgeRead(operand, recursive);
6713
+ // APRV-511 (cred-asymmetry round 3, N2): a recursive reader that no
6714
+ // argument proves names a path walks the working directory.
6715
+ if (walksWorkingDirectory(bin, shape.args))
6716
+ judgeRead(".", true);
5269
6717
  }
6718
+ if (readsWorkingDirectory(bin, shape.args, readWords))
6719
+ judgeRead(".", true);
5270
6720
  }
5271
6721
  return found;
5272
6722
  }
6723
+ /**
6724
+ * Does an unexpanded variable (or a backtick) decide which directory `text`
6725
+ * lands in (APRV-511)? True when one sits in any directory component, or opens
6726
+ * the final one: either can expand to a path with slashes in it.
6727
+ */
6728
+ function variableDecidesDirectory(text) {
6729
+ return /[$`]/u.test(dirname(text)) || /^[$`]/u.test(basename(text));
6730
+ }
5273
6731
  /** `*`, `?` or `[` in a path word. */
5274
6732
  function hasGlobChars(text) {
5275
6733
  return /[*?[]/u.test(text);
@@ -5295,7 +6753,7 @@ function globRegex(component) {
5295
6753
  }
5296
6754
  }
5297
6755
  /** 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"];
6756
+ const CREDENTIAL_NAMES = [".env", ".env.local", ".op.env", "auth.json", "env", "vault.enc", "keys"];
5299
6757
  /** Names an organ goes by, beyond the credentials. */
5300
6758
  const ORGAN_NAMES = [
5301
6759
  ...CREDENTIAL_NAMES,
@@ -5306,6 +6764,9 @@ const ORGAN_NAMES = [
5306
6764
  "agent-hooks",
5307
6765
  "approval",
5308
6766
  "scripts",
6767
+ // APRV-511: the runtime's source install and the plugin directory.
6768
+ "hermes-agent",
6769
+ "plugins",
5309
6770
  ];
5310
6771
  /** Could some component of `path`'s pattern name the home or the approval home? */
5311
6772
  function globCouldNameGateHome(path) {
@@ -5317,6 +6778,21 @@ function globCouldNameGateHome(path) {
5317
6778
  function globCouldNameOrgan(path) {
5318
6779
  if (globCouldNameGateHome(path))
5319
6780
  return true;
6781
+ // APRV-511: a pattern in a DIRECTORY component right under a home could name
6782
+ // an organ directory the write lands inside (`~/.hermes/plu*/av-approval/x`,
6783
+ // `~/.hermes/agent-h*/shim.sh`); the last component is judged below.
6784
+ const components = path.split(/[/\\]+/u);
6785
+ for (let index = 1; index < components.length - 1; index += 1) {
6786
+ const component = components[index];
6787
+ if (!hasGlobChars(component))
6788
+ continue;
6789
+ const parent = components[index - 1];
6790
+ const underHome = parent === ".hermes" ||
6791
+ hasGlobChars(parent) ||
6792
+ (components[index - 3] === ".hermes" && components[index - 2] === "profiles");
6793
+ if (underHome && ORGAN_NAMES.some((name) => globRegex(component).test(name)))
6794
+ return true;
6795
+ }
5320
6796
  const last = basename(path);
5321
6797
  if (!hasGlobChars(last))
5322
6798
  return false;
@@ -5326,13 +6802,437 @@ function globCouldNameOrgan(path) {
5326
6802
  hasGlobChars(parent);
5327
6803
  return parentIsHome && ORGAN_NAMES.some((name) => pattern.test(name));
5328
6804
  }
5329
- /** SF3b: a read pattern whose directory is unknown, that could name a credential. */
6805
+ /**
6806
+ * SF3b: a read pattern whose directory is unknown, that could name a credential.
6807
+ *
6808
+ * Fix round 4 (review-b r3 S2): a pattern in a DIRECTORY component is judged
6809
+ * too, as {@link globCouldNameOrgan} judges one for a write. Each such
6810
+ * component that could name `hermes-agent` or `profiles` (in any case) is
6811
+ * replaced by that name, and the path is judged again by the credential rows
6812
+ * ({@link isCredentialPath}), by this test for its other patterns, and, when
6813
+ * it holds an unexpanded span, by {@link unresolvedReadCouldNameCredential}.
6814
+ * So a pattern for the install directory before its env file, or for the
6815
+ * profiles directory before a profile's credential, is a credential read; the
6816
+ * same pattern before an ordinary file is not.
6817
+ *
6818
+ * Fix round 5 (review-b r4 M1): no recursion. Round 4 judged each replacement
6819
+ * by calling this test again (and, through
6820
+ * {@link unresolvedReadCouldNameCredential}, again), with no cap, so the work
6821
+ * grew factorially in the count of such components. A hook stopped by its
6822
+ * harness's timeout lets the call through on Claude Code. The same choices
6823
+ * are now walked once each, in a loop: every pattern component in a directory
6824
+ * position that could name either directory is kept as written or replaced by
6825
+ * a name it could stand for, and every combination but "all kept" is judged
6826
+ * by the credential rows, by this test's own name tests, and, where a span
6827
+ * remains, by {@link spanReadingsCouldNameCredential}. That is the set round 4
6828
+ * reached, so up to the cap the answer is round 4's. Past
6829
+ * {@link GLOB_DIRECTORY_NAMING_CAP} such components the word is a credential
6830
+ * read without judging it: fail closed, at the cost of one approval.
6831
+ */
5330
6832
  function globCouldNameCredential(path) {
6833
+ if (globNamesCredentialOrHome(path))
6834
+ return true;
6835
+ const components = path.split(/[/\\]+/u);
6836
+ const choices = [];
6837
+ for (let index = 0; index < components.length - 1; index += 1) {
6838
+ const component = components[index];
6839
+ if (!hasGlobChars(component))
6840
+ continue;
6841
+ const names = GLOB_DIRECTORY_NAMES.filter((name) => globRegex(component).test(name) || globRegex(component.toLowerCase()).test(name));
6842
+ if (names.length > 0)
6843
+ choices.push({ index, names });
6844
+ }
6845
+ if (choices.length === 0)
6846
+ return false;
6847
+ if (choices.length > GLOB_DIRECTORY_NAMING_CAP)
6848
+ return true;
6849
+ // An odometer over the choices: 0 keeps the pattern, n > 0 names names[n - 1].
6850
+ // All zeros (every pattern kept) is the word itself, which the caller judges.
6851
+ const picks = choices.map(() => 0);
6852
+ for (;;) {
6853
+ let at = 0;
6854
+ while (at < picks.length && picks[at] === choices[at].names.length) {
6855
+ picks[at] = 0;
6856
+ at += 1;
6857
+ }
6858
+ if (at === picks.length)
6859
+ return false;
6860
+ picks[at] = picks[at] + 1;
6861
+ const named = [...components];
6862
+ for (const [position, choice] of choices.entries()) {
6863
+ const pick = picks[position];
6864
+ if (pick > 0)
6865
+ named[choice.index] = choice.names[pick - 1];
6866
+ }
6867
+ const text = named.join("/");
6868
+ if (globNamesCredentialOrHome(text) || isCredentialPath(text))
6869
+ return true;
6870
+ if (/[$`]/u.test(text) && spanReadingsCouldNameCredential(text))
6871
+ return true;
6872
+ }
6873
+ }
6874
+ /** The directory names {@link globCouldNameCredential} reads a directory-component pattern as. */
6875
+ const GLOB_DIRECTORY_NAMES = ["hermes-agent", "profiles"];
6876
+ /**
6877
+ * How many directory-component patterns that could name the install or
6878
+ * profiles directory {@link globCouldNameCredential} judges (fix round 5,
6879
+ * review-b r4 M1). Each is kept or named, so k of them cost at most 3^k - 1
6880
+ * judgements (80 at this cap); a word with more is a credential read. A `*`
6881
+ * component is already one through {@link globCouldNameGateHome}, so the cap
6882
+ * only ever meets narrower patterns (`h*`, `p*`, `[hp]*`), and rarely.
6883
+ */
6884
+ const GLOB_DIRECTORY_NAMING_CAP = 4;
6885
+ /** A last-component pattern that could name a credential file, or any pattern that could name a home. */
6886
+ function globNamesCredentialOrHome(path) {
5331
6887
  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);
6888
+ if (hasGlobChars(last) && CREDENTIAL_NAMES.some((name) => globRegex(last).test(name)))
6889
+ return true;
6890
+ return globCouldNameGateHome(path);
6891
+ }
6892
+ /**
6893
+ * APRV-511 (cred-asymmetry M1, round 3): could a read word this hook cannot
6894
+ * expand (a variable, `${X:-default}`, the `$(...)` substitution sentinel, or
6895
+ * a tilde prefix {@link otherTildePrefix} turned into the variable it stands
6896
+ * for) name a credential? It reads the text only. {@link resolvedPathClasses}
6897
+ * also asks it about the word with its literal prefix replaced by each place
6898
+ * that prefix lands (round 3, S1), which is how a relative or symlinked prefix
6899
+ * is covered.
6900
+ *
6901
+ * The model, as in {@link isCredentialPath}: an UNKNOWN segment (one holding a
6902
+ * `$` or a backtick, or a leading `~` the hook did not expand) stands for
6903
+ * exactly one path segment of any name. Within that model it fails closed: it
6904
+ * answers false only when no choice of names makes the word a credential
6905
+ * path. It answers true when the classifier's text rule holds
6906
+ * ({@link isCredentialPath}), when a pattern in it could name a credential
6907
+ * ({@link globCouldNameCredential}), or when, in any of three readings of the
6908
+ * segments (`.` dropped with every `..` kept; `.` dropped with every `..`
6909
+ * resolved lexically, where a `..` after an unknown segment leaves an unknown
6910
+ * segment; and the same with that `..` cancelling the unknown segment, as
6911
+ * the model says it does; see {@link segmentReadings}), any of these holds:
6912
+ *
6913
+ * - the word ENDS in a Hermes credential row, with any unknown segment
6914
+ * standing for any element: a home (`.hermes` in any case, `$HERMES_HOME`)
6915
+ * then a credential file (`.env`, `.env.*`, `.op.env`, `.op.env.*`,
6916
+ * `auth.json`, any case), or a home then `hermes-agent` then the install's
6917
+ * `.env` or `.env.*` (not `.env.example`), each also under a home's
6918
+ * `profiles/<p>`. So an unknown last segment right under a literal home, a
6919
+ * profile home or the `hermes-agent/` of either is a credential, and so are
6920
+ * two unknown segments in a row at the end;
6921
+ * - ANYWHERE, an approval home (`.approval`, exact case) or an unknown segment
6922
+ * is followed by `env`, `env.*`, `vault*`, `keys` or an unknown segment;
6923
+ * - a credential file name appears at or after the first unknown segment
6924
+ * (which may have stood for a home, a profile home or a profiles directory).
6925
+ *
6926
+ * Not covered, by the model: in a single-file read, an unknown segment that
6927
+ * expands to several segments (a `/` or `..` inside it) and so moves the
6928
+ * literal names after it; a recursive reader of any unresolved word is
6929
+ * `account.credential` regardless. `.approval` and its file names in another
6930
+ * case are not matched, as in the classifier's text rule.
6931
+ */
6932
+ function unresolvedReadCouldNameCredential(text) {
6933
+ if (isCredentialPath(text) || globCouldNameCredential(text))
6934
+ return true;
6935
+ return spanReadingsCouldNameCredential(text);
6936
+ }
6937
+ /**
6938
+ * The segment rows of {@link unresolvedReadCouldNameCredential}, in its three
6939
+ * readings, without its pattern test. {@link globCouldNameCredential} calls
6940
+ * this one, so neither test calls back into the other (fix round 5).
6941
+ */
6942
+ function spanReadingsCouldNameCredential(text) {
6943
+ // One segment per braced variable, so a default's `/` (`${H:-$HOME/.hermes}`) splits nothing.
6944
+ const collapsed = text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)[^}]*\}/gu, "${$1}");
6945
+ const segments = collapsed.split(/[/\\]+/u).filter((segment) => segment.length > 0);
6946
+ // A leading `~` this hook did not expand stands for an unknown directory.
6947
+ if (!collapsed.startsWith("/") && segments[0]?.startsWith("~") === true)
6948
+ segments[0] = "$~";
6949
+ return segmentReadings(segments).some(segmentsCouldNameCredential);
6950
+ }
6951
+ /** A path segment holding an unexpanded span. */
6952
+ function unknownSegment(segment) {
6953
+ return /[$`]/u.test(segment);
6954
+ }
6955
+ /**
6956
+ * The three readings {@link unresolvedReadCouldNameCredential} judges: `.`
6957
+ * dropped with `..` kept; `.` dropped with each `..` resolved lexically, where
6958
+ * a `..` after an unknown segment leaves an unknown segment (the span may name
6959
+ * a symlink, so its parent is somewhere unknown too); and the same with that
6960
+ * `..` CANCELLING the unknown segment, as it cancels a literal one, which is
6961
+ * the one-segment model itself (fix round 4, review-b r3 M). Without the
6962
+ * third, the unknown left in place shifted every literal after it one segment
6963
+ * deeper, so a round trip through a span back to a home's `profiles/`, then a
6964
+ * literal profile name, then a span, matched no row. A `..` above the first
6965
+ * segment is kept in every reading.
6966
+ */
6967
+ function segmentReadings(segments) {
6968
+ const dropped = segments.filter((segment) => segment !== ".");
6969
+ const resolve = (unknownParent) => {
6970
+ const resolved = [];
6971
+ for (const segment of dropped) {
6972
+ const last = resolved[resolved.length - 1];
6973
+ if (segment !== "..")
6974
+ resolved.push(segment);
6975
+ else if (last === undefined || last === "..")
6976
+ resolved.push(segment);
6977
+ else if (unknownParent && unknownSegment(last))
6978
+ resolved[resolved.length - 1] = "$..";
6979
+ else
6980
+ resolved.pop();
6981
+ }
6982
+ return resolved;
6983
+ };
6984
+ return [dropped, resolve(true), resolve(false)];
6985
+ }
6986
+ /** One reading's segments against the rows {@link unresolvedReadCouldNameCredential} lists. */
6987
+ function segmentsCouldNameCredential(segments) {
6988
+ const named = (name) => (segment) => unknownSegment(segment) || segment.toLowerCase() === name;
6989
+ const credentialFile = (segment) => {
6990
+ const name = segment.toLowerCase();
6991
+ return name === ".env" || name.startsWith(".env.") || name === ".op.env" || name.startsWith(".op.env.") || name === "auth.json";
6992
+ };
6993
+ const hermesHome = (segment) => unknownSegment(segment) || segment.toLowerCase() === ".hermes";
6994
+ const homeCredential = (segment) => unknownSegment(segment) || credentialFile(segment);
6995
+ const installEnv = (segment) => {
6996
+ const name = segment.toLowerCase();
6997
+ return unknownSegment(segment) || name === ".env" || (name.startsWith(".env.") && segment !== ".env.example");
6998
+ };
6999
+ const anyName = () => true;
7000
+ const approvalHome = (segment) => unknownSegment(segment) || segment === ".approval";
7001
+ const approvalFile = (segment) => unknownSegment(segment) || segment === "env" || segment.startsWith("env.") || segment.startsWith("vault") || segment === "keys";
7002
+ const endsWith = (row) => {
7003
+ const start = segments.length - row.length;
7004
+ return start >= 0 && row.every((element, at) => element(segments[start + at]));
7005
+ };
7006
+ for (const tail of [[homeCredential], [named("hermes-agent"), installEnv]]) {
7007
+ if (endsWith([hermesHome, ...tail]))
7008
+ return true;
7009
+ if (endsWith([hermesHome, named("profiles"), anyName, ...tail]))
7010
+ return true;
7011
+ }
7012
+ for (let index = 0; index + 1 < segments.length; index += 1) {
7013
+ if (approvalHome(segments[index]) && approvalFile(segments[index + 1]))
7014
+ return true;
7015
+ }
7016
+ let afterUnknown = false;
7017
+ for (const segment of segments) {
7018
+ if (afterUnknown && credentialFile(segment))
7019
+ return true;
7020
+ if (unknownSegment(segment))
7021
+ afterUnknown = true;
7022
+ }
7023
+ return false;
7024
+ }
7025
+ /**
7026
+ * APRV-524: is the unexpanded span in the LAST component of the word? `rest`
7027
+ * is {@link unresolvedPrefix}'s, which starts at the span's component. Split
7028
+ * on `/` alone, the kernel's separator (a backslash is a character of a name),
7029
+ * with empty and `.` components dropped, so a trailing `/` or `/.` still
7030
+ * leaves the span last. The stricter reading on every count.
7031
+ */
7032
+ function lastComponentUnresolved(rest) {
7033
+ const components = rest.split("/").filter((component) => component.length > 0 && component !== ".");
7034
+ return components.length === 1 && /[$`]/u.test(components[0]);
7035
+ }
7036
+ /**
7037
+ * APRV-511 (cred-asymmetry round 3, S1): an unresolved word split at the last
7038
+ * `/` before its first unexpanded span: the literal directory the span sits
7039
+ * in (`.` when the word names none) and the rest, from the span's segment on.
7040
+ */
7041
+ function unresolvedPrefix(text) {
7042
+ const collapsed = text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)[^}]*\}/gu, "${$1}");
7043
+ const span = collapsed.search(/[$`]/u);
7044
+ const cut = span < 0 ? collapsed.lastIndexOf("/") : collapsed.lastIndexOf("/", span);
7045
+ if (cut < 0)
7046
+ return { prefix: ".", rest: collapsed };
7047
+ return { prefix: cut === 0 ? "/" : collapsed.slice(0, cut), rest: collapsed.slice(cut + 1) };
7048
+ }
7049
+ /**
7050
+ * The spellings an unresolved word's rest is judged in once its prefix has
7051
+ * landed (round 3, S1): under the landing itself, and under `$HERMES_HOME`
7052
+ * when the landing is the configured home or below it (a home whose directory
7053
+ * is not named `.hermes`).
7054
+ */
7055
+ function landedSpellings(landed, rest, configuredHome) {
7056
+ const spellings = [`${landed.replace(/\/+$/u, "")}/${rest}`];
7057
+ if (configuredHome !== null && (landed === configuredHome || landed.startsWith(`${configuredHome}/`))) {
7058
+ spellings.push(`$HERMES_HOME${landed.slice(configuredHome.length)}/${rest}`);
7059
+ }
7060
+ return spellings;
7061
+ }
7062
+ /**
7063
+ * APRV-511 (cred-asymmetry round 3, S2): bash's tilde prefixes other than a
7064
+ * bare `~` (which the caller expands to `HOME`) and `~+` (which
7065
+ * {@link tildeReadings} answers). `~-` is `$OLDPWD`, `~N`, `~+N` and `~-N` are
7066
+ * entries of the directory stack, and `~name` is that user's home. None of
7067
+ * those is known here, so each becomes the variable it stands for and the word
7068
+ * is unresolved.
7069
+ */
7070
+ function otherTildePrefix(text) {
7071
+ const match = /^~([^/]*)(?=\/|$)/u.exec(text);
7072
+ if (match === null)
7073
+ return text;
7074
+ const name = match[1];
7075
+ const rest = text.slice(match[0].length);
7076
+ if (name === "" || name === "+")
7077
+ return text;
7078
+ if (name === "-")
7079
+ return `$OLDPWD${rest}`;
7080
+ if (/^[+-]?\d+$/u.test(name))
7081
+ return `$DIRSTACK${rest}`;
7082
+ return `$TILDE_HOME${rest}`;
7083
+ }
7084
+ /**
7085
+ * The words a path word is judged as (round 3, S2). Unquoted, bash expands
7086
+ * `~+` to `$PWD`, which is spelled `.` here so it is judged from each working
7087
+ * directory; quoted, it is a literal directory named `~+`, and the lexer has
7088
+ * already removed the quotes. So a word that opens with `~+` is judged both
7089
+ * ways, and the stricter class wins.
7090
+ */
7091
+ function tildeReadings(word) {
7092
+ return /^~\+(?=\/|$)/u.test(word) ? [word, `.${word.slice(2)}`] : [word];
7093
+ }
7094
+ const GREP_WALK = {
7095
+ recursiveShort: "rR",
7096
+ recursiveLong: ["--recursive", "--dereference-recursive"],
7097
+ patternOperand: true,
7098
+ noPatternShort: "ef",
7099
+ noPatternLong: ["--regexp", "--file"],
7100
+ valueShort: "ABCDdefm",
7101
+ flagShort: "abcEFGHhIiLlnoPqRrsTUuVvwxZz",
7102
+ flagLong: [
7103
+ "--ignore-case", "--line-number", "--files-with-matches", "--files-without-match", "--count", "--invert-match",
7104
+ "--word-regexp", "--line-regexp", "--fixed-strings", "--extended-regexp", "--perl-regexp", "--basic-regexp",
7105
+ "--only-matching", "--with-filename", "--no-filename", "--quiet", "--silent", "--no-messages", "--null", "--text",
7106
+ ],
7107
+ };
7108
+ const WALK_SPECS = new Map([
7109
+ ["grep", GREP_WALK],
7110
+ ["egrep", GREP_WALK],
7111
+ ["fgrep", GREP_WALK],
7112
+ [
7113
+ "rg",
7114
+ {
7115
+ recursiveShort: null,
7116
+ recursiveLong: [],
7117
+ patternOperand: true,
7118
+ noPatternShort: "ef",
7119
+ noPatternLong: ["--regexp", "--file", "--files", "--type-list"],
7120
+ // Fix round 4 (review-b r3 S1): `d`, ripgrep's maximum-depth option.
7121
+ valueShort: "ABCdEefgjMmrTt",
7122
+ flagShort: "abcFHhIiLlNnopqSsUuVvwxz0",
7123
+ flagLong: [
7124
+ "--files", "--type-list", "--hidden", "--no-ignore", "--ignore-case", "--smart-case", "--line-number",
7125
+ "--follow", "--fixed-strings", "--files-with-matches", "--files-without-match", "--count", "--no-heading",
7126
+ "--with-filename", "--no-filename", "--json", "--text", "--word-regexp", "--invert-match", "--multiline",
7127
+ "--only-matching", "--vimgrep", "--null", "--unrestricted", "--quiet",
7128
+ ],
7129
+ },
7130
+ ],
7131
+ [
7132
+ "du",
7133
+ {
7134
+ recursiveShort: null,
7135
+ recursiveLong: [],
7136
+ patternOperand: false,
7137
+ noPatternShort: "",
7138
+ noPatternLong: [],
7139
+ valueShort: "BdtX",
7140
+ flagShort: "aAbcghHklLmnPsSx0",
7141
+ flagLong: ["--all", "--summarize", "--human-readable", "--apparent-size", "--bytes", "--total", "--si"],
7142
+ },
7143
+ ],
7144
+ [
7145
+ "tree",
7146
+ {
7147
+ recursiveShort: null,
7148
+ recursiveLong: [],
7149
+ patternOperand: false,
7150
+ noPatternShort: "",
7151
+ noPatternLong: [],
7152
+ valueShort: "LPIoHT",
7153
+ flagShort: "aAcCdDfFghiJlNnpqQrsStuUvx",
7154
+ flagLong: ["--dirsfirst", "--noreport", "--prune", "--du", "--si"],
7155
+ },
7156
+ ],
7157
+ ]);
7158
+ /**
7159
+ * APRV-511 (cred-asymmetry round 3, N2): does this reader walk the working
7160
+ * directory because no argument is proven to name a path? `grep` with a
7161
+ * recursive flag, `rg`, `du` and `tree` with no path operand, and `find` with
7162
+ * no path before its expression, each walk `.`. Fails closed: an option this
7163
+ * table does not know, long or short (a short cluster holding a letter in
7164
+ * neither of its lists; fix round 4), may take the next word as its value, so
7165
+ * that word is not counted as a path. (`ag`, `ack` and `zip` have no classifier rule and are
7166
+ * refused as unclassified; `tar` with no file operand reads none.)
7167
+ */
7168
+ function walksWorkingDirectory(bin, args) {
7169
+ if (bin === "find") {
7170
+ let index = 0;
7171
+ while (index < args.length) {
7172
+ const arg = args[index];
7173
+ if (arg === "-H" || arg === "-L" || arg === "-P" || /^-O\d*$/u.test(arg))
7174
+ index += 1;
7175
+ else if (arg === "-D")
7176
+ index += 2;
7177
+ else
7178
+ break;
7179
+ }
7180
+ const first = args[index];
7181
+ return first === undefined || first.startsWith("-") || first === "(" || first === "!";
7182
+ }
7183
+ const spec = bin === null ? undefined : WALK_SPECS.get(bin);
7184
+ if (spec === undefined)
7185
+ return false;
7186
+ let recursive = spec.recursiveShort === null;
7187
+ let patternOperand = spec.patternOperand;
7188
+ let words = 0;
7189
+ let endOfOptions = false;
7190
+ for (let index = 0; index < args.length; index += 1) {
7191
+ const arg = args[index];
7192
+ if (endOfOptions || arg === "-" || !arg.startsWith("-")) {
7193
+ words += 1;
7194
+ continue;
7195
+ }
7196
+ if (arg === "--") {
7197
+ endOfOptions = true;
7198
+ continue;
7199
+ }
7200
+ const next = args[index + 1];
7201
+ if (arg.startsWith("--")) {
7202
+ const name = arg.split("=", 1)[0];
7203
+ if (spec.recursiveLong.includes(name))
7204
+ recursive = true;
7205
+ if (spec.noPatternLong.includes(name))
7206
+ patternOperand = false;
7207
+ const flag = spec.flagLong.includes(name) || spec.recursiveLong.includes(name);
7208
+ if (!arg.includes("=") && !flag && next !== undefined && !next.startsWith("-"))
7209
+ index += 1;
7210
+ continue;
7211
+ }
7212
+ let unknownLetter = false;
7213
+ for (let at = 1; at < arg.length; at += 1) {
7214
+ const letter = arg[at];
7215
+ if (spec.recursiveShort?.includes(letter) === true)
7216
+ recursive = true;
7217
+ if (spec.noPatternShort.includes(letter))
7218
+ patternOperand = false;
7219
+ if (spec.valueShort.includes(letter)) {
7220
+ if (at === arg.length - 1 && next !== undefined)
7221
+ index += 1;
7222
+ unknownLetter = false;
7223
+ break;
7224
+ }
7225
+ if (!spec.flagShort.includes(letter))
7226
+ unknownLetter = true;
7227
+ }
7228
+ // Fix round 4 (review-b r3 S1): fails closed for a short option too. A
7229
+ // cluster holding a letter this table does not know may take the next word
7230
+ // as its value, so that word is not counted as a path (as the long branch
7231
+ // above already reads an unknown long option).
7232
+ if (unknownLetter && next !== undefined && !next.startsWith("-"))
7233
+ index += 1;
7234
+ }
7235
+ return recursive && words - (patternOperand ? 1 : 0) <= 0;
5336
7236
  }
5337
7237
  /** The entries a final-component glob matches, or `null` when the directory is unknown. */
5338
7238
  function expandGlob(path) {
@@ -5365,7 +7265,25 @@ function isDirectoryPath(path) {
5365
7265
  * `$HERMES_HOME`.
5366
7266
  */
5367
7267
  function gateRootKind(directory) {
5368
- const segments = directory.split(/[/\\]+/u).filter((segment) => segment.length > 0);
7268
+ // Fix round 2 (path-parser differential): a backslash is a character of a
7269
+ // name on macOS and Linux, so a path holding one is judged on the kernel's
7270
+ // `/`-only split as well (a profile name with a backslash in it).
7271
+ const splittings = [directory.split(/[/\\]+/u)];
7272
+ if (process.platform !== "win32" && directory.includes("\\"))
7273
+ splittings.push(directory.split(/\/+/u));
7274
+ for (const split of splittings) {
7275
+ const kind = gateRootKindOf(split);
7276
+ if (kind !== null)
7277
+ return kind;
7278
+ }
7279
+ return null;
7280
+ }
7281
+ function gateRootKindOf(split) {
7282
+ // APRV-522: case folded, as the credential rule is, for a case-insensitive
7283
+ // volume (`grep -r . .APPROVAL`).
7284
+ const segments = split
7285
+ .filter((segment) => segment.length > 0)
7286
+ .map((segment) => segment.toLowerCase());
5369
7287
  const last = segments[segments.length - 1];
5370
7288
  const isHome = (at) => segments[at] === ".hermes" ||
5371
7289
  (segments[at - 2] === ".hermes" && segments[at - 1] === "profiles" && segments[at] !== undefined);
@@ -5381,9 +7299,23 @@ function gateRootKind(directory) {
5381
7299
  return "scripts";
5382
7300
  return null;
5383
7301
  }
5384
- /** A glob or an unknown name written here may be an organ (SF1b). */
7302
+ /**
7303
+ * A directory every write inside which is `policy.core` (APRV-511 refuter S2):
7304
+ * a `policy.core` directory other than a home root, whose own paths are judged
7305
+ * one by one.
7306
+ */
7307
+ function isOrganDirectory(directory) {
7308
+ const kind = gateRootKind(directory);
7309
+ return kind !== "home" && protectedPathClass(directory) === "policy.core";
7310
+ }
7311
+ /**
7312
+ * A glob or an unknown name written here may be an organ (SF1b). Since
7313
+ * APRV-511 that includes any directory that is itself `policy.core`
7314
+ * (`agent-hooks/`, `hermes-agent/`, `plugins/` and everything under them),
7315
+ * since every name written into one is.
7316
+ */
5385
7317
  function writesIntoGateRoot(directory) {
5386
- return gateRootKind(directory) !== null;
7318
+ return gateRootKind(directory) !== null || protectedPathClass(directory) === "policy.core";
5387
7319
  }
5388
7320
  /**
5389
7321
  * A directory a recursive read of which reads credentials (SF1a): the home
@@ -5396,12 +7328,15 @@ function holdsCredentials(directory) {
5396
7328
  const kind = gateRootKind(directory);
5397
7329
  if (kind === "home" || kind === "approval-home" || kind === "home-approval")
5398
7330
  return true;
5399
- if (!directory.split(/[/\\]+/u).some((segment) => segment === ".hermes" || segment === ".approval"))
7331
+ if (!directory
7332
+ .split(/[/\\]+/u)
7333
+ .some((segment) => [".hermes", ".approval"].includes(segment.toLowerCase())))
5400
7334
  return false;
5401
7335
  return CREDENTIAL_FILE_NAMES.some((name) => existsSync(join(directory, name)));
5402
7336
  }
5403
7337
  const CREDENTIAL_FILE_NAMES = [
5404
7338
  ".env",
7339
+ ".op.env",
5405
7340
  "auth.json",
5406
7341
  "config.yaml",
5407
7342
  "config.yml",
@@ -5434,6 +7369,19 @@ const WRITE_EVERY_OPERAND = new Set([
5434
7369
  const WRITE_LAST_OPERAND = new Set(["cp", "mv", "ln", "install", "rsync"]);
5435
7370
  /** Binaries whose first operand is a mode or owner and the rest are paths. */
5436
7371
  const WRITE_AFTER_FIRST_OPERAND = new Set(["chmod", "chown", "chgrp"]);
7372
+ /**
7373
+ * Binaries whose effect on the file system this pass models, or which have
7374
+ * none beyond their redirections (APRV-511 fix round 3, M(L5c-1)): after any
7375
+ * other non-read segment, a later `..` out of a directory is unknowable.
7376
+ */
7377
+ const EFFECT_MODELED_BINS = new Set([
7378
+ "touch", "truncate", "mkdir", "rm", "rmdir", "shred", "tee", "unlink",
7379
+ "cp", "mv", "ln", "install", "rsync", "chmod", "chown", "chgrp", "sed", "dd", "tar", "unzip", "curl", "wget",
7380
+ "cd", "pushd", "popd", "echo", "printf", "cat", "true", "false", ":", "test", "[", "sleep", "date", "head",
7381
+ "tail", "grep", "egrep", "fgrep", "rg", "sort", "uniq", "wc", "jq", "cut", "tr", "ls", "pwd", "basename",
7382
+ "dirname", "realpath", "readlink", "stat", "file", "diff", "cmp", "seq", "base64", "xxd", "od", "sha256sum",
7383
+ "shasum", "md5sum", "nl", "column", "rev", "tac", "paste", "comm",
7384
+ ]);
5437
7385
  /** Readers that walk a directory they are handed. */
5438
7386
  const RECURSIVE_READERS = new Set([
5439
7387
  "grep", "egrep", "fgrep", "rg", "ag", "ack", "find", "tar", "zip", "du", "tree",
@@ -5459,6 +7407,106 @@ function canonicalPath(path) {
5459
7407
  }
5460
7408
  }
5461
7409
  }
7410
+ /** The symlink hops the kernel follows before it answers ELOOP (Linux and macOS: 40 / 32). */
7411
+ const KERNEL_LINK_HOPS = 40;
7412
+ /**
7413
+ * `from` and `text` as ONE absolute spelling, with every `..` kept (APRV-511
7414
+ * fix round 3, M(L5c-1)). Joined by string, never by `path.resolve` or `join`,
7415
+ * because both cancel `k/..` before anything can ask what `k` is.
7416
+ */
7417
+ function spelledAbsolute(from, text) {
7418
+ return isAbsolute(text) ? text : `${from.replace(/\/+$/u, "")}/${text}`;
7419
+ }
7420
+ /**
7421
+ * Where the KERNEL lands an absolute spelling (APRV-511 fix round 3, M(L5c-1)).
7422
+ *
7423
+ * Node's `realpathSync` (and {@link canonicalPath}, built on it) normalises
7424
+ * `..` textually first, so `k/../.op.env` with `k -> ~/.hermes/skills` is judged
7425
+ * as `./.op.env` while the shell opens `~/.hermes/.op.env`. Here each component
7426
+ * is looked up in order as the kernel does: a symlink is replaced by its
7427
+ * target (a relative one resolved against the link's own directory), and `..`
7428
+ * steps to the parent of what has been RESOLVED so far. Past the first
7429
+ * component that does not exist (or cannot be read), the rest is applied
7430
+ * lexically. `..` after a link to a FILE (where the kernel says ENOTDIR) steps
7431
+ * to the file's directory: an answer for a path the shell refuses to open can
7432
+ * only be stricter than none.
7433
+ *
7434
+ * `null` means the landing is unknowable: a link loop past
7435
+ * {@link KERNEL_LINK_HOPS}, or a `/proc/<pid>/{cwd,root,fd,…}` handle, which
7436
+ * names a directory of the process that RUNS the command (the agent's shell),
7437
+ * not of this hook. With `volatile` (the shell pass), a `..` is unknowable too
7438
+ * when what it climbs out of is something an earlier segment of the same
7439
+ * command writes, links, moves, deletes or extracts into (whether or not it
7440
+ * exists yet), or follows a program whose effects that pass does not model:
7441
+ * `ln -s ~/.hermes/skills k && cat k/../.op.env` plants the link this hook
7442
+ * cannot see, and `..` out of it lands wherever the link will point.
7443
+ */
7444
+ function kernelLanding(spelled, volatile = null) {
7445
+ const queue = spelled.split("/").filter((part) => part.length > 0 && part !== ".");
7446
+ if (queue[0] === "proc" &&
7447
+ queue[1] !== undefined &&
7448
+ /^(?:self|thread-self|\d+)$/u.test(queue[1]) &&
7449
+ (queue[2] === undefined || /^(?:cwd|root|fd|fdinfo|map_files|task|exe)$/u.test(queue[2]))) {
7450
+ return null;
7451
+ }
7452
+ let resolved = "/";
7453
+ let missing = false;
7454
+ let hops = 0;
7455
+ while (queue.length > 0) {
7456
+ const part = queue.shift();
7457
+ if (part === "..") {
7458
+ if (volatile !== null && volatile(resolved))
7459
+ return null;
7460
+ resolved = dirname(resolved);
7461
+ continue;
7462
+ }
7463
+ const next = resolved === "/" ? `/${part}` : `${resolved}/${part}`;
7464
+ if (missing) {
7465
+ resolved = next;
7466
+ continue;
7467
+ }
7468
+ let link = null;
7469
+ try {
7470
+ const stat = lstatSync(next);
7471
+ if (stat.isSymbolicLink())
7472
+ link = readlinkSync(next);
7473
+ }
7474
+ catch {
7475
+ // Absent, unreadable (a 0700 home under co-location), or under a file:
7476
+ // the rest of the spelling is applied lexically.
7477
+ missing = true;
7478
+ resolved = next;
7479
+ continue;
7480
+ }
7481
+ if (link === null) {
7482
+ resolved = next;
7483
+ continue;
7484
+ }
7485
+ hops += 1;
7486
+ if (hops > KERNEL_LINK_HOPS)
7487
+ return null;
7488
+ const parts = link.split("/").filter((piece) => piece.length > 0 && piece !== ".");
7489
+ if (link.startsWith("/"))
7490
+ resolved = "/";
7491
+ queue.unshift(...parts);
7492
+ }
7493
+ return resolved;
7494
+ }
7495
+ /**
7496
+ * Both readings of where an absolute spelling lands (APRV-511 fix round 3,
7497
+ * M(L5c-1)): the LEXICAL one this hook has always judged (`..` cancelled
7498
+ * first, then the realpath of the deepest existing ancestor) and the KERNEL one
7499
+ * ({@link kernelLanding}). Every caller judges each and keeps the stricter
7500
+ * class; `unknown` says the kernel reading could not be placed, which the
7501
+ * caller answers with the stricter class outright.
7502
+ */
7503
+ function landingPaths(spelled, volatile = null) {
7504
+ const lexical = canonicalPath(resolvePathSegments(spelled));
7505
+ const kernel = kernelLanding(spelled, volatile);
7506
+ if (kernel === null)
7507
+ return { paths: [lexical], unknown: true };
7508
+ return { paths: kernel === lexical ? [lexical] : [lexical, kernel], unknown: false };
7509
+ }
5462
7510
  /** The environment variable that turns the sandbox requirement on (APRV-193). */
5463
7511
  export const REQUIRE_SANDBOX_ENV = "APPROVAL_HOOK_REQUIRE_SANDBOX";
5464
7512
  /**
@@ -5496,7 +7544,10 @@ export function sandboxRequirement(segments, autonomies, env = process.env) {
5496
7544
  return null;
5497
7545
  const first = unwrapped[0];
5498
7546
  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.`;
7547
+ // Refuter S1: an `exec.code` segment is named by its program's hash, never
7548
+ // by its text (the segment text IS the program for an inline form).
7549
+ const named = first.execCode === undefined ? JSON.stringify(first.text) : describeProgram(first.execCode);
7550
+ 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
7551
  }
5501
7552
  /**
5502
7553
  * Is a window open over this log?
@@ -5606,7 +7657,11 @@ decidedOn) {
5606
7657
  // described under harness.tool.unmapped and the bypass records it: a
5607
7658
  // window being open does not let a call run unrecorded.
5608
7659
  unclaimedStanding(scope.options, cwd, input.toolName, decidedOn?.records ?? null).kind ===
5609
- "ungated");
7660
+ "ungated",
7661
+ // APRV-510: the window does not reach `exec.code`. Opening the class is a
7662
+ // policy statement, and the window suspends the policy; an opaque program is
7663
+ // the case this path already names as one nothing here can vouch for.
7664
+ closedExecCodeGate("window"));
5610
7665
  if (described.kind === "deny") {
5611
7666
  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
7667
  }
@@ -5902,8 +7957,23 @@ function* runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5902
7957
  // appends on this path and no gate lifecycle opens. An OPEN WINDOW does not
5903
7958
  // reach it either, which is the strict reading a window deserves here: a
5904
7959
  // window suspends the policy, and this refusal is not a policy question.
7960
+ //
7961
+ // APRV-510: the ONE exception is a policy declaring both `exec.code` keys
7962
+ // (`custody: attested` and `execute_code: hooked`). That call goes on to be
7963
+ // classified `exec.code` with its program's hash bound; the policy is read
7964
+ // here for this one tool, and a policy that does not load opens nothing.
5905
7965
  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);
7966
+ const early = hookScope(parsed.flags, cwd);
7967
+ const loaded = loadPolicy(early.options.policy?.file === undefined
7968
+ ? { dir: early.options.policy?.dir ?? cwd }
7969
+ : { file: early.options.policy.file });
7970
+ const gate = execGateOf(adapter, loaded.ok ? loaded.policy : null);
7971
+ if (!gate.executeCode) {
7972
+ if (gate.closed === "unattested") {
7973
+ 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);
7974
+ }
7975
+ return deny(streams, HERMES_EXECUTE_CODE_REFUSAL, executeCodeRefusal(gate.shell ? "unhooked-attested" : "unhooked", adapter.shellTool), adapter.kind);
7976
+ }
5907
7977
  }
5908
7978
  // APRV-415, and placed here for the reason the two refusals above are: before
5909
7979
  // the open window, the gate-self path, the carry and the registration, because
@@ -6075,7 +8145,7 @@ function* decideHarnessSteps(decide) {
6075
8145
  // both paths since APRV-214 (see `describeToolCall`): the open window
6076
8146
  // classifies exactly as the closed one does, and a second copy of this would
6077
8147
  // be a second answer to "what is this command".
6078
- const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots, load.policy);
8148
+ const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots, load.policy, true, execGateOf(adapter, load.policy));
6079
8149
  if (described.kind === "deny") {
6080
8150
  return { permission: "deny", code: described.code, detail: described.detail };
6081
8151
  }