@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.
- package/CHANGELOG.md +18 -0
- package/dist/agents/peer-directory.d.ts +8 -3
- package/dist/agents/peer-directory.js +5 -3
- package/dist/core/arg-summary.js +2 -1
- package/dist/core/credential-vocabulary.js +1 -1
- package/dist/core/gate-fold.js +7 -3
- package/dist/core/permission-rule-model.js +5 -5
- package/dist/core/posix-mode-face.d.ts +10 -0
- package/dist/core/posix-mode-face.js +3 -0
- package/dist/core/read-only-shell.d.ts +8 -1
- package/dist/core/read-only-shell.js +19 -4
- package/dist/engine/execution-env/node-execution-env.js +2 -1
- package/dist/stores/file/permission-rule-store.d.ts +13 -1
- package/dist/stores/file/permission-rule-store.js +40 -21
- package/dist/tools/fs/bash-readonly-classifier.js +12 -29
- package/dist/tools/fs/program-read-model.d.ts +15 -0
- package/dist/tools/fs/program-read-model.js +24 -0
- package/dist/tools/fs/program-read-model.rows.js +15 -12
- package/dist/tools/fs/shell-read-check.js +3 -3
- package/dist/tools/fs/shell-read-device.d.ts +92 -0
- package/dist/tools/fs/shell-read-device.js +109 -0
- package/dist/tools/fs/shell-read-establish.js +2 -1
- package/dist/tools/fs/shell-read-judge.js +4 -3
- package/dist/tools/fs/shell-read-plan.d.ts +5 -0
- package/dist/tools/fs/shell-read-plan.js +9 -6
- package/dist/tools/fs/shell-read-write-targets.d.ts +14 -0
- package/dist/tools/fs/shell-read-write-targets.js +63 -12
- package/package.json +1 -1
- package/wire-changes/core@7.49.4.json +69 -0
- package/wire-changes/core@7.49.5.json +104 -0
|
@@ -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
|
|
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(
|
|
207
|
-
F: val(FORM), H: val(HEADER), K: val(READ), m: val(COUNT), o: val(
|
|
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(
|
|
214
|
-
data: val(INDIRECT), "data-ascii": val(INDIRECT), "data-binary": val(INDIRECT), "data-raw": val(LABEL), "data-urlencode": val(FORM), "dump-header": val(
|
|
215
|
-
form: val(FORM), "form-string": val(LABEL), header: val(HEADER), key: val(READ), "max-time": val(COUNT), "netrc-file": val(READ), output: val(
|
|
216
|
-
proxy: val(LABEL), range: val(COUNT), referer: val(LABEL), request: val(LABEL), resolve: val(LABEL), trace: val(
|
|
217
|
-
"trace-ascii": val(
|
|
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(
|
|
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:
|
|
578
|
-
{ key: "cp", grammars: copyGrammars(true), operands: { rest: COPY_SOURCE, last:
|
|
579
|
-
{ key: "mv", grammars: copyGrammars(false), operands: { rest: MOVE_SOURCE, last:
|
|
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
|
-
|
|
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
|
-
|
|
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) => (
|
|
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
|