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.
- package/SPEC.md +13 -2
- package/dist/src/channels/telegram.d.ts +105 -31
- package/dist/src/channels/telegram.js +157 -81
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/channel-telegram.d.ts +100 -35
- package/dist/src/cli/channel-telegram.js +216 -119
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/doctor.js +33 -13
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/help.js +3 -3
- package/dist/src/cli/hook-exec-code.d.ts +150 -0
- package/dist/src/cli/hook-exec-code.js +357 -0
- package/dist/src/cli/hook-exec-code.js.map +1 -0
- package/dist/src/cli/hook.d.ts +125 -1
- package/dist/src/cli/hook.js +2177 -107
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/command-class.d.ts +349 -1
- package/dist/src/core/command-class.js +2347 -87
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/exec-code.d.ts +77 -0
- package/dist/src/core/exec-code.js +60 -0
- package/dist/src/core/exec-code.js.map +1 -0
- package/dist/src/core/git-push-argv.d.ts +111 -0
- package/dist/src/core/git-push-argv.js +375 -0
- package/dist/src/core/git-push-argv.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +71 -30
- package/dist/src/core/harness-version.js +101 -34
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +8 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/docs/README-extended.md +3 -2
- package/docs/cli-reference.md +20 -7
- package/package.json +2 -2
- package/schema/event.schema.json +1 -1
- package/schema/fixtures/policy/invalid/exec-code-custody-boolean.json +11 -0
- package/schema/fixtures/policy/invalid/exec-code-execute-code-value.json +11 -0
- package/schema/fixtures/policy/invalid/exec-code-unknown-key.json +12 -0
- package/schema/fixtures/policy/valid/exec-code-both.json +17 -0
- package/schema/fixtures/policy/valid/exec-code-custody.json +11 -0
- package/schema/policy.schema.json +23 -0
package/dist/src/cli/hook.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
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
|
-
|
|
5102
|
-
|
|
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) =>
|
|
5107
|
-
|
|
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
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
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
|
-
|
|
5197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5208
|
-
|
|
5209
|
-
//
|
|
5210
|
-
|
|
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
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5226
|
-
|
|
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 =
|
|
6667
|
+
const intoDirectory = single ||
|
|
6668
|
+
targetDir !== null ||
|
|
5232
6669
|
destination.endsWith("/") ||
|
|
5233
6670
|
(!expanded.unresolved &&
|
|
5234
|
-
cwds.some((from) =>
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
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 (
|
|
5333
|
-
return
|
|
5334
|
-
|
|
5335
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|