@sema-agent/core 7.24.2 → 7.24.3

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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * ESTABLISHMENT — the filesystem-resolving half of the shell read boundary, as one value both shell
3
+ * faces hold. The lexical pass (`bash-readonly-classifier.ts`) decides WHICH words a command may read;
4
+ * this module decides WHERE those words land once the filesystem has answered: canonical targets, glob
5
+ * expansions, and the fail-closed arm for what could not be established. It is the seam the read
6
+ * judgment pipeline's `establish` step is built on; the candidate contract it will be fed from lives in
7
+ * `shell-read-plan.ts`.
8
+ *
9
+ * Extracted verbatim from `fs-bash.ts` (the `createCanonicalReadBoundary` family and its glob matcher):
10
+ * behaviour is byte-for-byte the tool's own, and every property stated below is the property the tool
11
+ * always held. Zero imports from `fs-bash.ts` (a leaf the shell faces import, never the reverse).
12
+ */
13
+ import type { ExecutionEnv } from "../../internal/harness-types.js";
14
+ import type { ReadDenySeat } from "./read-deny.js";
15
+ /**
16
+ * How many directories one glob operand may enumerate, and how many names it may match, before
17
+ * the leg gives up and refuses.
18
+ *
19
+ * Sized so the everyday shapes never hit them: 256 directories covers a two-level pattern over a source
20
+ * tree's top level, and 8192 names covers `ls *` in a dependency directory an order of magnitude larger than a
21
+ * normal one. Past those the answer is a REFUSAL naming the bound, never a silent allow — an operand
22
+ * this leg could not finish enumerating is exactly an operand whose expansion it cannot vouch for.
23
+ */
24
+ export declare const MAX_GLOB_DIRS = 256;
25
+ export declare const MAX_GLOB_MATCHES = 8192;
26
+ /**
27
+ * The SYMLINK-RESOLVING half of the `bash_readonly` read boundary.
28
+ *
29
+ * {@link classifySimpleCommandReadBoundary} is synchronous and pure, so its containment is lexical: it
30
+ * judges the written spelling, while the kernel opens the symlink-resolved target. The two diverge in
31
+ * both directions, and both are closed here — this is the caller that holds an {@link ExecutionEnv}:
32
+ * · LEAK — a symlink inside a root pointing out of it (`<root>/vendor` → `/etc`) has an in-root
33
+ * prefix, so `cat vendor/passwd` was auto-allowed and really read `/etc/passwd`. Every candidate
34
+ * the lexical pass accepted ({@link CompoundReadonlyVerdict.checkedPaths}) is canonicalized and
35
+ * re-compared; one that leaves the roots is refused.
36
+ * · FALSE REFUSAL — a non-canonical spelling of an in-root path (a symlinked `/tmp` → `/private/tmp`
37
+ * prefix, or any aliased root) does not textually match the canonical roots. A refusal whose paths
38
+ * ALL canonicalize back inside is dropped.
39
+ *
40
+ * Three properties this deliberately keeps:
41
+ * 1. A NON-EXISTENT target must not become a boundary refusal — `cat nope.txt` has to keep producing
42
+ * the shell's own "No such file", or the boundary would break ordinary use. The deepest EXISTING
43
+ * ancestor is canonicalized and the missing tail rejoined (the rule safety.ts's `canonicalizeNewPath`
44
+ * already uses for the structured tools), so a symlinked PARENT still cannot carry across the target out.
45
+ * 2. Nothing here can manufacture a refusal out of an unanswerable filesystem: a path whose whole
46
+ * ancestor chain fails to canonicalize degrades to the lexical verdict. (`/` resolves on any sane
47
+ * env, so this is a defensive arm, not a routine one.)
48
+ * 3. An ISOLATED env stays lexical, disclosed rather than silently degraded: the sandbox itself is the
49
+ * containment there, and `canonicalPath` is an RPC into a filesystem that is not this host's. The
50
+ * split is the DECLARED {@link isIsolated} capability, never the remote interface's SHAPE — an
51
+ * adapter driving the operator's own machine (a host lane, an SSH host) implements that same
52
+ * interface and declares no containment, so the resolving pass runs there, on the env's own paths.
53
+ *
54
+ * NOT cached deliberately: a per-run memo keyed on the lexical spelling would answer from a snapshot of
55
+ * where a symlink pointed EARLIER in the run, which is the one thing this check exists to observe. The
56
+ * cost it would save is a `realpath` on a local env. The residual it cannot remove either way is the
57
+ * classic one — the link can be re-pointed between this check and the shell's own open.
58
+ *
59
+ * The GLOB half of the same seam, added here for the same reason (this is where the filesystem
60
+ * is). A candidate carrying `*`/`?`/`[…]` is a path that does NOT exist as spelled, so property 1's
61
+ * deepest-existing-ancestor rule resolved `<root>/*` to the root itself and pronounced it inside — after
62
+ * which the real shell expanded the pattern and handed the command every matching entry, including one
63
+ * that is a symlink out of the root. `cat vendor/secret.txt` was refused while `cat *` over the same
64
+ * link was not. Such a candidate is now expanded against the filesystem FIRST (`expandGlobCandidate`,
65
+ * shell-semantics matcher over `env.listDir`) and each expansion resolved individually. Its own
66
+ * properties, on top of the three above:
67
+ * 4. NO MATCH is not a refusal. The shell hands an unmatched pattern to the command verbatim, which then
68
+ * reports its own "No such file" — so an empty expansion falls back to judging the literal spelling,
69
+ * i.e. the literal-spelling answer (which also covers a file literally named `nomatch*`).
70
+ * 5. What it could not enumerate, it REFUSES (`unverifiable`), never silently allows: an unreadable
71
+ * directory, a pattern construct the matcher does not model, or an expansion past
72
+ * {@link MAX_GLOB_DIRS}/{@link MAX_GLOB_MATCHES}. This face has no approval channel to degrade to
73
+ * (see `createBashReadonlyTool`'s own note), so fail-closed is the only honest direction.
74
+ * 6. Cost is paid only by candidates that actually carry a metacharacter, and only past the roots'
75
+ * own spelling (a root literally containing `[` must not be re-globbed). A plain path takes the
76
+ * same single `resolve` it always took. Inside one enumerated directory only the SYMLINK entries
77
+ * need their own `realpath` — the directory is canonicalized once and a non-symlink child of a
78
+ * canonical in-root directory is in-root by construction.
79
+ */
80
+ export declare function createCanonicalReadBoundary(env: ExecutionEnv, readRoots: readonly string[]): {
81
+ /** What the read boundary objects to once the filesystem has answered: canonical targets OUTSIDE the roots,
82
+ * the fail-closed arm (`unverifiable` — a candidate this leg could not establish, property 5), and
83
+ * `denied`, the first path the wired deny judge matched. All empty/absent ⇒ no objection.
84
+ *
85
+ * The two input lists are NOT interchangeable, and the split is the lexical pass's answer, never a
86
+ * re-reading of the text: `patterns` are the operands the shell will expand
87
+ * ({@link CompoundReadonlyVerdict.undecidedPaths}), `literal` the ones it will not. Quoting separates them
88
+ * and lives only in the RAW token, which these resolved paths no longer carry — `cat "link*"` reads a file
89
+ * literally named `link*` and must not be re-globbed here. */
90
+ escapesAfterResolution: (paths: {
91
+ literal: readonly string[];
92
+ patterns: readonly string[];
93
+ }, ask: BoundaryQuestions, signal?: AbortSignal) => Promise<{
94
+ escaping: readonly string[];
95
+ unverifiable?: string;
96
+ denied?: {
97
+ path: string;
98
+ pattern: string;
99
+ };
100
+ }>;
101
+ /** Do ALL of `paths` canonicalize back INSIDE the roots? (false for an empty list, and where the sandbox is the containment.) */
102
+ allResolveInside: (paths: readonly string[], signal?: AbortSignal) => Promise<boolean>;
103
+ /** Where one spelling really lands, for a caller deciding a GRANT on something other than the roots.
104
+ * `undefined` where the sandbox is the containment or the chain is unanswerable, and the
105
+ * caller then judges the written spelling — what it had before. */
106
+ canonicalOf: (path: string, signal?: AbortSignal) => Promise<string | undefined>;
107
+ };
108
+ /** The symlink-resolving second pass, as one value both shell faces hold. */
109
+ export type CanonicalReadBoundary = ReturnType<typeof createCanonicalReadBoundary>;
110
+ /** WHICH of the boundary's two halves still has a question once the filesystem can answer. Not a mode and not
111
+ * a face: the face DECIDES these two, separately. Containment is structurally satisfied under the OPEN read
112
+ * face (an out-of-roots operand rides `checkedPaths` there as an ACCEPTED candidate); the deny half judges in
113
+ * BOTH faces by its seat's own contract, so a face-level exit takes it along with the other half. */
114
+ export interface BoundaryQuestions {
115
+ readonly deny: ReadDenySeat["denyMatch"];
116
+ readonly containment: boolean;
117
+ /** Per HALF, the paths this face must NOT re-judge, because the approver was asked THAT question about
118
+ * THAT path and said yes. Two sets, not one, and the difference is a leak: "release this deny row" is
119
+ * not "release wherever it lands" (a deny-listed name that is a symlink OUT of the roots ran, and
120
+ * ADDING the deny row is what turned the refusal off), and "read outside the roots" says nothing about
121
+ * the deny table the target is on. Keyed on the folded spelling the card named; empty = nothing
122
+ * answered. */
123
+ readonly denyAnswered: ReadonlySet<string>;
124
+ readonly containmentAnswered: ReadonlySet<string>;
125
+ }
126
+ /** The empty half-answer, for a face with no approval channel to have been answered through. */
127
+ export declare const NOTHING_ANSWERED: ReadonlySet<string>;
@@ -0,0 +1,326 @@
1
+ import { normalizeAbsPathLexically, withinAnyRoot } from "./safety.js";
2
+ import { boundaryIsTheSandbox, canonicalPathIsAuthoritative } from "../../core/remote-env.js";
3
+ const GLOB_METACHAR_RE = /[*?[]/;
4
+ export const MAX_GLOB_DIRS = 256;
5
+ export const MAX_GLOB_MATCHES = 8192;
6
+ function compileGlobSegment(pattern) {
7
+ if (pattern.includes("\\"))
8
+ return undefined;
9
+ let src = "";
10
+ for (let i = 0; i < pattern.length; i++) {
11
+ const ch = pattern[i];
12
+ if (ch === "*") {
13
+ src += "[^/]*";
14
+ }
15
+ else if (ch === "?") {
16
+ src += "[^/]";
17
+ }
18
+ else if (ch === "[") {
19
+ const cls = parseBracketExpression(pattern, i);
20
+ if (cls === undefined)
21
+ src += "\\[";
22
+ else {
23
+ src += cls.source;
24
+ i = cls.end;
25
+ }
26
+ }
27
+ else {
28
+ src += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
+ }
30
+ }
31
+ try {
32
+ return new RegExp(`^${src}$`, "s");
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ function parseBracketExpression(pattern, start) {
39
+ let i = start + 1;
40
+ let negate = false;
41
+ if (pattern[i] === "!" || pattern[i] === "^") {
42
+ negate = true;
43
+ i++;
44
+ }
45
+ const contentStart = i;
46
+ let body = "";
47
+ let anyChar = false;
48
+ for (; i < pattern.length; i++) {
49
+ const ch = pattern[i];
50
+ if (ch === "]" && i > contentStart) {
51
+ if (body.length === 0 && !anyChar)
52
+ return undefined;
53
+ return { source: anyChar ? "[^/]" : `[${negate ? "^" : ""}${body}]`, end: i };
54
+ }
55
+ if (ch === "[" && (pattern[i + 1] === ":" || pattern[i + 1] === "." || pattern[i + 1] === "=")) {
56
+ const close = pattern.indexOf(`${pattern[i + 1]}]`, i + 2);
57
+ if (close < 0)
58
+ return undefined;
59
+ anyChar = true;
60
+ i = close + 1;
61
+ continue;
62
+ }
63
+ body += ch === "-" ? "-" : ch.replace(/[\]\\^]/g, "\\$&");
64
+ }
65
+ return undefined;
66
+ }
67
+ function globSegmentMatches(re, pattern, name) {
68
+ if (name.startsWith(".") && !pattern.startsWith("."))
69
+ return false;
70
+ return re.test(name);
71
+ }
72
+ function joinCandidate(dir, name) {
73
+ return `${dir.replace(/[\\/]+$/, "")}/${name}`;
74
+ }
75
+ function pathSegmentsFrom(p, from) {
76
+ const segs = [];
77
+ let scan = from;
78
+ while (scan < p.length) {
79
+ while (scan < p.length && (p[scan] === "/" || p[scan] === "\\"))
80
+ scan++;
81
+ const s = scan;
82
+ while (scan < p.length && p[scan] !== "/" && p[scan] !== "\\")
83
+ scan++;
84
+ if (scan > s)
85
+ segs.push({ text: p.slice(s, scan), start: s });
86
+ }
87
+ return segs;
88
+ }
89
+ export function createCanonicalReadBoundary(env, readRoots) {
90
+ const lexicalOnly = boundaryIsTheSandbox(env);
91
+ const resolverIsAuthoritative = canonicalPathIsAuthoritative(env);
92
+ let rootsOnce;
93
+ const canonicalRoots = async (signal) => {
94
+ rootsOnce ??= (async () => {
95
+ const out = [];
96
+ for (const r of readRoots) {
97
+ const c = await env.canonicalPath(r, signal);
98
+ out.push(c.ok ? c.value : r);
99
+ }
100
+ return out;
101
+ })();
102
+ return await rootsOnce;
103
+ };
104
+ const resolveWithoutParentSteps = async (p, signal) => {
105
+ const parts = p.split(/[\\/]/);
106
+ const tail = [];
107
+ let allAbsent = true;
108
+ while (parts.length > 0) {
109
+ const canon = await env.canonicalPath(parts.join("/") || "/", signal);
110
+ if (canon.ok) {
111
+ if (tail.length === 0)
112
+ return { value: canon.value, exact: true };
113
+ return { value: [canon.value.replace(/[\\/]+$/, ""), ...[...tail].reverse()].join("/"), exact: false, missing: allAbsent };
114
+ }
115
+ if (canon.error.code !== "not_found" && canon.error.code !== "not_directory")
116
+ allAbsent = false;
117
+ tail.push(parts.pop());
118
+ }
119
+ return undefined;
120
+ };
121
+ const parentOf = (canonical) => normalizeAbsPathLexically(`${canonical}/..`);
122
+ const resolve = async (p, signal) => {
123
+ const parts = p.split(/[\\/]/);
124
+ const first = parts.findIndex((seg) => seg === "..");
125
+ if (first < 0)
126
+ return await resolveWithoutParentSteps(p, signal);
127
+ const headEnd = parts.slice(0, first).reduce((n, seg) => n + seg.length + 1, 0);
128
+ let head = await resolveWithoutParentSteps(p.slice(0, headEnd) || "/", signal);
129
+ if (head === undefined)
130
+ return undefined;
131
+ let pending = [];
132
+ const flush = async () => {
133
+ if (pending.length === 0)
134
+ return true;
135
+ const next = await resolveWithoutParentSteps([head.value.replace(/[\\/]+$/, ""), ...pending].join("/"), signal);
136
+ pending = [];
137
+ if (next === undefined)
138
+ return false;
139
+ head = next;
140
+ return true;
141
+ };
142
+ for (let i = first; i < parts.length; i++) {
143
+ const seg = parts[i];
144
+ if (seg === "" || seg === ".")
145
+ continue;
146
+ if (seg !== "..") {
147
+ pending.push(seg);
148
+ continue;
149
+ }
150
+ if (!(await flush()))
151
+ return undefined;
152
+ if (!head.exact)
153
+ return head;
154
+ head = { value: parentOf(head.value), exact: true };
155
+ }
156
+ return (await flush()) ? head : undefined;
157
+ };
158
+ const literalRootHead = (p, roots) => {
159
+ let best;
160
+ for (const r of [...readRoots, ...roots]) {
161
+ if (!r)
162
+ continue;
163
+ const withSep = r.endsWith("/") || r.endsWith("\\") ? r : `${r}/`;
164
+ if (p !== r && !p.startsWith(withSep))
165
+ continue;
166
+ if (best === undefined || r.length > best.length)
167
+ best = r;
168
+ }
169
+ return best;
170
+ };
171
+ const expandGlobCandidate = async (p, roots, signal) => {
172
+ const head = literalRootHead(p, roots);
173
+ let segs = pathSegmentsFrom(p, head?.length ?? 0);
174
+ let first = segs.findIndex((s) => GLOB_METACHAR_RE.test(s.text));
175
+ if (first < 0 && head !== undefined && GLOB_METACHAR_RE.test(head)) {
176
+ segs = pathSegmentsFrom(p, 0);
177
+ first = segs.findIndex((s) => GLOB_METACHAR_RE.test(s.text));
178
+ }
179
+ if (first < 0)
180
+ return undefined;
181
+ let frontier = [p.slice(0, segs[first].start).replace(/[\\/]+$/, "") || "/"];
182
+ let dirsUsed = 0;
183
+ let matches = 0;
184
+ const canonicalOfMatch = new Map();
185
+ for (let j = first; j < segs.length; j++) {
186
+ const seg = segs[j].text;
187
+ if (!GLOB_METACHAR_RE.test(seg)) {
188
+ frontier = frontier.map((d) => joinCandidate(d, seg));
189
+ continue;
190
+ }
191
+ const re = compileGlobSegment(seg);
192
+ if (re === undefined) {
193
+ return { unverifiable: `the pattern "${seg}" cannot be expanded to verify what it would read` };
194
+ }
195
+ const isLast = j === segs.length - 1;
196
+ const next = [];
197
+ for (const dir of frontier) {
198
+ if (++dirsUsed > MAX_GLOB_DIRS) {
199
+ return { unverifiable: `"${p}" expands over more than ${MAX_GLOB_DIRS} directories, too many to enumerate to verify` };
200
+ }
201
+ const canonDir = await resolve(dir, signal);
202
+ if (canonDir !== undefined && canonDir.missing === true)
203
+ continue;
204
+ if (canonDir !== undefined && !canonDir.exact) {
205
+ return { unverifiable: `"${dir}" cannot be resolved to verify what "${seg}" would expand to` };
206
+ }
207
+ const listed = await env.listDir(canonDir?.value ?? dir, signal);
208
+ if (!listed.ok) {
209
+ if (listed.error.code === "not_found" || listed.error.code === "not_directory")
210
+ continue;
211
+ return { unverifiable: `"${dir}" cannot be enumerated to verify what "${seg}" would expand to (${listed.error.code})` };
212
+ }
213
+ if (seg.startsWith(".")) {
214
+ for (const dot of [".", ".."])
215
+ if (re.test(dot))
216
+ next.push(joinCandidate(dir, dot));
217
+ }
218
+ const canonDirHere = isLast ? canonDir?.value : undefined;
219
+ for (const entry of listed.value) {
220
+ if (!globSegmentMatches(re, seg, entry.name))
221
+ continue;
222
+ if (++matches > MAX_GLOB_MATCHES) {
223
+ return { unverifiable: `"${p}" expands to more than ${MAX_GLOB_MATCHES} names, too many to enumerate to verify` };
224
+ }
225
+ const candidate = joinCandidate(dir, entry.name);
226
+ if (canonDirHere !== undefined && entry.kind !== "symlink")
227
+ canonicalOfMatch.set(candidate, joinCandidate(canonDirHere, entry.name));
228
+ next.push(candidate);
229
+ }
230
+ }
231
+ frontier = next;
232
+ if (frontier.length === 0)
233
+ return { paths: [], canonical: canonicalOfMatch };
234
+ }
235
+ return { paths: frontier, canonical: canonicalOfMatch };
236
+ };
237
+ return {
238
+ escapesAfterResolution: async (paths, ask, signal) => {
239
+ const deny = ask.deny;
240
+ if (lexicalOnly || (paths.literal.length === 0 && paths.patterns.length === 0))
241
+ return { escaping: [] };
242
+ const roots = await canonicalRoots(signal);
243
+ const escaping = [];
244
+ let unresolvable;
245
+ let denied;
246
+ const denyHit = (p) => {
247
+ if (deny === undefined || denied !== undefined)
248
+ return false;
249
+ const folded = normalizeAbsPathLexically(p);
250
+ if (ask.denyAnswered.has(folded))
251
+ return false;
252
+ const pattern = deny(folded);
253
+ if (pattern === null)
254
+ return false;
255
+ denied = { path: folded, pattern };
256
+ return true;
257
+ };
258
+ const judge = async (p, canonicalKnown, origin) => {
259
+ if (denyHit(p))
260
+ return;
261
+ const real = canonicalKnown !== undefined ? { value: canonicalKnown, exact: true } : await resolve(p, signal);
262
+ if (real === undefined) {
263
+ if (resolverIsAuthoritative)
264
+ unresolvable ??= `"${p}" cannot be resolved to verify where it reads`;
265
+ return;
266
+ }
267
+ if (!real.exact && real.missing !== true) {
268
+ unresolvable ??= `"${p}" cannot be resolved to verify where it reads`;
269
+ return;
270
+ }
271
+ if (denyHit(real.value))
272
+ return;
273
+ if (ask.containmentAnswered.has(normalizeAbsPathLexically(origin ?? p)))
274
+ return;
275
+ if (ask.containment && !withinAnyRoot(roots, real.value) && !escaping.includes(real.value))
276
+ escaping.push(real.value);
277
+ };
278
+ for (const p of paths.literal) {
279
+ if (denied !== undefined)
280
+ break;
281
+ await judge(p);
282
+ }
283
+ for (const p of paths.patterns) {
284
+ if (denied !== undefined)
285
+ break;
286
+ const expanded = await expandGlobCandidate(p, roots, signal);
287
+ if (expanded !== undefined && "unverifiable" in expanded) {
288
+ unresolvable ??= expanded.unverifiable;
289
+ continue;
290
+ }
291
+ if (expanded !== undefined && expanded.paths.length > 0) {
292
+ for (const t of expanded.paths) {
293
+ if (denied !== undefined)
294
+ break;
295
+ await judge(t, expanded.canonical.get(t), p);
296
+ }
297
+ }
298
+ else
299
+ await judge(p);
300
+ }
301
+ if (denied !== undefined)
302
+ return { escaping, denied };
303
+ return unresolvable === undefined ? { escaping } : { escaping, unverifiable: unresolvable };
304
+ },
305
+ canonicalOf: async (path, signal) => {
306
+ if (lexicalOnly)
307
+ return undefined;
308
+ const real = await resolve(path, signal);
309
+ return real === undefined || (!real.exact && real.missing !== true) ? undefined : real.value;
310
+ },
311
+ allResolveInside: async (paths, signal) => {
312
+ if (lexicalOnly || paths.length === 0)
313
+ return false;
314
+ const roots = await canonicalRoots(signal);
315
+ for (const p of paths) {
316
+ const real = await resolve(p, signal);
317
+ if (real === undefined || (!real.exact && real.missing !== true))
318
+ return false;
319
+ if (!withinAnyRoot(roots, real.value))
320
+ return false;
321
+ }
322
+ return true;
323
+ },
324
+ };
325
+ }
326
+ export const NOTHING_ANSWERED = new Set();
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `planShellReads` — ONE parse of a shell command into the read candidates every shell face judges from.
3
+ *
4
+ * The read boundary today re-derives "which words may be read" at several sites (the compound walk, the
5
+ * containment-only gate, the refused-command whitelist, the execute-time recheck), each with its own idea
6
+ * of launchers, option slots, redirections and re-entry. This module is the single extraction those sites
7
+ * are to consume: a pure, zero-I/O function over the lenient lexer (`bash-lexer.ts`) and the program-position
8
+ * reader (`bash-program-position.ts`), returning every path word a command MAY read, each tagged with its
9
+ * PROVENANCE (why it is a candidate) and the working-directory candidates it is relative to. Establishment
10
+ * (`shell-read-establish.ts`) and judgment are downstream; nothing here decides allow/ask/deny.
11
+ *
12
+ * Contract, in one sentence per rule:
13
+ * · a LISTED reader hands its positional operands and the declared option-value slots (the verb tables of
14
+ * `bash-readonly-classifier.ts`, read here through their exported readers, never copied);
15
+ * · an UNLISTED program hands every word of its argv (a program declared to open no path operand hands none);
16
+ * · a LAUNCHER chain is peeled to its program and the chain's own words are never candidates; a launcher
17
+ * option the row cannot read is recorded as a decline and every word after it is handed over;
18
+ * · a SHELL re-entry (`sh -c S`, `eval S`, a launcher handing text to a shell, `env -S S`) carries its text as a
19
+ * NESTED plan; an argv re-entry (`xargs`) is a decline unless the target opens nothing it is handed;
20
+ * · an INTERPRETER's inline program is a shape fact (`reentry:"interpreter"`), never parsed, never a finding;
21
+ * · `<` targets are read candidates, `>`-family targets are write targets (tagged, not read), here-documents
22
+ * and here-strings are data (an UNQUOTED here-document body's substitutions are nested plans);
23
+ * · `$(…)`, backticks and `<(…)` bodies are nested plans; a word carrying one cannot be resolved from the text;
24
+ * · `cd` moves the base the LATER segments resolve against, as a candidate SET (`&&` proves a move, nothing
25
+ * else does); `pushd`/`popd` lose the base; a loop body with a base move is replayed per LITERAL beat and
26
+ * otherwise loses the base for what follows.
27
+ * Every ambiguity resolves toward MORE candidates, never fewer.
28
+ */
29
+ import { type LenientConnector, type LexedRedirect, type LexedWord } from "./bash-lexer.js";
30
+ import { type ShellScanDecline } from "./bash-program-position.js";
31
+ /** WHY a word is a read candidate — a closed set, registered in `docs/CLOSED-SETS.md`. */
32
+ export declare const READ_CANDIDATE_PROVENANCES: readonly ["operand", "option_value", "stdin_redirect", "write_redirect_target", "env_assignment", "unlisted_word", "traversal_root", "cd_target", "program_path", "script_slot_file"];
33
+ export type ReadCandidateProvenance = (typeof READ_CANDIDATE_PROVENANCES)[number];
34
+ /** What the caller declares about the deployment: the listed readers and the boundary the bases start from. */
35
+ export interface ShellReadDeclarations {
36
+ /** The listed read verbs. They decide whether a segment is read by verb slots or word by word, never whether it hands candidates. */
37
+ readonly readers: ReadonlySet<string>;
38
+ /** Substituted for `$HOME` / `${HOME}` word spellings before lexing; `~` itself is left to establishment. */
39
+ readonly homeDir?: string;
40
+ /** The working directory the first segment's relative words resolve against. */
41
+ readonly cwd: string;
42
+ readonly roots: readonly string[];
43
+ readonly face?: "open" | "roots";
44
+ }
45
+ export interface ReadCandidate {
46
+ /** The folded word (quotes removed, a literal leading tilde spelled `./~`). */
47
+ readonly spelled: string;
48
+ /** The raw spelling, quotes retained — where "is this metacharacter quoted" is still answerable. */
49
+ readonly raw: string;
50
+ readonly provenance: ReadCandidateProvenance;
51
+ /** `"glob"` = an unquoted pathname-expansion character; `"unresolvable"` = the shell rewrites the word
52
+ * (brace/variable/command substitution) so its text is not the path. */
53
+ readonly expands: false | "glob" | "unresolvable";
54
+ /** The working directories this word may be relative to (a candidate SET where a `cd` could not be proven);
55
+ * EMPTY for an absolute or home-rooted spelling; `"unmodelled"` for a relative word in a segment whose base the
56
+ * text does not settle (an unfollowable move before it, a loop this parser cannot count) — it cannot be placed. */
57
+ readonly bases: readonly string[] | "unmodelled";
58
+ }
59
+ /** How the shell reached one segment — what the base-candidate walk needs from the connectors. */
60
+ export interface SegmentReach {
61
+ /** Reached from the previous emitted segment through `&&` only: everything before it exited zero. */
62
+ readonly andJoined: boolean;
63
+ /** The crossing that broke the `&&` run carried a `||`. */
64
+ readonly orBroken: boolean;
65
+ /** A directory change here, once proven to have run, moves the base the later segments see. */
66
+ readonly moveProvable: boolean;
67
+ }
68
+ export interface ShellReadRedirect {
69
+ readonly op: LexedRedirect["op"];
70
+ readonly direction: "in" | "out" | "dup";
71
+ readonly target?: ReadCandidate;
72
+ }
73
+ export interface ShellReadSegment {
74
+ readonly text: string;
75
+ readonly connector: LenientConnector;
76
+ /** Every argv word (structure removed); the shape consumers' input. The read judgment reads `reads` only. */
77
+ readonly argv: readonly LexedWord[];
78
+ /** The launcher chain in front of the program (`env`, `nice`, …) — never candidates. */
79
+ readonly launcherChain: readonly string[];
80
+ readonly verb?: {
81
+ readonly name: string;
82
+ readonly basename: string;
83
+ readonly listed: boolean;
84
+ };
85
+ /** `shell` = the program runs its argument as shell text; `argv` = its operands arrive on a stream;
86
+ * `interpreter` = it runs an inline program in a language this parser does not read (a shape fact only). */
87
+ readonly reentry?: "shell" | "argv" | "interpreter";
88
+ readonly declines: readonly ShellScanDecline[];
89
+ readonly reads: readonly ReadCandidate[];
90
+ readonly redirects: readonly ShellReadRedirect[];
91
+ readonly baseMove?: {
92
+ readonly target: ReadCandidate | "home" | "previous" | "unmodelled";
93
+ };
94
+ /** The working directories this segment may be standing in (what every relative candidate's `bases` is), or
95
+ * `"unmodelled"` when the text does not settle it. */
96
+ readonly bases: readonly string[] | "unmodelled";
97
+ /** Substitution bodies, unquoted here-document substitutions, and re-entered shell text, each its own plan. */
98
+ readonly nested: readonly ShellReadPlan[];
99
+ readonly reach: SegmentReach;
100
+ }
101
+ export interface ShellReadPlan {
102
+ readonly segments: readonly ShellReadSegment[];
103
+ /** The lexer refused the whole text (an unterminated construct, nesting past the bound). UNCONDITIONAL
104
+ * fail-closed for the consumer: a quote-blind re-read yields more words, never zero, so this is not "no candidates". */
105
+ readonly unreadable?: string;
106
+ /** Shape facts the same lex produced, for the shape consumers; the read judgment does not read them. */
107
+ readonly shape: {
108
+ readonly grouped: boolean;
109
+ readonly backgrounded: boolean;
110
+ readonly strayRedirection: boolean;
111
+ };
112
+ }
113
+ export declare function planShellReads(command: string, decl: ShellReadDeclarations, depth?: number): ShellReadPlan;