@sema-agent/core 7.49.3 → 7.49.5

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.
@@ -65,6 +65,11 @@ export type ArgRole =
65
65
  * copy READS its sources; the write face still judges them, as the reference implementation does). Absent: every
66
66
  * seat in {@link seats}. */
67
67
  readonly writtenAt?: readonly WriteSeat[];
68
+ /** A writing effect that writes the path's CONTENT by opening it (`true`), or does so only while the invocation carries
69
+ * no option word (`"plain"`: a copy's `-f`, `-b` and `--remove-destination` replace the entry, `-l` and `-s` link it) —
70
+ * the one kind of write a standard device takes without a containment question (`passesUnasked`). Absent: the program
71
+ * creates, unlinks, renames or retargets the path's ENTRY, or the row does not say it only writes through. */
72
+ readonly through?: true | "plain";
68
73
  /** The program RUNS in this directory (`env -C <dir>`), or with it as its root (`sudo -R <dir>`): the paths it is
69
74
  * handed resolve against it, not against the working directory the command line stands in. Or the program word is
70
75
  * LOOKED UP in these directories (`env -P <dirs>`): which program runs is not the one the word names on the PATH. */
@@ -789,4 +794,14 @@ export declare function messageWords(program: string, words: readonly {
789
794
  readonly expansions: readonly WordPiece[];
790
795
  };
791
796
  }[], programIndex: number): Set<number>;
797
+ /** The most bytes a read may be bounded to and still count as small (`head -c N`): a bound above it is no bound a person would call one. */
798
+ export declare const SMALL_BYTE_BOUND_MAX = 1000000;
799
+ /**
800
+ * Does this invocation read at most a SMALL, LITERAL number of BYTES? The rule is positive: the row's role walk ({@link readArgRoles})
801
+ * must find an option the row says bounds the output in BYTES ({@link OptionSpec.bounds}), valued with a plain integer within
802
+ * {@link SMALL_BYTE_BOUND_MAX}, and NO other option word — a line bound is no bound for a source that holds no newline, and a
803
+ * switch, a spelling the row does not explain or an option-shaped word past an operand makes the invocation unreadable, which is
804
+ * not exempt (the safe side). `false` for a program with no row.
805
+ */
806
+ export declare function byteBoundIsSmall(row: ProgramRow | undefined, args: readonly string[]): boolean;
792
807
  export {};
@@ -1351,3 +1351,27 @@ export function messageWords(program, words, programIndex) {
1351
1351
  }
1352
1352
  return out;
1353
1353
  }
1354
+ export const SMALL_BYTE_BOUND_MAX = 1_000_000;
1355
+ export function byteBoundIsSmall(row, args) {
1356
+ if (row === undefined)
1357
+ return false;
1358
+ let byteBound = false;
1359
+ for (const e of readArgRoles(row, args)) {
1360
+ if (e.kind === "operand" || e.kind === "end-of-options")
1361
+ continue;
1362
+ if (e.kind === "value" && e.spec?.bounds === "bytes") {
1363
+ if (!/^\d+$/.test(args[e.at]) || Number(args[e.at]) > SMALL_BYTE_BOUND_MAX)
1364
+ return false;
1365
+ byteBound = true;
1366
+ continue;
1367
+ }
1368
+ if (e.kind === "option" && typeof e.reading === "object" && e.reading.spec?.bounds === "bytes") {
1369
+ if (!/^\d+$/.test(e.reading.attached) || Number(e.reading.attached) > SMALL_BYTE_BOUND_MAX)
1370
+ return false;
1371
+ byteBound = true;
1372
+ continue;
1373
+ }
1374
+ return false;
1375
+ }
1376
+ return byteBound;
1377
+ }
@@ -1,12 +1,14 @@
1
1
  import { shellWordsOf } from "../../shell/shell-words.js";
2
2
  const READ = { kind: "path", effect: "read" };
3
3
  const WRITE = { kind: "path", effect: "write" };
4
+ const WRITE_THROUGH = { kind: "path", effect: "write", through: true };
4
5
  const CREATED = { kind: "path", effect: "create" };
5
6
  const TOUCHED = { kind: "path", effect: "create", seats: ["landing"] };
6
7
  const REMOVED = { kind: "path", effect: "write", seats: ["entry"] };
7
8
  const COPY_SOURCE = { kind: "path", effect: "write", seats: ["landing", "entry"], writtenAt: [] };
8
9
  const MOVE_SOURCE = { kind: "path", effect: "write", seats: ["landing", "entry"], writtenAt: ["entry"] };
9
- const DESTINATION = { kind: "path", effect: "write", seats: ["landing", "entry"] };
10
+ const MOVE_DESTINATION = { kind: "path", effect: "write", seats: ["landing", "entry"] };
11
+ const COPY_DESTINATION = { kind: "path", effect: "write", seats: ["landing", "entry"], through: "plain" };
10
12
  const EDITED = { kind: "path", effect: "edit-in-place", seats: ["entry"] };
11
13
  const NAMES = { kind: "path", effect: "names" };
12
14
  const INDIRECT = { kind: "path", effect: "read", valueForm: "indirect" };
@@ -203,18 +205,18 @@ const find = {
203
205
  const curl = {
204
206
  key: "curl",
205
207
  grammars: [grammar({
206
- A: val(LABEL), b: val(INDIRECT), C: val(COUNT), c: val(WRITE), D: val(WRITE), d: val(INDIRECT), E: val(READ), e: val(LABEL),
207
- F: val(FORM), H: val(HEADER), K: val(READ), m: val(COUNT), o: val(WRITE), P: val(LABEL), Q: val(LABEL), r: val(COUNT),
208
+ A: val(LABEL), b: val(INDIRECT), C: val(COUNT), c: val(WRITE_THROUGH), D: val(WRITE_THROUGH), d: val(INDIRECT), E: val(READ), e: val(LABEL),
209
+ F: val(FORM), H: val(HEADER), K: val(READ), m: val(COUNT), o: val(WRITE_THROUGH), P: val(LABEL), Q: val(LABEL), r: val(COUNT),
208
210
  T: val(UPLOAD), t: val(LABEL), U: val(LABEL), u: val(LABEL), w: val(INDIRECT), X: val(LABEL), x: val(LABEL), Y: val(COUNT),
209
211
  y: val(COUNT), z: val(READ), g: { arity: 0, literalValues: true },
210
212
  ...switches("#012346aBfGiIjJklLnNOpqRsSvZ"),
211
213
  ...switches("hMV", { mode: "banner" }),
212
214
  }, {
213
- cacert: val(READ), capath: val(READ), cert: val(READ), config: val(READ), "connect-timeout": val(COUNT), cookie: val(INDIRECT), "cookie-jar": val(WRITE),
214
- data: val(INDIRECT), "data-ascii": val(INDIRECT), "data-binary": val(INDIRECT), "data-raw": val(LABEL), "data-urlencode": val(FORM), "dump-header": val(WRITE),
215
- form: val(FORM), "form-string": val(LABEL), header: val(HEADER), key: val(READ), "max-time": val(COUNT), "netrc-file": val(READ), output: val(WRITE),
216
- proxy: val(LABEL), range: val(COUNT), referer: val(LABEL), request: val(LABEL), resolve: val(LABEL), trace: val(WRITE), "time-cond": val(READ),
217
- "trace-ascii": val(WRITE), "upload-file": val(UPLOAD), url: val(URL), user: val(LABEL), "user-agent": val(LABEL), "write-out": val(INDIRECT),
215
+ cacert: val(READ), capath: val(READ), cert: val(READ), config: val(READ), "connect-timeout": val(COUNT), cookie: val(INDIRECT), "cookie-jar": val(WRITE_THROUGH),
216
+ data: val(INDIRECT), "data-ascii": val(INDIRECT), "data-binary": val(INDIRECT), "data-raw": val(LABEL), "data-urlencode": val(FORM), "dump-header": val(WRITE_THROUGH),
217
+ form: val(FORM), "form-string": val(LABEL), header: val(HEADER), key: val(READ), "max-time": val(COUNT), "netrc-file": val(READ), output: val(WRITE_THROUGH),
218
+ proxy: val(LABEL), range: val(COUNT), referer: val(LABEL), request: val(LABEL), resolve: val(LABEL), trace: val(WRITE_THROUGH), "time-cond": val(READ),
219
+ "trace-ascii": val(WRITE_THROUGH), "upload-file": val(UPLOAD), url: val(URL), user: val(LABEL), "user-agent": val(LABEL), "write-out": val(INDIRECT),
218
220
  ...longSwitches(["append", "compressed", "disable", "fail", "get", "head", "include", "insecure", "junk-session-cookies", "list-only", "location", "netrc", "next", "no-buffer", "parallel", "proxytunnel", "remote-header-name", "remote-name", "remote-time", "show-error", "silent", "use-ascii", "verbose"]), ...BANNERS,
219
221
  manual: { arity: 0, mode: "banner" }, globoff: { arity: 0, literalValues: true },
220
222
  })],
@@ -497,6 +499,7 @@ const git = {
497
499
  merge: gitSubcommand("nqve", "S", "mFsX", ["message", "file", "strategy", "strategy-option", "into-name", "cleanup"]),
498
500
  stash: gitSubcommand("pkuaqS", "", "m", ["message", "pathspec-from-file"]),
499
501
  grep: GIT_GREP,
502
+ diff: { grammar: grammar({}, {}), operands: { rest: READ } },
500
503
  show: gitObjectSubcommand(),
501
504
  "cat-file": gitObjectSubcommand(switches("ptsep")),
502
505
  archive: gitObjectSubcommand({ o: val(WRITE) }, { output: val(WRITE), format: val(LABEL), prefix: val(LABEL), remote: val(LABEL) }),
@@ -568,15 +571,15 @@ const codeBodyRows = [
568
571
  { key: "compgen", grammars: [grammar({ ...switches("abcdefgjksuv"), ...values("oAGWPSXV"), F: val(COMMAND), C: val(SHELL_TEXT) })], body: "option-values", witness: BINARY },
569
572
  { key: "complete", grammars: [grammar({ ...switches("abcdefgjksuvDEIpr"), ...values("oAGWPSXV"), F: val(COMMAND), C: val(SHELL_TEXT) })], body: "option-values", witness: { declared: "a completion spec runs its command only when a person asks for a completion interactively" } },
570
573
  ];
571
- const copyGrammars = (bsdSuffixSwitch) => implementations(grammar({ S: val(LABEL, { effect: "writes" }), t: val(DESTINATION, { fillsLast: true }) }, { "target-directory": val(DESTINATION, { fillsLast: true }), suffix: val(LABEL, { effect: "writes" }), "no-preserve": val(LABEL), sparse: val(LABEL) }), { afterFirstOperand: "options" }, { ...(bsdSuffixSwitch ? { reads: { "-S": SWITCH } } : {}), lacks: [...(bsdSuffixSwitch ? [] : ["-S"]), "-t", "--target-directory", "--suffix", "--no-preserve", "--sparse"], afterFirstOperand: "operands" });
574
+ const copyGrammars = (bsdSuffixSwitch, destination) => implementations(grammar({ S: val(LABEL, { effect: "writes" }), t: val(destination, { fillsLast: true }) }, { "target-directory": val(destination, { fillsLast: true }), suffix: val(LABEL, { effect: "writes" }), "no-preserve": val(LABEL), sparse: val(LABEL) }), { afterFirstOperand: "options" }, { ...(bsdSuffixSwitch ? { reads: { "-S": SWITCH } } : {}), lacks: [...(bsdSuffixSwitch ? [] : ["-S"]), "-t", "--target-directory", "--suffix", "--no-preserve", "--sparse"], afterFirstOperand: "operands" });
572
575
  const writerRows = [
573
576
  { key: "mkdir", grammars: [], operands: { rest: CREATED, dash: "file" }, witness: BINARY },
574
577
  { key: "touch", grammars: implementations(grammar({ h: { arity: 0, writesAt: ["landing", "entry"] } }, { "no-dereference": { arity: 0, writesAt: ["landing", "entry"] } }), {}, { lacks: ["--no-dereference"] }), operands: { rest: TOUCHED, dash: "file" }, witness: BINARY },
575
578
  { key: "rm", grammars: implementations(grammar({ ...switches("rR", { selects: "traversal" }), f: { arity: 0, mode: "forced" } }, { ...longSwitches(["recursive"], { selects: "traversal" }), force: { arity: 0, mode: "forced" } }), {}, { lacks: ["--recursive", "--force"] }), operands: { rest: REMOVED, dash: "file" }, witness: BINARY },
576
579
  { key: "rmdir", grammars: implementations(grammar({ p: { arity: 0, effect: "writes" } }, { parents: { arity: 0, effect: "writes" } }), {}, { lacks: ["--parents"] }), operands: { rest: REMOVED, dash: "file" }, witness: BINARY },
577
- { key: "tee", grammars: [], operands: { rest: WRITE, dash: "file" }, witness: BINARY },
578
- { key: "cp", grammars: copyGrammars(true), operands: { rest: COPY_SOURCE, last: DESTINATION, dash: "file" }, witness: BINARY },
579
- { key: "mv", grammars: copyGrammars(false), operands: { rest: MOVE_SOURCE, last: DESTINATION, dash: "file" }, witness: BINARY },
580
+ { key: "tee", grammars: [], operands: { rest: WRITE_THROUGH, dash: "file" }, witness: BINARY },
581
+ { key: "cp", grammars: copyGrammars(true, COPY_DESTINATION), operands: { rest: COPY_SOURCE, last: COPY_DESTINATION, dash: "file" }, witness: BINARY },
582
+ { key: "mv", grammars: copyGrammars(false, MOVE_DESTINATION), operands: { rest: MOVE_SOURCE, last: MOVE_DESTINATION, dash: "file" }, witness: BINARY },
580
583
  ];
581
584
  const stdinReaders = [
582
585
  { key: "cat", grammars: [], operands: { stdinBelow: 1 }, witness: BINARY },
@@ -8,7 +8,8 @@ import { bashClassifyDefaultAllow } from "./bash-readonly-classifier.js";
8
8
  import { planShellReads } from "./shell-read-plan.js";
9
9
  import { createShellReadEstablisher, establishShellReadsLexically } from "./shell-read-establish.js";
10
10
  import { declaredReadDenyHomes, homeViewsAt } from "./read-deny-home.js";
11
- import { programArgs } from "./shell-read-write-targets.js";
11
+ import { anyOptionWord, programArgs } from "./shell-read-write-targets.js";
12
+ import { passesUnasked } from "./shell-read-device.js";
12
13
  import { programRow, selectedOptions } from "./program-read-model.js";
13
14
  import { allFindings, anyFaceGuards, decideShellRead, denyRowAtOrBelow, foldShellReadFindings, judgeEstablishedPlan, readRootGrantCandidateOf, stationGuards, writeFaceGuards, } from "./shell-read-judge.js";
14
15
  import { seatOf } from "../../core/tool-name-readings.js";
@@ -32,7 +33,6 @@ const WRITES_BENEATH = {
32
33
  };
33
34
  const isPattern = (f) => f.read.candidate.expands !== false && f.read.candidate.expands !== "unresolvable";
34
35
  const RESHAPES_TREE = new Set(["mv"]);
35
- const anyOptionWord = (words) => words.some((w) => w.startsWith("-"));
36
36
  const UNPLACED_OPTIONS = {
37
37
  rmdir: (ws) => selectedOptions(programRow("rmdir"), ws).some((o) => o.effect === "writes"),
38
38
  sed: (ws) => selectedOptions(programRow("sed"), ws).some((o) => o.value?.kind === "program-file"),
@@ -117,7 +117,7 @@ async function writeGuardsOf(judged, boundary, env, signal) {
117
117
  runs.forEach((s, at) => {
118
118
  for (const f of s.findings) {
119
119
  const written = f.read.written;
120
- if (written === undefined || f.read.established === "unestablishable" || f.read.target === undefined)
120
+ if (written === undefined || f.read.established === "unestablishable" || f.read.target === undefined || passesUnasked(f.read.candidate))
121
121
  continue;
122
122
  const child = written.child === true;
123
123
  const key = f.read.established === "canonical" || f.read.established === "expansion";
@@ -0,0 +1,92 @@
1
+ /**
2
+ * THE STANDARD DEVICES — the spellings a shell command names for its own standard streams and terminal, which the
3
+ * containment question is not asked of, and which write nothing durable: ONE table, ONE predicate, read by every layer that
4
+ * would otherwise treat the word as a path — the shell read station's entries (an output or input redirection's target, a read
5
+ * operand, the content a program writes through an operand, a `git diff` operand) and the shape tier's two readings of "does
6
+ * this redirection write" (the read-only reader, the classify faces' data-free redirections).
7
+ *
8
+ * The table is the reference implementation's own device predicate (CC 2.1.296 `wwe`, cli296.js @18511591): the null
9
+ * device, the three standard streams, the terminal, any `/dev/fd/<digits>`, and `/proc/self/fd/{0,1,2}`. A word is such a
10
+ * spelling AS WRITTEN (quotes removed, nothing folded: `/dev/./null` is a path, not the null device).
11
+ *
12
+ * What exempts a word is more than its spelling, on both counts:
13
+ * · it is I/O THROUGH the node — a redirection, a read operand, the content a program writes by opening the path
14
+ * (`ReadCandidate.through`). A program that creates, unlinks, renames or retargets the path's directory ENTRY
15
+ * (`rm`, `mv`, `mkdir`, `touch`, an in-place edit) is judged as it judges any path, and so is a word of a program the
16
+ * station has no row for (`ln`, `chmod`): nothing says that program only opens it;
17
+ * · no redirection of the same text can have rebound the descriptor the spelling names. A descriptor spelling is a
18
+ * name for whatever that descriptor is bound to WHEN THE WORD IS OPENED: `3< f` or `exec 3>f` before it makes
19
+ * `/dev/fd/3` the file `f`, and `1<f > /dev/stdout` reopens `f` for writing. Such a descriptor is judged at its landing
20
+ * like any path. (One step stricter than the reference, which exempts the spelling whatever the text does.) A text the
21
+ * consumer cannot read in full may bind anything, so it exempts no descriptor spelling; the null device and the
22
+ * terminal name no descriptor and are exempt always.
23
+ *
24
+ * Beside the table, the generator devices (`/dev/zero`, `/dev/random`, `/dev/urandom`, `/dev/full`) are not a stream of the
25
+ * process: a read of one is no question only while the program read model knows the read is bounded
26
+ * (`byteBoundIsSmall`: `head -c N`), the same fact the read-only classifier's endless-device arm reads.
27
+ */
28
+ import type { ReadCandidate, ReadCandidateProvenance, ShellReadPlan } from "./shell-read-plan.js";
29
+ /** Is this word, as written, a standard device? `descriptor`: the descriptor number it names, when it names one. */
30
+ export declare function standardDeviceOf(spelled: string): {
31
+ readonly descriptor?: number;
32
+ } | undefined;
33
+ /** The devices that PRODUCE data without end: read in bounded bytes they cost nothing, read unbounded they never finish. */
34
+ export declare const GENERATOR_DEVICES: ReadonlySet<string>;
35
+ /** A redirection, as far as binding a descriptor goes: its operator and the number written in front of it (the lexer's
36
+ * `LexedRedirect` and the plan's `ShellReadRedirect` both are one). */
37
+ export interface RedirectionBinding {
38
+ readonly op: string;
39
+ readonly fd?: number;
40
+ }
41
+ /** What a text does to its descriptors: how many redirections bind each (a count, so the one that IS the use of a spelling
42
+ * can be taken out of it), and whether it may bind any it does not show. */
43
+ export interface LineDescriptors {
44
+ /** Some part of the text the consumer cannot read in full may bind any descriptor (an unreadable text, a program or an
45
+ * `eval` / `source` text it does not see, a numbered here-document whose number the lexer drops, a redirection whose number
46
+ * the reader keeps no record of). */
47
+ unknown: boolean;
48
+ /** A `{name}` word — bash allocates a descriptor from 10 up for a redirection written `{name}>f`. */
49
+ named: boolean;
50
+ readonly bound: Map<number, number>;
51
+ }
52
+ export declare const newLineDescriptors: () => LineDescriptors;
53
+ /** The descriptors one redirection binds: the number written in front of it, else the operator's own (`>` family: 1,
54
+ * `<` family: 0, `&>`: both output streams). */
55
+ export declare function descriptorsBoundBy(r: RedirectionBinding): readonly number[];
56
+ export declare function bindDescriptors(into: LineDescriptors, r: RedirectionBinding): void;
57
+ export declare const isNamedDescriptorWord: (raw: string) => boolean;
58
+ /**
59
+ * THE predicate for a word and the text it stands in: is it a standard device (the table, as written) whose descriptor — if it
60
+ * names one — no redirection of the text rebinds? `self`: the descriptors the redirection that IS the use binds (a word that
61
+ * is not a redirection's target passes none).
62
+ */
63
+ export declare function standardDeviceUnrebound(spelled: string, text: LineDescriptors, self: readonly number[]): boolean;
64
+ /**
65
+ * The provenances whose word a program OPENS (or a redirection opens): a read operand or option value, an input
66
+ * redirection's source, an output redirection's target, and a word of a program the deployment did not list as a reader but
67
+ * whose row the station holds (`unlisted_word`, unless it waits for the filesystem — {@link ReadCandidate.speculative}: a
68
+ * program with no row may do anything with the word), and the root of a tree walk (`grep -r`, `diff`: a standard device is
69
+ * no directory to walk — an option that writes beyond the operands, `find -delete`, takes the exemption back, see
70
+ * `judgedCandidatesOf`). Not a directory a program changes into (`cd_target`), not a program, an assignment's value or a
71
+ * mount source.
72
+ */
73
+ export declare const OPENED_PROVENANCES: ReadonlySet<ReadCandidateProvenance>;
74
+ /**
75
+ * Does the containment question go unasked of this candidate? The one predicate every entry of the station reads: the plan
76
+ * marked the word a standard device no redirection of its text rebinds ({@link ReadCandidate.device}), and — for a word the
77
+ * program WRITES — the program writes its content through the path ({@link ReadCandidate.through}), not its entry.
78
+ */
79
+ export declare function passesUnasked(candidate: ReadCandidate): boolean;
80
+ /**
81
+ * What the whole text of a plan binds: every redirection of every segment — a single one, an `exec`'s, a `{name}` one included —
82
+ * and whether the text may bind more than it shows. The ONE computation behind the station's rebinding rule
83
+ * ({@link markStandardDevices}) and the shape tier's reading of a program's device operands (`tee /dev/fd/3`).
84
+ */
85
+ export declare function descriptorsOfPlan(plan: ShellReadPlan): LineDescriptors;
86
+ /**
87
+ * Mark every candidate of a whole plan (the nested plans included) that names a standard device the containment question
88
+ * is not asked of ({@link ReadCandidate.device}): the word is the table's, it is a word a program or a redirection opens,
89
+ * and no redirection of the text rebinds the descriptor it names. Computed once, over the text as a whole: whether a
90
+ * descriptor is rebound is a fact of the line, not of a segment.
91
+ */
92
+ export declare function markStandardDevices(plan: ShellReadPlan): ShellReadPlan;
@@ -0,0 +1,109 @@
1
+ const STANDARD_DEVICES = [
2
+ { spelled: /^\/dev\/null$/ },
3
+ { spelled: /^\/dev\/stdin$/, descriptor: () => 0 },
4
+ { spelled: /^\/dev\/stdout$/, descriptor: () => 1 },
5
+ { spelled: /^\/dev\/stderr$/, descriptor: () => 2 },
6
+ { spelled: /^\/dev\/tty$/ },
7
+ { spelled: /^\/dev\/fd\/(\d+)$/, descriptor: (m) => Number(m[1]) },
8
+ { spelled: /^\/proc\/self\/fd\/([0-2])$/, descriptor: (m) => Number(m[1]) },
9
+ ];
10
+ export function standardDeviceOf(spelled) {
11
+ for (const row of STANDARD_DEVICES) {
12
+ const match = row.spelled.exec(spelled);
13
+ if (match !== null)
14
+ return row.descriptor === undefined ? {} : { descriptor: row.descriptor(match) };
15
+ }
16
+ return undefined;
17
+ }
18
+ export const GENERATOR_DEVICES = new Set(["/dev/zero", "/dev/random", "/dev/urandom", "/dev/full"]);
19
+ export const newLineDescriptors = () => ({ unknown: false, named: false, bound: new Map() });
20
+ export function descriptorsBoundBy(r) {
21
+ if (r.fd !== undefined)
22
+ return [r.fd];
23
+ if (r.op === "&>" || r.op === "&>>")
24
+ return [1, 2];
25
+ return [r.op === "<" || r.op === "<<<" || r.op === "<&" ? 0 : 1];
26
+ }
27
+ export function bindDescriptors(into, r) {
28
+ for (const fd of descriptorsBoundBy(r))
29
+ into.bound.set(fd, (into.bound.get(fd) ?? 0) + 1);
30
+ }
31
+ const NAMED_DESCRIPTOR_WORD = /^\{[A-Za-z_][A-Za-z0-9_]*\}$/;
32
+ export const isNamedDescriptorWord = (raw) => NAMED_DESCRIPTOR_WORD.test(raw);
33
+ function reboundBy(text, descriptor, self) {
34
+ if (text.unknown || (text.named && descriptor >= 3))
35
+ return true;
36
+ return (text.bound.get(descriptor) ?? 0) - (self.includes(descriptor) ? 1 : 0) > 0;
37
+ }
38
+ export function standardDeviceUnrebound(spelled, text, self) {
39
+ const device = standardDeviceOf(spelled);
40
+ return device !== undefined && (device.descriptor === undefined || !reboundBy(text, device.descriptor, self));
41
+ }
42
+ export const OPENED_PROVENANCES = new Set(["operand", "option_value", "stdin_redirect", "write_redirect_target", "unlisted_word", "traversal_root"]);
43
+ export function passesUnasked(candidate) {
44
+ return candidate.device === true && (candidate.face !== "write" || candidate.through === true);
45
+ }
46
+ const SHELL_CODE_VERBS = new Set(["eval", "source", "."]);
47
+ function collectDescriptors(plan, into) {
48
+ if (plan.unreadable !== undefined)
49
+ into.unknown = true;
50
+ for (const segment of plan.segments) {
51
+ if (segment.declines.length > 0 || segment.unreadProgramText !== undefined)
52
+ into.unknown = true;
53
+ if (segment.verb !== undefined && SHELL_CODE_VERBS.has(segment.verb.basename) && segment.nested.length === 0)
54
+ into.unknown = true;
55
+ if (segment.reads.some((c) => c.provenance === "program_path" && c.expands === "unresolvable"))
56
+ into.unknown = true;
57
+ for (const r of segment.redirects)
58
+ bindDescriptors(into, r);
59
+ if (segment.argv.some((w) => isNamedDescriptorWord(w.raw)))
60
+ into.named = true;
61
+ const text = segment.text.replace(/\\\r?\n/g, "");
62
+ if (/(?:^|[^<])<<(?!<)/.test(text)) {
63
+ if (/\d<<(?!<)/.test(text))
64
+ into.unknown = true;
65
+ else
66
+ into.bound.set(0, (into.bound.get(0) ?? 0) + 1);
67
+ }
68
+ for (const n of segment.nested)
69
+ collectDescriptors(n, into);
70
+ }
71
+ }
72
+ export function descriptorsOfPlan(plan) {
73
+ const into = newLineDescriptors();
74
+ collectDescriptors(plan, into);
75
+ return into;
76
+ }
77
+ export function markStandardDevices(plan) {
78
+ const text = descriptorsOfPlan(plan);
79
+ const mark = (c, self) => {
80
+ if (c.device === true || c.expands !== false || c.denyOnly === true || c.speculative !== undefined || !OPENED_PROVENANCES.has(c.provenance))
81
+ return c;
82
+ return standardDeviceUnrebound(c.spelled, text, self) ? { ...c, device: true } : c;
83
+ };
84
+ const marked = new Map();
85
+ const walk = (p) => {
86
+ const seen = marked.get(p);
87
+ if (seen !== undefined)
88
+ return seen;
89
+ const out = markedPlan(p);
90
+ marked.set(p, out);
91
+ return out;
92
+ };
93
+ const markedPlan = (p) => ({
94
+ ...p,
95
+ segments: p.segments.map((s) => {
96
+ const useOf = (c) => {
97
+ const r = s.redirects.find((x) => x.target !== undefined && x.target.raw === c.raw && x.target.provenance === c.provenance);
98
+ return r === undefined ? [] : descriptorsBoundBy(r);
99
+ };
100
+ return {
101
+ ...s,
102
+ reads: s.reads.map((c) => mark(c, c.provenance === "stdin_redirect" ? useOf(c) : [])),
103
+ redirects: s.redirects.map((r) => (r.target === undefined ? r : { ...r, target: mark(r.target, descriptorsBoundBy(r)) })),
104
+ nested: s.nested.map(walk),
105
+ };
106
+ }),
107
+ });
108
+ return walk(plan);
109
+ }
@@ -4,6 +4,7 @@ import { boundaryIsTheSandbox } from "../../core/remote-env.js";
4
4
  import { resolveOperandSpellings } from "./bash-readonly-classifier.js";
5
5
  import { judgedCandidatesOf, sedBackupNames } from "./shell-read-write-targets.js";
6
6
  import { movedReads, withHomeViews } from "./shell-read-identity.js";
7
+ import { passesUnasked } from "./shell-read-device.js";
7
8
  const GLOB_METACHAR_RE = /[*?[]/;
8
9
  export const MAX_GLOB_DIRS = 256;
9
10
  export const MAX_GLOB_MATCHES = 8192;
@@ -569,7 +570,7 @@ function establishLegOver(seen, readRoots) {
569
570
  out.push({ ...(await canonicalReadOf(candidate, m.path, [m.path], "expansion", folded, m.canonical, signal)), node: m.node, walked: true });
570
571
  };
571
572
  for (const [i, lexical] of lexicalReadsOf(candidate, boundary).entries()) {
572
- if (lexical.established !== "lexical") {
573
+ if (lexical.established !== "lexical" || passesUnasked(candidate)) {
573
574
  out.push(writtenAt(lexical, writtenSeats, writtenSeats.written.includes("landing") ? "landing" : "entry"));
574
575
  continue;
575
576
  }
@@ -1,6 +1,7 @@
1
1
  import { hasWin32AliasedComponent, normalizeAbsPathLexically, withinAnyRoot } from "./safety.js";
2
2
  import { globInWord } from "./bash-readonly-classifier.js";
3
3
  import { readOfText } from "./shell-read-word.js";
4
+ import { passesUnasked } from "./shell-read-device.js";
4
5
  import { skipsRootsContainment } from "./containment-face.js";
5
6
  export const SHELL_READ_FINDINGS = Object.freeze(["deny_hit", "outside_roots", "unestablishable", "declined", "identity_changed", "unanswered", "none"]);
6
7
  export const SHELL_READ_SEATS = Object.freeze(["full_shell", "readonly", "rejudge"]);
@@ -57,6 +58,8 @@ export function judgeRead(read, boundary) {
57
58
  const containment = containmentOf(read, boundary);
58
59
  if (read.candidate.denyOnly === true)
59
60
  return { read, kind: "none", containment };
61
+ if (passesUnasked(read.candidate))
62
+ return { read, kind: "none", containment: "open" };
60
63
  const provenance = read.candidate.provenance;
61
64
  if (provenance === "env_assignment" || (provenance === "program_path" && read.candidate.expands !== "unresolvable"))
62
65
  return { read, kind: "none", containment };
@@ -84,10 +87,8 @@ function containmentOf(read, boundary) {
84
87
  return "unknown";
85
88
  return withinAnyRoot(boundary.roots, read.target) ? "inside" : "outside";
86
89
  }
87
- const NULL_DEVICE = "/dev/null";
88
90
  function writeContainmentOf(read, boundary) {
89
- const device = read.written?.seat === "landing" && read.candidate.expands === false && read.candidate.spelled === NULL_DEVICE;
90
- if (read.written === undefined || read.candidate.denyOnly === true || device)
91
+ if (read.written === undefined || read.candidate.denyOnly === true || passesUnasked(read.candidate))
91
92
  return "open";
92
93
  const roots = boundary.writeRoots;
93
94
  if (roots === undefined || read.target === undefined)
@@ -84,6 +84,10 @@ export interface ReadCandidate {
84
84
  * word) that is a candidate at all only where it exists as a file. Where the filesystem confirms it is not there (and
85
85
  * nothing in the text writes or creates it), it is no read; a lexical establishment keeps it whole. */
86
86
  readonly speculative?: "containment" | "whole";
87
+ /** A standard device a program or redirection OPENS, its descriptor (if any) unrebound by the text ({@link markStandardDevices}). */
88
+ readonly device?: true;
89
+ /** Only beside `face: "write"`: the program writes the word's CONTENT through the path, not its ENTRY ({@link passesUnasked}). */
90
+ readonly through?: true;
87
91
  }
88
92
  /** How the shell reached one segment — what the base-candidate walk needs from the connectors. */
89
93
  export interface SegmentReach {
@@ -97,6 +101,7 @@ export interface SegmentReach {
97
101
  export interface ShellReadRedirect {
98
102
  readonly op: LexedRedirect["op"];
99
103
  readonly direction: "in" | "out" | "dup";
104
+ readonly fd?: number;
100
105
  readonly target?: ReadCandidate;
101
106
  }
102
107
  export interface ShellReadSegment {
@@ -10,6 +10,7 @@ import { shellWordsOf } from "../../shell/shell-words.js";
10
10
  import { isAbsoluteForFamily } from "./safety.js";
11
11
  import { codeBodies, launchedInput, reentryText, scriptFileAt, stdinTextsOf } from "./shell-reentry-text.js";
12
12
  import { heldAt, plannedOnce, rebasePlan, startedAt, stdinFeeds } from "./shell-read-start.js";
13
+ import { markStandardDevices } from "./shell-read-device.js";
13
14
  export const READ_CANDIDATE_PROVENANCES = Object.freeze([
14
15
  "operand",
15
16
  "option_value",
@@ -27,7 +28,8 @@ export const READ_CANDIDATE_PROVENANCES = Object.freeze([
27
28
  const NESTING_MAX = 4;
28
29
  const LOOP_END = shellWordsOf("loop_end"), LOOP_CONDITION = shellWordsOf("loop_condition"), COPROC = shellWordsOf("background"), RESERVED = shellWordsOf("rule_reserved");
29
30
  export function planShellReads(command, decl, depth = 0, start) {
30
- return planText(command, decl, depth, start).plan;
31
+ const plan = planText(command, decl, depth, start).plan;
32
+ return depth === 0 ? markStandardDevices(plan) : plan;
31
33
  }
32
34
  function planText(command, decl, depth, start, from) {
33
35
  const shape = { grouped: false, backgrounded: false, strayRedirection: false };
@@ -209,7 +211,7 @@ function planText(command, decl, depth, start, from) {
209
211
  ...(f.stdin !== undefined ? { stdin: f.stdin } : {}),
210
212
  declines: f.declines,
211
213
  reads: dedupe(f.reads).map(programRead),
212
- redirects: f.redirects.map((r) => (r.target === undefined ? { op: r.op, direction: r.direction } : { op: r.op, direction: r.direction, target: withBases(r.target) })),
214
+ redirects: f.redirects.map((r) => ({ op: r.op, direction: r.direction, ...(r.fd !== undefined ? { fd: r.fd } : {}), ...(r.target !== undefined ? { target: withBases(r.target) } : {}) })),
213
215
  ...(move !== undefined ? { baseMove: { target: typeof move.target === "string" ? move.target : withBases(move.target) } } : {}),
214
216
  bases,
215
217
  nested: f.nested.map((n) => (f.elsewhere?.has(n) === true ? n : rebasePlan(n, decl.cwd, moved && f.shellSide?.has(n) !== true ? "unmodelled" : bases, bases === "unmodelled" ? floor : bases))),
@@ -669,13 +671,14 @@ function relativeSpelling(spelled) {
669
671
  return !(isAbsoluteForFamily("own", spelled) || spelled.startsWith("~"));
670
672
  }
671
673
  function redirectFact(r, table, homeDir) {
674
+ const fd = r.fd !== undefined ? { fd: r.fd } : {};
672
675
  if (r.target === undefined)
673
- return { op: r.op, direction: "dup" };
676
+ return { op: r.op, direction: "dup", ...fd };
674
677
  if (r.op === "<")
675
- return { op: r.op, direction: "in", target: bare(readsOf([r.target], table, homeDir)[0], "stdin_redirect") };
678
+ return { op: r.op, direction: "in", ...fd, target: bare(readsOf([r.target], table, homeDir)[0], "stdin_redirect") };
676
679
  if (r.op === "<<<")
677
- return { op: r.op, direction: "in" };
678
- return { op: r.op, direction: "out", target: bare(readsOf([r.target], table, homeDir)[0], "write_redirect_target") };
680
+ return { op: r.op, direction: "in", ...fd };
681
+ return { op: r.op, direction: "out", ...fd, target: bare(readsOf([r.target], table, homeDir)[0], "write_redirect_target") };
679
682
  }
680
683
  function commandStringValue(row, toks, option) {
681
684
  const at = toks.findIndex((r) => r.folded === option);
@@ -9,6 +9,7 @@
9
9
  * program writes is its row's.
10
10
  */
11
11
  import type { ReadCandidate, ShellReadSegment } from "./shell-read-plan.js";
12
+ import { type LineDescriptors } from "./shell-read-device.js";
12
13
  import { type WriteSeat } from "./program-read-model.js";
13
14
  export type { WriteSeat } from "./program-read-model.js";
14
15
  /** A word of a program's argv: its raw spelling (the plan's candidates are keyed by it) and its folded spelling. */
@@ -46,6 +47,19 @@ export interface JudgedCandidate {
46
47
  };
47
48
  readonly onlyIfExists?: ReadCandidate;
48
49
  }
50
+ /** Is any of these words an option word (one starting with `-`)? The reference implementation's reading of a copy or a move: a
51
+ * command with flags is not one whose paths it can validate (`--target-directory`, `--remove-destination`, a backup suffix
52
+ * all change what is written where). */
53
+ export declare const anyOptionWord: (words: readonly string[]) => boolean;
54
+ /**
55
+ * Does this invocation write ONLY standard devices — what the shape tier reads as "writes nothing durable" for a PROGRAM (a
56
+ * redirection is the reader's own)? Every word any implementation's reading of the row writes ({@link writeReadings}; a word the
57
+ * reading names written at no seat, a copy's source, is read) is one the program writes THROUGH ({@link writesThrough}) and
58
+ * is a standard device no redirection of the line rebinds ({@link standardDeviceUnrebound}) — the station's own predicate
59
+ * (`passesUnasked`) over the same row facts, for the one entry a text-only reader has: `tee /dev/tty`, `cp a.txt /dev/stdout`.
60
+ * No program is named here: which programs may ask this is the caller's.
61
+ */
62
+ export declare function writesOnlyStandardDevices(program: string, args: readonly string[], line: LineDescriptors): boolean;
49
63
  /** Every candidate a segment hands the judge: its reads minus the write targets, plus its `cd` target (the plan already
50
64
  * lists it among the reads), plus its WRITE targets for the write face ({@link writeTargetsOf}), plus the deny-floor
51
65
  * shadow of every unplaced home word ({@link denyFloorShadowsOf}) on both faces. A word the program only writes keeps