@sema-agent/core 7.49.3 → 7.49.4
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 +4 -0
- 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/read-only-shell.d.ts +8 -1
- package/dist/core/read-only-shell.js +19 -4
- 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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 7.49.4 — 2026-10-13
|
|
2
|
+
|
|
3
|
+
**Rebuild of 7.49.3 — no source change.** The npm tarball of 7.49.3 (shasum `77a7fd37…`, now deprecated) carried the `dist/` of 7.49.2: it was published with `--ignore-scripts` from a checkout whose `dist/` had not been rebuilt (the release-candidate build and gates had run on a detached gate copy). None of the four 7.49.3 entries below (#1685, #1691, #1692, #1693) were in that package. 7.49.4 is the same source as tag `v7.49.3` built in the publishing checkout and compared byte-for-byte with the gated dist before publish; every 7.49.3 entry, criterion (`docs/release-criteria/7.49.3.json`) and wire manifest applies to 7.49.4. **Hard-breaking changes:** none. **Closed sets:** none. **Package exports:** +0 / −0 (`npm run handoff:diff v7.49.3 HEAD`). Consumers pinned to 7.49.3 must move to 7.49.4 (@server @cli @web-admin @client-core @test).
|
|
4
|
+
|
|
1
5
|
## 7.49.3 — 2026-10-13
|
|
2
6
|
|
|
3
7
|
**Hard-breaking changes (#1685, #1693, #1691, #1692):** none. **Closed sets:** none (`READ_CANDIDATE_PROVENANCES`, `SHELL_READ_FINDINGS`, `ASK_CLASSES`, `PERSISTED_RULE_MANDATES` unchanged). **Package exports:** +0 / −0 names (`npm run handoff:diff v7.49.2 HEAD`: 2 609 → 2 609); member changes, all optional: `ReadCandidate.device` / `through`, and the plan's redirection record `fd` (`ShellReadSegment.redirects[]`). **Behavior:** #1685 makes the reference's device table a non-question at every entry of the shell read station and of the shape tier — a loosening for the standard streams (open to veto: it departs from the reference at the output-redirection, read-operand and write-operand entries), and a one-line tightening for `touch` / `mkdir` of the null device; #1693 makes an environment non-answer a bypass-immune ask — a tightening that restores what 7.49.2's own documentation promised; #1691 removes a value that was left in clear after a bearer token (a name=value glued to the token by `:` `,` `@` `!` `;` `|` `&`) and hides the character that joins it; #1692 gives the overlapping re-scan's work allowance a floor (2^17 characters of matches, binding below 16 KB of input only), so a short text with a dense line is read in full instead of losing every line behind it — a scan that still runs out of work masks to the end of the input exactly as in 7.49.2 — redaction only, nothing a run could do before is refused. **Known limits added / closed** (`docs/KNOWN-LIMITS.md`): KL-1644-a closed; KL-1685-b / -c / -d / -e / -f added; KL-958-f, KL-958-g, KL-958-h added (KL-958-d and KL-958-e restated).
|
package/dist/core/arg-summary.js
CHANGED
|
@@ -87,12 +87,13 @@ function removedRegion(pass, match, groups, inserted) {
|
|
|
87
87
|
return [head, match.length - tail];
|
|
88
88
|
}
|
|
89
89
|
const SOLO_SCAN_WORK_PER_CHAR = 8;
|
|
90
|
+
const SOLO_SCAN_WORK_FLOOR = 1 << 17;
|
|
90
91
|
function soloHits(input, passes) {
|
|
91
92
|
const hits = [];
|
|
92
93
|
passes.forEach((pass, i) => {
|
|
93
94
|
const re = new RegExp(pass.re.source, pass.re.flags.includes("g") ? pass.re.flags : `${pass.re.flags}g`);
|
|
94
95
|
const rescans = pass.family !== undefined;
|
|
95
|
-
let work = SOLO_SCAN_WORK_PER_CHAR * input.length;
|
|
96
|
+
let work = Math.max(SOLO_SCAN_WORK_PER_CHAR * input.length, SOLO_SCAN_WORK_FLOOR);
|
|
96
97
|
for (let m = re.exec(input); m !== null; m = re.exec(input)) {
|
|
97
98
|
const at = m.index;
|
|
98
99
|
const familyAt = () => (typeof pass.family === "function" ? pass.family(input, at) : pass.family);
|
|
@@ -16,7 +16,7 @@ const QUOTED_VALUE = [
|
|
|
16
16
|
...ESCAPED_QUOTES.map((q, i) => pairedValue(`(${BACKSLASHES})${q}`, `(?<!\\\\)(?:\\${i + 2})${q}`)),
|
|
17
17
|
`${QUOTE}(?!${AFTER_CLOSING_QUOTE})${GLUED_RUN}`,
|
|
18
18
|
].join("|");
|
|
19
|
-
const BEARER_TAIL = `(
|
|
19
|
+
const BEARER_TAIL = `(?:(?!${QUOTE})\\S${GLUED_RUN}|(?<==)(?:${QUOTED_VALUE}))`;
|
|
20
20
|
export const CREDENTIAL_VOCABULARY = {
|
|
21
21
|
values: [
|
|
22
22
|
{ id: "github-token", kind: "prefixed-token", source: "arg-summary", confidence: "high", pass: "vendor-prefixed", match: new RegExp(`(?<![A-Za-z0-9])gh[opsur][-_]${VENDOR_BODY}`, "g"), strict: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/ },
|
package/dist/core/gate-fold.js
CHANGED
|
@@ -208,9 +208,13 @@ function applyShellReadVerdict(pass, verdict, toolName, shellGated) {
|
|
|
208
208
|
case "mandated": {
|
|
209
209
|
if (verdict.details !== undefined)
|
|
210
210
|
pass.shellReadDetails = verdict.details;
|
|
211
|
+
const unreadable = verdict.finding === "unanswered";
|
|
211
212
|
if (pass.decision.action !== "allow") {
|
|
212
|
-
if (pass.decision.action === "ask")
|
|
213
|
-
pass.decision =
|
|
213
|
+
if (pass.decision.action === "ask") {
|
|
214
|
+
pass.decision = !unreadable
|
|
215
|
+
? { ...pass.decision, probeMandated: true }
|
|
216
|
+
: { ...pass.decision, requiresRealApproval: true, ...(pass.decision.guard === undefined ? { guard: "unverifiable_shell_text" } : { foldedAskClasses: [...new Set([...(pass.decision.foldedAskClasses ?? []), "unverifiable_shell_text"])] }) };
|
|
217
|
+
}
|
|
214
218
|
return;
|
|
215
219
|
}
|
|
216
220
|
const candidate = normalizeReadRootCandidate(verdict.readRootCandidate);
|
|
@@ -218,7 +222,7 @@ function applyShellReadVerdict(pass, verdict, toolName, shellGated) {
|
|
|
218
222
|
action: "ask",
|
|
219
223
|
message: shellReadAskMessage(toolName, verdict),
|
|
220
224
|
decisionReason: "safety",
|
|
221
|
-
probeMandated: true,
|
|
225
|
+
...(unreadable ? { requiresRealApproval: true, guard: "unverifiable_shell_text" } : { probeMandated: true }),
|
|
222
226
|
...(account !== undefined ? { probeReason: account } : {}),
|
|
223
227
|
...(candidate !== undefined ? { probeReadRootCandidate: candidate } : {}),
|
|
224
228
|
};
|
|
@@ -9,7 +9,7 @@ import { heldValues } from "./tool-name-readings.js";
|
|
|
9
9
|
import { carriesDotSegment, isAbsoluteForFamily, isIdentityDir, isPathIdentity, isShellRootedSpellingUnmapped, isUncIdentity, pathFamilyOf, pathIdentityOf } from "../tools/fs/safety.js";
|
|
10
10
|
import { bindPathRules, judgeRuleRows, rowsWhereTheyLead } from "./path-rule-binding.js";
|
|
11
11
|
import { readShellCommand } from "./shell-lexer.js";
|
|
12
|
-
import { readOnlyLineObjection, readOnlyShellVerdict } from "./read-only-shell.js";
|
|
12
|
+
import { lineDescriptorsOfText, readOnlyLineObjection, readOnlyShellVerdict } from "./read-only-shell.js";
|
|
13
13
|
export const RULE_BEHAVIORS = Object.freeze(["deny", "ask", "allow"]);
|
|
14
14
|
const RULE_BEHAVIOR_SET = new Set(RULE_BEHAVIORS);
|
|
15
15
|
export function isRuleBehavior(v) {
|
|
@@ -951,7 +951,7 @@ export function findAdmittingRule(rules, call) {
|
|
|
951
951
|
const dir = cdDirs[i];
|
|
952
952
|
const first = firstEligibleAdmittingRule(rules, segment, call) ?? (dir !== undefined ? firstEligibleDirectoryRule(rules, dir, call) : undefined);
|
|
953
953
|
if (first === undefined) {
|
|
954
|
-
if (readsOnlyAsSegment(segment, lineObjection))
|
|
954
|
+
if (readsOnlyAsSegment(segment, lineObjection, call.command))
|
|
955
955
|
continue;
|
|
956
956
|
return undefined;
|
|
957
957
|
}
|
|
@@ -960,8 +960,8 @@ export function findAdmittingRule(rules, call) {
|
|
|
960
960
|
}
|
|
961
961
|
return covering.length > 0 ? covering : undefined;
|
|
962
962
|
}
|
|
963
|
-
function readsOnlyAsSegment(segment, lineObjection) {
|
|
964
|
-
return lineObjection === undefined && !movesShellCursor(segment) && readOnlyShellVerdict(segment).readOnly;
|
|
963
|
+
function readsOnlyAsSegment(segment, lineObjection, whole) {
|
|
964
|
+
return lineObjection === undefined && !movesShellCursor(segment) && readOnlyShellVerdict(segment, undefined, undefined, () => lineDescriptorsOfText(whole)).readOnly;
|
|
965
965
|
}
|
|
966
966
|
function firstEligibleDirectoryRule(rules, directory, call) {
|
|
967
967
|
for (const rule of rules) {
|
|
@@ -1067,7 +1067,7 @@ export function segmentCoverageOf(command, rules, call) {
|
|
|
1067
1067
|
const dir = cdDirs[i];
|
|
1068
1068
|
return {
|
|
1069
1069
|
segment: segment.trim(),
|
|
1070
|
-
covered: readsOnlyAsSegment(segment, lineObjection) ||
|
|
1070
|
+
covered: readsOnlyAsSegment(segment, lineObjection, folded) ||
|
|
1071
1071
|
firstEligibleAdmittingRule(rules.persisted, segment, call) !== undefined ||
|
|
1072
1072
|
(dir !== undefined && firstEligibleDirectoryRule(rules.persisted, dir, call) !== undefined) ||
|
|
1073
1073
|
proposed.some((p) => p.rule.tool === "Read"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ShellCommandShape } from "./shell-lexer.js";
|
|
2
|
+
import { type LineDescriptors } from "../tools/fs/shell-read-device.js";
|
|
2
3
|
/** The reader's answer: read-only, or not — with the first reason the reading stopped on (a sentence a
|
|
3
4
|
* trace or a card can show; never adjudication input). */
|
|
4
5
|
export type ReadOnlyShellVerdict = {
|
|
@@ -32,7 +33,7 @@ export declare function changesDirectoryBeforeGit(shape: ShellCommandShape, cwd?
|
|
|
32
33
|
* stands ({@link changesDirectoryBeforeGit}). Anything the reading does not positively admit is NOT read-only — the
|
|
33
34
|
* answer is never a guess, and a `false` costs one question.
|
|
34
35
|
*/
|
|
35
|
-
export declare function readOnlyShellVerdict(command: string, shape?: ShellCommandShape, cwd?: string): ReadOnlyShellVerdict;
|
|
36
|
+
export declare function readOnlyShellVerdict(command: string, shape?: ShellCommandShape, cwd?: string, bound?: LineDescriptors | (() => LineDescriptors)): ReadOnlyShellVerdict;
|
|
36
37
|
/**
|
|
37
38
|
* The reader's LINE-level objection to `command` — what makes no segment of it read-only in its place, whatever the
|
|
38
39
|
* segment reads on its own: the line's structure, or a `cd` beside a `git` ({@link changesDirectoryBeforeGit}). A caller
|
|
@@ -40,3 +41,9 @@ export declare function readOnlyShellVerdict(command: string, shape?: ShellComma
|
|
|
40
41
|
* line first.
|
|
41
42
|
*/
|
|
42
43
|
export declare function readOnlyLineObjection(command: string, cwd?: string): string | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* What the whole text of `command` binds — every redirection of every segment (a single one, an `exec`'s, a `{name}` one included),
|
|
46
|
+
* read by the station's own computation ({@link descriptorsOfPlan}): the one binding map the shape tier's operand reading shares with
|
|
47
|
+
* the station's rebinding rule. The plan is read with no listed readers: the bindings do not depend on which programs read.
|
|
48
|
+
*/
|
|
49
|
+
export declare function lineDescriptorsOfText(command: string): LineDescriptors;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
2
|
import { readShellCommand } from "./shell-lexer.js";
|
|
3
|
+
import { descriptorsOfPlan, newLineDescriptors, standardDeviceUnrebound } from "../tools/fs/shell-read-device.js";
|
|
4
|
+
import { planShellReads } from "../tools/fs/shell-read-plan.js";
|
|
5
|
+
import { writesOnlyStandardDevices } from "../tools/fs/shell-read-write-targets.js";
|
|
3
6
|
import { FIND_ACTION_PRIMARIES, FIND_NEWER_PRIMARY, FIND_VALUE_PRIMARIES, FLAG_VALUE_ACCEPTS, READ_ONLY_BARE_ONLY, READ_ONLY_BARE_PROGRAMS, READ_ONLY_COMMAND_TABLE, READ_ONLY_ENV_NAMES, READ_ONLY_EXACT_FORMS, READ_ONLY_GLOB_PROGRAMS, XARGS_READ_ONLY_TARGETS, dockerRetargetDanger, } from "./read-only-shell-table.js";
|
|
4
7
|
const NOT = (reason) => ({ readOnly: false, reason });
|
|
5
8
|
export const CD_GIT_COMPOUND_SENTENCE = "This command changes directory before running a version-control command, which can pick up untrusted hooks or repository configuration from the target directory. Approve only if you trust it.";
|
|
@@ -39,7 +42,7 @@ const PRINTF_ESCAPE = new RegExp(`%[^%a-zA-Z]*${PRINTF_LENGTH}\\\\[0-7xX]`);
|
|
|
39
42
|
const PRINTF_NUMERIC = new RegExp(`%[-+ 0#']*[0-9.*]*${PRINTF_LENGTH}[diouxXeEfFgGaAn]`);
|
|
40
43
|
const JQ_DANGER_FLAG = /^['"]?-[a-zA-Z]*[fL]/;
|
|
41
44
|
const JQ_DANGER_TEXT = /--from-file|--rawfile|--slurpfile|--run-tests|--library-path|\benv\b|\$ENV\b|\binclude\b|\bimport\b/;
|
|
42
|
-
export function readOnlyShellVerdict(command, shape = readShellCommand(command), cwd) {
|
|
45
|
+
export function readOnlyShellVerdict(command, shape = readShellCommand(command), cwd, bound) {
|
|
43
46
|
const line = lineStructureObjection(shape);
|
|
44
47
|
if (line !== undefined)
|
|
45
48
|
return NOT(line);
|
|
@@ -59,8 +62,12 @@ export function readOnlyShellVerdict(command, shape = readShellCommand(command),
|
|
|
59
62
|
}
|
|
60
63
|
if (changesDirectoryBeforeGit(shape, cwd))
|
|
61
64
|
return NOT(CD_GIT_REASON);
|
|
65
|
+
const descriptors = newLineDescriptors();
|
|
66
|
+
descriptors.unknown = shape.segments.reduce((n, s) => n + s.redirections.length, 0) > 1;
|
|
67
|
+
let boundOfText;
|
|
68
|
+
const bindings = () => (boundOfText ??= (typeof bound === "function" ? bound() : bound) ?? lineDescriptorsOfText(command));
|
|
62
69
|
for (const seg of shape.segments) {
|
|
63
|
-
const verdict = segmentVerdict(seg);
|
|
70
|
+
const verdict = segmentVerdict(seg, descriptors, bindings);
|
|
64
71
|
if (!verdict.readOnly)
|
|
65
72
|
return verdict;
|
|
66
73
|
}
|
|
@@ -125,10 +132,10 @@ function peelBuiltinPrefix(words) {
|
|
|
125
132
|
return t;
|
|
126
133
|
}
|
|
127
134
|
}
|
|
128
|
-
function segmentVerdict(seg) {
|
|
135
|
+
function segmentVerdict(seg, line, bindings) {
|
|
129
136
|
for (const r of seg.redirections) {
|
|
130
137
|
const target = r.target.text;
|
|
131
|
-
if (!READING_REDIRECTS.has(r.op) && target
|
|
138
|
+
if (!READING_REDIRECTS.has(r.op) && !standardDeviceUnrebound(target, line, []) && !(r.op === ">&" && /^\d+$/.test(target)))
|
|
132
139
|
return NOT(`the redirection \`${r.op} ${target}\` writes`);
|
|
133
140
|
if (/^\/dev\/(tcp|udp)\//.test(target))
|
|
134
141
|
return NOT("a redirection to a network pseudo-device");
|
|
@@ -148,11 +155,17 @@ function segmentVerdict(seg) {
|
|
|
148
155
|
const program = words[0];
|
|
149
156
|
if (run.some((w) => expansionKind(w) === "glob"))
|
|
150
157
|
return GLOB_PROGRAMS.has(program) ? READ_ONLY : NOT(`\`${program}\` with a glob argument — the glob may name anything`);
|
|
158
|
+
if (DEVICE_WRITERS.has(program) && writesOnlyStandardDevices(program, words.slice(1), bindings()))
|
|
159
|
+
return READ_ONLY;
|
|
151
160
|
const structural = structuralReading(words);
|
|
152
161
|
if (structural !== null)
|
|
153
162
|
return structural ? READ_ONLY : NOT(`\`${program}\` with these arguments is not a read-only form`);
|
|
154
163
|
return tableReading(words, run);
|
|
155
164
|
}
|
|
165
|
+
export function lineDescriptorsOfText(command) {
|
|
166
|
+
return descriptorsOfPlan(planShellReads(command, { readers: new Set(), cwd: "/", roots: [] }));
|
|
167
|
+
}
|
|
168
|
+
const DEVICE_WRITERS = new Set(["tee", "cp"]);
|
|
156
169
|
function structuralReading(words) {
|
|
157
170
|
const program = words[0];
|
|
158
171
|
if (BARE_ONLY.has(program))
|
|
@@ -167,6 +180,8 @@ function structuralReading(words) {
|
|
|
167
180
|
return form[0] !== "docker" || !dockerRetargetDanger(words);
|
|
168
181
|
if (program === "echo")
|
|
169
182
|
return true;
|
|
183
|
+
if (program === "exec")
|
|
184
|
+
return words.length === 1;
|
|
170
185
|
if (program === "printf")
|
|
171
186
|
return printfReading(words);
|
|
172
187
|
if (program === "ls")
|
|
@@ -2,8 +2,9 @@ import { anchorSpelling, isAbsoluteForFamily, isBlockedDevicePath, isShellRooted
|
|
|
2
2
|
import { PARSE_FAILURE_SENTENCE, UNRESOLVED_SENTENCE, argvOf, lexShell, literalTildeSpelling, } from "../../shell/lex.js";
|
|
3
3
|
import { shellWordsOf } from "../../shell/shell-words.js";
|
|
4
4
|
import { textBindsHome } from "./shell-read-bindings.js";
|
|
5
|
+
import { GENERATOR_DEVICES, newLineDescriptors, bindDescriptors, isNamedDescriptorWord, standardDeviceUnrebound, descriptorsBoundBy } from "./shell-read-device.js";
|
|
5
6
|
import { NOT_AUTO_ALLOWED, argvReentryUnjudged, isBareControlWord, programPositionRefusalSentence, readProgramPosition, } from "./bash-program-position.js";
|
|
6
|
-
import { BLOCK_WORD_NAMES, attachedStdinFile, declaresOption, firstOperandSlot, opensNoOperand, programDelivery, programRow, programTextReadings,
|
|
7
|
+
import { BLOCK_WORD_NAMES, attachedStdinFile, byteBoundIsSmall, declaresOption, firstOperandSlot, opensNoOperand, programDelivery, programRow, programTextReadings, readArgs, readProgramTextLanguage, runsShellText, selectedOptions, slotValueCandidates, suppliesFirstOperandSlot, takesSeparatedDatum, valuesAreLiteral, writeReadings, writesPath, } from "./program-read-model.js";
|
|
7
8
|
export const BASH_READONLY_DEFAULT_ALLOW = [
|
|
8
9
|
"ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
|
|
9
10
|
"grep", "cut", "tr", "basename", "dirname", "stat", "du", "df", "which",
|
|
@@ -82,8 +83,8 @@ export function carriesShellRedirection(text) {
|
|
|
82
83
|
return SHELL_REDIRECTION_OPERATORS.test(text);
|
|
83
84
|
}
|
|
84
85
|
const SHELL_REDIRECTION_OPERATORS = /[<>]/;
|
|
85
|
-
function isDataFreeRedirection(r) {
|
|
86
|
-
if (r.op === ">" && r.fd === 2 && r.target !== undefined && r.target.raw
|
|
86
|
+
function isDataFreeRedirection(r, line) {
|
|
87
|
+
if (r.op === ">" && r.fd === 2 && r.target !== undefined && standardDeviceUnrebound(r.target.raw, line, descriptorsBoundBy(r)))
|
|
87
88
|
return true;
|
|
88
89
|
return r.op === ">&" && r.dup !== undefined && /^[12]$/.test(r.dup) && (r.fd === undefined || r.fd === 1 || r.fd === 2);
|
|
89
90
|
}
|
|
@@ -103,6 +104,13 @@ export function strictResidualRefusal(lexed, source, opts = {}) {
|
|
|
103
104
|
if ("parseFailure" in lexed)
|
|
104
105
|
return inClass(source) || SHELL_EXPANSION_CHARS.test(source) ? reason : PARSE_FAILURE_SENTENCE[lexed.parseFailure.reason];
|
|
105
106
|
const words = [];
|
|
107
|
+
const line = newLineDescriptors();
|
|
108
|
+
for (const s of lexed.segments)
|
|
109
|
+
for (const t of s.tokens)
|
|
110
|
+
if (t.kind === "redirect")
|
|
111
|
+
bindDescriptors(line, t);
|
|
112
|
+
else if (t.kind === "word" && isNamedDescriptorWord(t.raw))
|
|
113
|
+
line.named = true;
|
|
106
114
|
for (const s of lexed.segments) {
|
|
107
115
|
if (s.connector === "\n")
|
|
108
116
|
return reason;
|
|
@@ -115,7 +123,7 @@ export function strictResidualRefusal(lexed, source, opts = {}) {
|
|
|
115
123
|
}
|
|
116
124
|
if (t.kind !== "redirect")
|
|
117
125
|
return reason;
|
|
118
|
-
if (opts.dataFreeRedirections === true && isDataFreeRedirection(t))
|
|
126
|
+
if (opts.dataFreeRedirections === true && isDataFreeRedirection(t, line))
|
|
119
127
|
continue;
|
|
120
128
|
if (!tolerate)
|
|
121
129
|
return reason;
|
|
@@ -795,30 +803,6 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
|
|
|
795
803
|
}
|
|
796
804
|
unlistedSegments.add(si);
|
|
797
805
|
}
|
|
798
|
-
const HEAD_AUTO_ALLOW_MAX = 1_000_000;
|
|
799
|
-
const byteBoundIsSmall = (row, args) => {
|
|
800
|
-
if (row === undefined)
|
|
801
|
-
return false;
|
|
802
|
-
let byteBound = false;
|
|
803
|
-
for (const e of readArgRoles(row, args)) {
|
|
804
|
-
if (e.kind === "operand" || e.kind === "end-of-options")
|
|
805
|
-
continue;
|
|
806
|
-
if (e.kind === "value" && e.spec?.bounds === "bytes") {
|
|
807
|
-
if (!/^\d+$/.test(args[e.at]) || Number(args[e.at]) > HEAD_AUTO_ALLOW_MAX)
|
|
808
|
-
return false;
|
|
809
|
-
byteBound = true;
|
|
810
|
-
continue;
|
|
811
|
-
}
|
|
812
|
-
if (e.kind === "option" && typeof e.reading === "object" && e.reading.spec?.bounds === "bytes") {
|
|
813
|
-
if (!/^\d+$/.test(e.reading.attached) || Number(e.reading.attached) > HEAD_AUTO_ALLOW_MAX)
|
|
814
|
-
return false;
|
|
815
|
-
byteBound = true;
|
|
816
|
-
continue;
|
|
817
|
-
}
|
|
818
|
-
return false;
|
|
819
|
-
}
|
|
820
|
-
return byteBound;
|
|
821
|
-
};
|
|
822
806
|
const foldedSegments = [];
|
|
823
807
|
let shapeRefusal = nameRefusal;
|
|
824
808
|
for (let si = 0; si < segments.length; si++) {
|
|
@@ -896,7 +880,6 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
|
|
|
896
880
|
shapeRefusal ??= `\`${name}\` in follow mode never terminates ${NOT_AUTO_ALLOWED}`;
|
|
897
881
|
continue;
|
|
898
882
|
}
|
|
899
|
-
const GENERATOR_DEVICES = new Set(["/dev/zero", "/dev/random", "/dev/urandom", "/dev/full"]);
|
|
900
883
|
const deviceCandidates = openableWordsOf(row, restArgs);
|
|
901
884
|
const deviceArgs = deviceCandidates.map((t) => resolveOperandLexically(undefined, t, boundary?.homeDir) ?? normalizeAbsPathLexically(t)).filter(isBlockedDevicePath);
|
|
902
885
|
const rescuedByHead = byteBoundIsSmall(row, restArgs) && deviceArgs.every((d) => GENERATOR_DEVICES.has(d));
|
|
@@ -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
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { denyFloorShadowsOf } from "./shell-read-judge.js";
|
|
2
|
+
import { GENERATOR_DEVICES, OPENED_PROVENANCES, passesUnasked, standardDeviceUnrebound } from "./shell-read-device.js";
|
|
2
3
|
import { renamed } from "./shell-read-word.js";
|
|
3
|
-
import { implementationsOf, programRow, selectedOptions, writeReadings } from "./program-read-model.js";
|
|
4
|
+
import { byteBoundIsSmall, implementationsOf, programRow, readArgRoles, selectedOptions, subcommandOf, writeReadings } from "./program-read-model.js";
|
|
4
5
|
import { programBasename, readProgramPosition } from "./bash-program-position.js";
|
|
5
6
|
export function programArgs(segment) {
|
|
6
7
|
const words = segment.argv.map((w) => ({ raw: w.raw, spelled: w.folded }));
|
|
@@ -11,6 +12,25 @@ export function sedBackupNames(name, suffix) {
|
|
|
11
12
|
const appended = `${name}${suffix}`;
|
|
12
13
|
return suffix.includes("*") ? [suffix.replace(/\*/g, () => name), appended] : [appended];
|
|
13
14
|
}
|
|
15
|
+
export const anyOptionWord = (words) => words.some((w) => w.startsWith("-"));
|
|
16
|
+
function writesThrough(roles, row, words) {
|
|
17
|
+
const args = words.map((w) => w.spelled);
|
|
18
|
+
if (roles.length === 0 || readArgRoles(row, args).some((e) => e.kind === "option" && e.reading === "speculative"))
|
|
19
|
+
return false;
|
|
20
|
+
return roles.every((role) => role.through === true || (role.through === "plain" && !anyOptionWord(args)));
|
|
21
|
+
}
|
|
22
|
+
export function writesOnlyStandardDevices(program, args, line) {
|
|
23
|
+
const row = programRow(program);
|
|
24
|
+
if (row === undefined)
|
|
25
|
+
return false;
|
|
26
|
+
const words = args.map((a) => ({ raw: a, spelled: a }));
|
|
27
|
+
const readings = writeReadings(row, [...args]);
|
|
28
|
+
return (readings.length > 0 &&
|
|
29
|
+
readings.every((r) => {
|
|
30
|
+
const written = r.written.filter((w) => w.role.writtenAt?.length !== 0);
|
|
31
|
+
return !r.unexplained && written.length > 0 && writesThrough(written.map((w) => w.role), row, words) && written.every((w) => standardDeviceUnrebound(w.value ?? args[w.at], line, []));
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
14
34
|
const WORD_PROVENANCES = new Set(["operand", "unlisted_word", "option_value"]);
|
|
15
35
|
function writeOwnersOf(segment) {
|
|
16
36
|
const pos = readProgramPosition(segment.argv);
|
|
@@ -30,18 +50,19 @@ function writeOwnersOf(segment) {
|
|
|
30
50
|
function writeTargetsOf(segment) {
|
|
31
51
|
const out = [];
|
|
32
52
|
const writeOnly = new Set();
|
|
33
|
-
const write = (c, seats, written, child, onlyIfExists) => void out.push({ candidate: { ...c, face: "write" }, ...(seats !== undefined ? { seats } : {}), ...(written !== undefined ? { written } : {}), ...(child === true ? { child } : {}), ...(onlyIfExists !== undefined ? { onlyIfExists } : {}) });
|
|
53
|
+
const write = (c, seats, written, child, onlyIfExists, through) => void out.push({ candidate: { ...c, face: "write", ...(through === true ? { through } : {}) }, ...(seats !== undefined ? { seats } : {}), ...(written !== undefined ? { written } : {}), ...(child === true ? { child } : {}), ...(onlyIfExists !== undefined ? { onlyIfExists } : {}) });
|
|
34
54
|
for (const r of segment.redirects)
|
|
35
55
|
if (r.direction === "out" && r.target !== undefined)
|
|
36
|
-
write(r.target);
|
|
56
|
+
write(r.target, undefined, undefined, undefined, undefined, true);
|
|
37
57
|
for (const c of segment.reads)
|
|
38
58
|
if (c.provenance === "write_redirect_target")
|
|
39
|
-
write(c);
|
|
59
|
+
write(c, undefined, undefined, undefined, undefined, true);
|
|
40
60
|
const owners = writeOwnersOf(segment).map(({ row, words, program }) => {
|
|
41
61
|
const args = words.map((w) => w.spelled);
|
|
42
62
|
const all = writeReadings(row, args);
|
|
43
|
-
const
|
|
44
|
-
|
|
63
|
+
const selected = implementationsOf(row).flatMap((view) => selectedOptions(view, args, "one-implementation"));
|
|
64
|
+
const moved = [...new Set(selected.flatMap((o) => o.writesAt ?? []))];
|
|
65
|
+
return { row, words, program, all, readings: all.filter((r) => r.written.length > 0 || (r.unexplained && r.operandRole !== undefined)), override: moved.length > 0 ? moved : undefined, beyondOperands: selected.some((o) => o.effect === "writes" || o.effect === "executes") };
|
|
45
66
|
});
|
|
46
67
|
const edited = new Map();
|
|
47
68
|
for (const c of segment.reads) {
|
|
@@ -77,7 +98,7 @@ function writeTargetsOf(segment) {
|
|
|
77
98
|
const seats = stated ? [...new Set(roles.flatMap((role) => o.override ?? role.seats ?? ["landing"]))] : undefined;
|
|
78
99
|
const written = unexplained || roles.some((role) => role.writtenAt === undefined) ? undefined : [...new Set(roles.flatMap((role) => role.writtenAt ?? []))];
|
|
79
100
|
const speculative = readings < o.all.length && roles.every((role) => role.effect === "edit-in-place") ? c : undefined;
|
|
80
|
-
write(c, seats, written, undefined, speculative);
|
|
101
|
+
write(c, seats, written, undefined, speculative, !unexplained && writesThrough(roles, o.row, o.words) ? true : undefined);
|
|
81
102
|
if (roles.some((role) => role.effect === "edit-in-place") && c.expands === false)
|
|
82
103
|
edited.set(o, [...(edited.get(o) ?? []), { candidate: c, ...(speculative !== undefined ? { onlyIfExists: speculative } : {}) }]);
|
|
83
104
|
}
|
|
@@ -85,11 +106,25 @@ function writeTargetsOf(segment) {
|
|
|
85
106
|
writeOnly.add(c);
|
|
86
107
|
}
|
|
87
108
|
for (const o of owners) {
|
|
88
|
-
for (const c of copyDestinationChildren(segment, o.words, o.readings))
|
|
109
|
+
for (const c of copyDestinationChildren(segment, o.row, o.words, o.readings))
|
|
89
110
|
write(c, ["landing", "entry"], undefined, true);
|
|
90
111
|
out.push(...backupTargetsOf(edited.get(o) ?? [], o.readings, o.override));
|
|
91
112
|
}
|
|
92
|
-
|
|
113
|
+
const readWords = new Set();
|
|
114
|
+
for (const o of owners) {
|
|
115
|
+
if (!o.program)
|
|
116
|
+
continue;
|
|
117
|
+
const args = o.words.map((w) => w.spelled);
|
|
118
|
+
const found = subcommandOf(o.row, args);
|
|
119
|
+
const sub = found !== undefined && found.row.operands !== undefined ? found : undefined;
|
|
120
|
+
const at = sub === undefined ? 0 : sub.at + 1;
|
|
121
|
+
for (const e of readArgRoles(sub?.row ?? o.row, args.slice(at))) {
|
|
122
|
+
const role = e.kind === "operand" || e.kind === "value" ? e.role : undefined;
|
|
123
|
+
if (role?.kind === "path" && (role.effect === "read" || role.writtenAt?.length === 0))
|
|
124
|
+
readWords.add(o.words[at + e.at].raw);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { targets: out, writeOnly, beyondOperands: owners.some((o) => o.beyondOperands), boundedRead: owners.some((o) => o.program && byteBoundIsSmall(o.row, o.words.map((w) => w.spelled))), readWords };
|
|
93
128
|
}
|
|
94
129
|
function backupTargetsOf(edited, readings, override) {
|
|
95
130
|
const suffixes = [...new Set(readings.flatMap((r) => r.suffixes))];
|
|
@@ -98,7 +133,7 @@ function backupTargetsOf(edited, readings, override) {
|
|
|
98
133
|
? [{ candidate: { ...renamed(f, `${f.spelled}${suffix}`), face: "write" }, seats: ["entry"], followed: { operand: f, suffix }, ...(onlyIfExists !== undefined ? { onlyIfExists } : {}) }]
|
|
99
134
|
: sedBackupNames(f.spelled, suffix).map((spelled) => ({ candidate: { ...renamed(f, spelled), face: "write" }, seats: ["entry"], ...(onlyIfExists !== undefined ? { onlyIfExists } : {}) }))));
|
|
100
135
|
}
|
|
101
|
-
function copyDestinationChildren(segment, words, readings) {
|
|
136
|
+
function copyDestinationChildren(segment, row, words, readings) {
|
|
102
137
|
const candidateOf = (raw) => segment.reads.find((c) => c.raw === raw && WORD_PROVENANCES.has(c.provenance));
|
|
103
138
|
const children = new Map();
|
|
104
139
|
for (const r of readings) {
|
|
@@ -109,6 +144,9 @@ function copyDestinationChildren(segment, words, readings) {
|
|
|
109
144
|
const destCandidate = candidateOf(dest.raw);
|
|
110
145
|
if (destCandidate !== undefined && destCandidate.expands !== false)
|
|
111
146
|
continue;
|
|
147
|
+
const destRole = r.written.find((w) => w.at === r.copy.destination.at)?.role;
|
|
148
|
+
if (destCandidate !== undefined && destRole !== undefined && passesUnasked({ ...destCandidate, face: "write", ...(writesThrough([destRole], row, words) ? { through: true } : {}) }))
|
|
149
|
+
continue;
|
|
112
150
|
for (const at of r.copy.sources) {
|
|
113
151
|
const src = words[at];
|
|
114
152
|
const source = candidateOf(src.raw);
|
|
@@ -123,8 +161,21 @@ function copyDestinationChildren(segment, words, readings) {
|
|
|
123
161
|
return [...children.values()];
|
|
124
162
|
}
|
|
125
163
|
export function judgedCandidatesOf(segment) {
|
|
126
|
-
const { targets: writes, writeOnly } = writeTargetsOf(segment);
|
|
127
|
-
const
|
|
164
|
+
const { targets: writes, writeOnly, beyondOperands, boundedRead, readWords } = writeTargetsOf(segment);
|
|
165
|
+
const throughWritten = new Set(writes.filter((w) => w.candidate.through === true && w.candidate.device === true).map((w) => w.candidate.raw));
|
|
166
|
+
const opened = (c) => {
|
|
167
|
+
if (c.provenance === "unlisted_word" && c.device === true && !readWords.has(c.raw) && !throughWritten.has(c.raw)) {
|
|
168
|
+
const { device: _unread, ...judged } = c;
|
|
169
|
+
return judged;
|
|
170
|
+
}
|
|
171
|
+
if (boundedRead && c.device !== true && c.expands === false && c.speculative === undefined && c.denyOnly !== true && GENERATOR_DEVICES.has(c.spelled) && OPENED_PROVENANCES.has(c.provenance) && (c.provenance !== "unlisted_word" || readWords.has(c.raw)))
|
|
172
|
+
return { ...c, device: true };
|
|
173
|
+
if (!beyondOperands || c.device !== true || c.provenance === "stdin_redirect")
|
|
174
|
+
return c;
|
|
175
|
+
const { device: _device, ...judged } = c;
|
|
176
|
+
return judged;
|
|
177
|
+
};
|
|
178
|
+
const reads = segment.reads.flatMap((c) => (c.provenance === "write_redirect_target" ? [] : [opened(writeOnly.has(c) ? { ...c, denyOnly: true } : c)]));
|
|
128
179
|
const plain = (cs) => cs.map((candidate) => ({ candidate }));
|
|
129
180
|
return [...plain(reads), ...plain(denyFloorShadowsOf(reads)), ...writes, ...plain(denyFloorShadowsOf(writes.map((w) => w.candidate)))];
|
|
130
181
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"repo": "core",
|
|
3
|
+
"version": "7.49.4",
|
|
4
|
+
"from": "33e4b503cea033b8c08b96103e4707a5f8a34573",
|
|
5
|
+
"to": "18d7fa8fa5f2d2949a3848397709cdf60920dbca",
|
|
6
|
+
"added": [],
|
|
7
|
+
"removed": [],
|
|
8
|
+
"changed": [],
|
|
9
|
+
"narrowed": [],
|
|
10
|
+
"closedSetMembers": [],
|
|
11
|
+
"closedSetsUnreadable": [],
|
|
12
|
+
"typeKeys": [],
|
|
13
|
+
"typeKeysUnreadableArms": [],
|
|
14
|
+
"typeKeysFlattenedCount": 223,
|
|
15
|
+
"signatures": [],
|
|
16
|
+
"declarationMoves": [],
|
|
17
|
+
"mintMoves": [],
|
|
18
|
+
"criteriaObserves": [
|
|
19
|
+
{
|
|
20
|
+
"kind": "typeMember",
|
|
21
|
+
"name": "AskRequest.classes",
|
|
22
|
+
"assertions": [
|
|
23
|
+
"G1.a",
|
|
24
|
+
"G2.a",
|
|
25
|
+
"G7.a"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"kind": "typeMember",
|
|
30
|
+
"name": "AskRequest.mandated",
|
|
31
|
+
"assertions": [
|
|
32
|
+
"G1.a",
|
|
33
|
+
"G7.a",
|
|
34
|
+
"G8.a"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"kind": "typeMember",
|
|
39
|
+
"name": "AskRequest.requiresRealApproval",
|
|
40
|
+
"assertions": [
|
|
41
|
+
"G8.a"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"kind": "typeMember",
|
|
46
|
+
"name": "RedactionReport.findings",
|
|
47
|
+
"assertions": [
|
|
48
|
+
"G4.a",
|
|
49
|
+
"G5.a",
|
|
50
|
+
"G6.a"
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"kind": "typeMember",
|
|
55
|
+
"name": "ShellReadVerdict.finding",
|
|
56
|
+
"assertions": [
|
|
57
|
+
"G3.a"
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
"consumersHint": {
|
|
62
|
+
"cli": [],
|
|
63
|
+
"server": [],
|
|
64
|
+
"sdk": [],
|
|
65
|
+
"note": "scanned cli@26456d2d server@a0533487 sdk@f1b30d4 — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
|
|
66
|
+
},
|
|
67
|
+
"breakingCount": 0,
|
|
68
|
+
"breakingItems": []
|
|
69
|
+
}
|