@sema-agent/core 7.17.0 → 7.17.2
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 +51 -0
- package/dist/core/fs-write-gate-policy.js +4 -0
- package/dist/core/memory-engine/engine.js +2 -1
- package/dist/core/memory-engine/layout.d.ts +18 -6
- package/dist/core/memory-engine/layout.js +40 -21
- package/dist/core/physical-path.d.ts +37 -0
- package/dist/core/physical-path.js +30 -0
- package/dist/core/runner/contracts.d.ts +3 -1
- package/dist/core/runner/prepare-artifact.d.ts +4 -5
- package/dist/core/runner/prepare-artifact.js +2 -16
- package/dist/core/runner/prepare-ask-lane.d.ts +3 -0
- package/dist/core/runner/prepare-ask-lane.js +3 -3
- package/dist/core/runner/prepare-park-ask.d.ts +3 -0
- package/dist/core/runner/prepare-park-ask.js +3 -2
- package/dist/core/runner/prepare-policy-chain.js +2 -2
- package/dist/core/runner/prepare-question-face.js +2 -1
- package/dist/core/runner/prepare-task.js +5 -5
- package/dist/core/runner/run-harness-handlers.js +4 -1
- package/dist/core/runner/run-leg.js +4 -1
- package/dist/core/sensitive-path-policy.js +7 -8
- package/dist/core/skills-directory.js +4 -3
- package/dist/core/spec-contract.js +5 -4
- package/dist/core/task-registry-shared.d.ts +5 -1
- package/dist/core/task-registry-shared.js +1 -0
- package/dist/core/tool-catalog-entries.js +1 -1
- package/dist/core/tool-policy.d.ts +56 -0
- package/dist/core/tool-policy.js +6 -0
- package/dist/engine/execution-env/node-execution-env.js +4 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/orchestration/workflow-script-store.js +9 -25
- package/dist/orchestration/workflow.js +6 -3
- package/dist/stores/cc/task-list-store.js +2 -10
- package/dist/stores/file/fs-atomic.d.ts +15 -18
- package/dist/stores/file/fs-atomic.js +4 -14
- package/dist/stores/file/mailbox-store.d.ts +7 -11
- package/dist/stores/file/mailbox-store.js +4 -11
- package/dist/tools/artifact/local-stub.js +4 -3
- package/dist/tools/fs/bash-readonly-classifier.d.ts +19 -1
- package/dist/tools/fs/bash-readonly-classifier.js +413 -12
- package/dist/tools/fs/fs-bash.js +42 -17
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +5 -1
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import { join, resolve, sep } from "node:path";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { assertRetentionPolicy } from "../../core/retention-policy.js";
|
|
4
|
-
import { existsSync, linkSync, readdirSync, readFileSync,
|
|
4
|
+
import { existsSync, linkSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { detachMailboxMessage, newestSentAt, readMailboxPeerMeta, } from "../../core/mailbox-store.js";
|
|
6
6
|
import { AppendLog, atomicWriteFile, canonicalStoreKey, ensureDir, readJsonlRecords, sanitizeScope, sanitizePathComponent } from "./fs-atomic.js";
|
|
7
|
+
import { physicalPathOf } from "../../core/physical-path.js";
|
|
7
8
|
import { assertAdoptionBootGate } from "./adoption/marker.js";
|
|
8
|
-
function realpathSyncSafe(p) {
|
|
9
|
-
try {
|
|
10
|
-
return realpathSync(p);
|
|
11
|
-
}
|
|
12
|
-
catch {
|
|
13
|
-
return p;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
9
|
function diskMarkOf(path) {
|
|
17
10
|
try {
|
|
18
11
|
const st = statSync(path);
|
|
@@ -361,8 +354,8 @@ export class FileMailboxStore {
|
|
|
361
354
|
const lexicalTmp = resolve(join(root, "tmp"));
|
|
362
355
|
ensureDir(lexicalDir);
|
|
363
356
|
ensureDir(lexicalTmp);
|
|
364
|
-
this.dir =
|
|
365
|
-
this.tmpDir =
|
|
357
|
+
this.dir = physicalPathOf(lexicalDir);
|
|
358
|
+
this.tmpDir = physicalPathOf(lexicalTmp);
|
|
366
359
|
}
|
|
367
360
|
boxPath(scope, handle) {
|
|
368
361
|
return join(this.dir, sanitizeScope(scope), `${sanitizePathComponent(handle).toLowerCase()}.jsonl`);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync,
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve, sep } from "node:path";
|
|
4
4
|
import {} from "../../core/artifact-host.js";
|
|
5
|
+
import { physicalPathOf } from "../../core/physical-path.js";
|
|
5
6
|
import { acquireStoreDirLock } from "../../stores/file/fs-atomic.js";
|
|
6
7
|
import { normalizePublishedPath } from "./artifact-tool.js";
|
|
7
8
|
export const EVAL_STUB_URL_PREFIX = "eval-stub://artifact/";
|
|
@@ -112,9 +113,9 @@ export class LocalArtifactStub {
|
|
|
112
113
|
}
|
|
113
114
|
#snapshotOf(row) {
|
|
114
115
|
const base = this.#versionDir(row.slug, row.version);
|
|
115
|
-
const realBase =
|
|
116
|
+
const realBase = physicalPathOf(base);
|
|
116
117
|
const files = row.files.map((f) => {
|
|
117
|
-
const target =
|
|
118
|
+
const target = physicalPathOf(resolve(base, ...f.path.split("/")));
|
|
118
119
|
if (!target.startsWith(realBase + sep))
|
|
119
120
|
throw new Error(`artifact stub ledger is corrupt (a file resolves outside its version directory): ${this.#ledgerPath()}`);
|
|
120
121
|
return { path: f.path, content: new Uint8Array(readFileSync(target)), ...(f.mediaType !== undefined ? { mediaType: f.mediaType } : {}) };
|
|
@@ -321,9 +321,18 @@ export interface CompoundReadonlyVerdict {
|
|
|
321
321
|
* (`cd -`, bare `cd`, a pattern). The STRUCTURAL form of the unresolvable sentence: `reason` carries the
|
|
322
322
|
* first one's sentence, this member every one of them, and both survive whatever other sentence (a shape
|
|
323
323
|
* refusal) takes precedence. CONSUMER CONTRACT: non-empty ⇒ the boundary could not read where the command
|
|
324
|
-
* reads — a fail-closed ask (plain
|
|
324
|
+
* reads — a fail-closed ask, never a vouch (plain on the classify seat, whose classifier asks beside it; MANDATED on the
|
|
325
|
+
* boundary-only seat, where nothing else would ask).
|
|
325
326
|
*/
|
|
326
327
|
unresolvedOperands?: readonly string[];
|
|
328
|
+
/**
|
|
329
|
+
* The grammar gate refused the WHOLE command before the walk ran (an escape, a substitution, a subshell, a
|
|
330
|
+
* redirection, a line break — see {@link rejectedSansRedirection} and the compound gate): no operand was judged,
|
|
331
|
+
* and what the shell would run is not knowable from the text (a reader can be spelled `ca\t`, fed by `<`, or hidden
|
|
332
|
+
* in `$(…)`). CONSUMER CONTRACT: present ⇒ the boundary judged nothing — MANDATED on both probe seats (a name-reading
|
|
333
|
+
* arm or a stored allow rule must not retire a read nobody judged). Structure, never the sentence.
|
|
334
|
+
*/
|
|
335
|
+
refusedWhole?: true;
|
|
327
336
|
/**
|
|
328
337
|
* Operands of a RECURSIVE/EXPANDING read form (`grep -r`, `ls -R`, `du`, … — see
|
|
329
338
|
* {@link RECURSIVE_READ_FORMS}) judged with a {@link BashReadonlyRootBoundary.denyMatch} seat wired.
|
|
@@ -380,6 +389,15 @@ export declare function formatOutOfRootReadApprovalOption(directory: string): st
|
|
|
380
389
|
* has no filesystem and stays lexical, and a REMOTE env keeps the lexical behaviour (its
|
|
381
390
|
* `canonicalPath` is an RPC per candidate). */
|
|
382
391
|
export declare function resolveOperandLexically(base: string | undefined, operand: string, homeDir: string | undefined): string | undefined;
|
|
392
|
+
/** Whether a command the grammar gate REFUSED WHOLE may READ a file the boundary never judged. The answer is the
|
|
393
|
+
* complement of a WHITELIST: the command is exempt only when every part of it is a shape the scan fully understands
|
|
394
|
+
* and none of those shapes reads a file outside the walk's reach — everything else is read evidence, so a token the
|
|
395
|
+
* scan does not understand mandates rather than passes. Exempt shapes, per segment: a program that reads no path
|
|
396
|
+
* (`echo`, `python3 -`, `tee out.txt`, an unlisted program); `cat` / `wc` with payload-free flags whose input is a
|
|
397
|
+
* here-document, a here-string or a literal in-root stdin file; a directory change (which then makes every relative
|
|
398
|
+
* stdin literal in the command unjudged). A substitution body (`$(…)`, backticks, `<(…)`) is judged on its own: green
|
|
399
|
+
* under the compound walk, or exempt under this whitelist; a here-document body is text, not commands. */
|
|
400
|
+
export declare function refusedCommandMayRead(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): boolean;
|
|
383
401
|
/**
|
|
384
402
|
* The home directory's VARIABLE spellings, SUBSTITUTED with the declared value before a read face segments the
|
|
385
403
|
* command: `$HOME` / `${HOME}` at the START of a word (an empty quote pair before it included), unquoted or
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, pathFamilyOf, withinAnyRoot } from "./safety.js";
|
|
1
|
+
import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf, withinAnyRoot } from "./safety.js";
|
|
2
2
|
export const NOT_AUTO_ALLOWED = "— not auto-allowed";
|
|
3
3
|
export const BASH_READONLY_DEFAULT_ALLOW = [
|
|
4
4
|
"ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
|
|
@@ -224,6 +224,8 @@ export function resolveOperandLexically(base, operand, homeDir) {
|
|
|
224
224
|
return undefined;
|
|
225
225
|
}
|
|
226
226
|
const family = base === undefined ? undefined : pathFamilyOf({ root: base });
|
|
227
|
+
if (!win32NamespaceScreen(raw, family).ok)
|
|
228
|
+
return undefined;
|
|
227
229
|
if (isAbsoluteForFamily(family, raw))
|
|
228
230
|
return normalizeAbsPathLexically(nativeUncSpellingOf(family, raw));
|
|
229
231
|
if (base === undefined || family === undefined || !isAbsoluteForFamily(family, base))
|
|
@@ -232,6 +234,361 @@ export function resolveOperandLexically(base, operand, homeDir) {
|
|
|
232
234
|
return undefined;
|
|
233
235
|
return normalizeAbsPathLexically(joinForFamily(family, base, raw));
|
|
234
236
|
}
|
|
237
|
+
function reheadSegment(toks, head, from = 0) {
|
|
238
|
+
return { folded: [head, ...toks.folded.slice(from + 1)], raw: [head, ...toks.raw.slice(from + 1)] };
|
|
239
|
+
}
|
|
240
|
+
const SHELL_CONTROL_PREFIXES = new Set(["!", "if", "then", "elif", "else", "while", "until", "do", "time"]);
|
|
241
|
+
const SHELL_BLOCK_WORDS = new Set(["for", "select", "case", "esac", "fi", "done", "function", "coproc", "[[", "]]", "in"]);
|
|
242
|
+
function isBareControlWord(toks, i, words) {
|
|
243
|
+
const word = toks.folded[i];
|
|
244
|
+
return word !== undefined && toks.raw[i] === word && words.has(word);
|
|
245
|
+
}
|
|
246
|
+
function tokensMoveBase(toks, allow) {
|
|
247
|
+
let from = 0;
|
|
248
|
+
while (from < toks.folded.length && isBareControlWord(toks, from, SHELL_CONTROL_PREFIXES))
|
|
249
|
+
from++;
|
|
250
|
+
const head = toks.folded[from];
|
|
251
|
+
if (head === undefined)
|
|
252
|
+
return false;
|
|
253
|
+
if (toks.raw[from] !== head)
|
|
254
|
+
return true;
|
|
255
|
+
if (head === "cd")
|
|
256
|
+
return true;
|
|
257
|
+
if (!COMMAND_LAUNCHERS.has(head))
|
|
258
|
+
return false;
|
|
259
|
+
const unwrapped = unwrapLauncher(toks.folded.slice(from), allow);
|
|
260
|
+
return unwrapped.kind === "refused" || (unwrapped.kind === "reader" && unwrapped.head === "cd");
|
|
261
|
+
}
|
|
262
|
+
const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi)?sh|eval|source|\.)$/;
|
|
263
|
+
export function refusedCommandMayRead(command, allow, boundary) {
|
|
264
|
+
return !refusedCommandExempt(command, allow, boundary, 0);
|
|
265
|
+
}
|
|
266
|
+
const STDIN_ONLY_READER_FLAGS = new Map([
|
|
267
|
+
["cat", /^-[AbeEnstTuv]+$/],
|
|
268
|
+
["wc", /^-[clmwL]+$/],
|
|
269
|
+
]);
|
|
270
|
+
const SUBSTITUTION_PLACEHOLDER = "\u0000subst";
|
|
271
|
+
function withoutHeredocBodies(text) {
|
|
272
|
+
const lines = text.split("\n");
|
|
273
|
+
const out = [];
|
|
274
|
+
const expandedBodies = [];
|
|
275
|
+
let terminator;
|
|
276
|
+
let expanded = false;
|
|
277
|
+
let body = [];
|
|
278
|
+
for (const line of lines) {
|
|
279
|
+
if (terminator !== undefined) {
|
|
280
|
+
if (line.trim() === terminator) {
|
|
281
|
+
if (expanded)
|
|
282
|
+
expandedBodies.push(body.join("\n"));
|
|
283
|
+
terminator = undefined;
|
|
284
|
+
body = [];
|
|
285
|
+
}
|
|
286
|
+
else
|
|
287
|
+
body.push(line);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
out.push(line);
|
|
291
|
+
const header = heredocHeaderOf(line);
|
|
292
|
+
if (header === null)
|
|
293
|
+
return undefined;
|
|
294
|
+
if (header !== undefined) {
|
|
295
|
+
terminator = header.word;
|
|
296
|
+
expanded = !header.quoted;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return terminator === undefined ? { outer: out.join("\n"), expandedBodies } : undefined;
|
|
300
|
+
}
|
|
301
|
+
function heredocHeaderOf(line) {
|
|
302
|
+
let inSingle = false;
|
|
303
|
+
let inDouble = false;
|
|
304
|
+
for (let i = 0; i < line.length; i++) {
|
|
305
|
+
const ch = line[i];
|
|
306
|
+
if (ch === "\\" && !inSingle) {
|
|
307
|
+
i++;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (ch === "'" && !inDouble) {
|
|
311
|
+
inSingle = !inSingle;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (ch === '"' && !inSingle) {
|
|
315
|
+
inDouble = !inDouble;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (inSingle || inDouble)
|
|
319
|
+
continue;
|
|
320
|
+
if (ch === "#")
|
|
321
|
+
return undefined;
|
|
322
|
+
if (ch === "<" && line[i + 1] === "<") {
|
|
323
|
+
if (line[i + 2] === "<") {
|
|
324
|
+
i += 2;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const m = /^<<-?\s*([^\s;|&<>()]+)/.exec(line.slice(i));
|
|
328
|
+
if (m === null)
|
|
329
|
+
return null;
|
|
330
|
+
const word = m[1];
|
|
331
|
+
if (/[$`]/.test(word))
|
|
332
|
+
return null;
|
|
333
|
+
const delimiter = word.replace(/\\(.)/g, "$1").replace(/["']/g, "");
|
|
334
|
+
if (delimiter === "")
|
|
335
|
+
return null;
|
|
336
|
+
return { word: delimiter, quoted: /["'\\]/.test(word) };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
function splitSubstitutions(text, quotesAreLiteral = false) {
|
|
342
|
+
let outer = "";
|
|
343
|
+
const bodies = [];
|
|
344
|
+
let i = 0;
|
|
345
|
+
let inSingle = false;
|
|
346
|
+
let inDouble = false;
|
|
347
|
+
while (i < text.length) {
|
|
348
|
+
const ch = text[i];
|
|
349
|
+
if (ch === "\\" && !inSingle) {
|
|
350
|
+
outer += text.slice(i, i + 2);
|
|
351
|
+
i += 2;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (ch === "'" && !inDouble && !quotesAreLiteral) {
|
|
355
|
+
inSingle = !inSingle;
|
|
356
|
+
outer += ch;
|
|
357
|
+
i++;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (ch === '"' && !inSingle && !quotesAreLiteral) {
|
|
361
|
+
inDouble = !inDouble;
|
|
362
|
+
outer += ch;
|
|
363
|
+
i++;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (inSingle) {
|
|
367
|
+
outer += ch;
|
|
368
|
+
i++;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (ch === "`") {
|
|
372
|
+
const close = text.indexOf("`", i + 1);
|
|
373
|
+
if (close < 0)
|
|
374
|
+
return undefined;
|
|
375
|
+
bodies.push(text.slice(i + 1, close));
|
|
376
|
+
outer += SUBSTITUTION_PLACEHOLDER;
|
|
377
|
+
i = close + 1;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if ((ch === "$" || ch === "<" || ch === ">") && text[i + 1] === "(") {
|
|
381
|
+
let depth = 0;
|
|
382
|
+
let j = i + 1;
|
|
383
|
+
let quoted;
|
|
384
|
+
for (; j < text.length; j++) {
|
|
385
|
+
const c = text[j];
|
|
386
|
+
if (quoted !== undefined) {
|
|
387
|
+
if (c === "\\" && quoted === '"')
|
|
388
|
+
j++;
|
|
389
|
+
else if (c === quoted)
|
|
390
|
+
quoted = undefined;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (c === "'" || c === '"') {
|
|
394
|
+
quoted = c;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (c === "(")
|
|
398
|
+
depth++;
|
|
399
|
+
else if (c === ")") {
|
|
400
|
+
depth--;
|
|
401
|
+
if (depth === 0)
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (j >= text.length)
|
|
406
|
+
return undefined;
|
|
407
|
+
bodies.push(text.slice(i + 2, j));
|
|
408
|
+
outer += SUBSTITUTION_PLACEHOLDER;
|
|
409
|
+
i = j + 1;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
outer += ch;
|
|
413
|
+
i++;
|
|
414
|
+
}
|
|
415
|
+
if (inSingle)
|
|
416
|
+
return undefined;
|
|
417
|
+
return { outer, bodies };
|
|
418
|
+
}
|
|
419
|
+
function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside = false) {
|
|
420
|
+
if (depth > 4)
|
|
421
|
+
return false;
|
|
422
|
+
const stripped = withoutHeredocBodies(command);
|
|
423
|
+
if (stripped === undefined)
|
|
424
|
+
return false;
|
|
425
|
+
const split = splitSubstitutions(stripped.outer);
|
|
426
|
+
if (split === undefined)
|
|
427
|
+
return false;
|
|
428
|
+
const bodies = [...split.bodies];
|
|
429
|
+
for (const expandedBody of stripped.expandedBodies) {
|
|
430
|
+
const inner = splitSubstitutions(expandedBody, true);
|
|
431
|
+
if (inner === undefined)
|
|
432
|
+
return false;
|
|
433
|
+
bodies.push(...inner.bodies);
|
|
434
|
+
}
|
|
435
|
+
const fold = (w) => w.replace(/\\(.)/g, "$1").replace(/["']/g, "");
|
|
436
|
+
const programOf = (w) => {
|
|
437
|
+
const inner = fold(w);
|
|
438
|
+
return inner.includes("/") ? inner.slice(inner.lastIndexOf("/") + 1) : inner;
|
|
439
|
+
};
|
|
440
|
+
const segments = split.outer.split(/\n|;|&&|\|\||\||(?<![>&\d])&(?![>&])/).map((segment) => {
|
|
441
|
+
const toks = [];
|
|
442
|
+
for (const word of segment.trim().split(/\s+/)) {
|
|
443
|
+
let rest = word;
|
|
444
|
+
while (rest !== "") {
|
|
445
|
+
const m = /^(\d*(?:<<<|<<|<|>>|>)(?:&\d*)?|&>>?)(.*)$/.exec(rest);
|
|
446
|
+
if (m !== null) {
|
|
447
|
+
toks.push(m[1].replace(/^\d+/, ""));
|
|
448
|
+
rest = m[2];
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const at = rest.search(/\d*(?:<<<|<<|<|>>|>)|&>/);
|
|
452
|
+
if (at <= 0) {
|
|
453
|
+
toks.push(rest);
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
toks.push(rest.slice(0, at));
|
|
457
|
+
rest = rest.slice(at);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const commentAt = toks.findIndex((t) => t.startsWith("#"));
|
|
461
|
+
return (commentAt >= 0 ? toks.slice(0, commentAt) : toks)
|
|
462
|
+
.map((t) => (/^[A-Za-z_][A-Za-z0-9_]*\(\)$/.test(t) ? "{" : t.replace(/^[({]+|[)}]+$/g, "")))
|
|
463
|
+
.filter((t) => t !== "");
|
|
464
|
+
});
|
|
465
|
+
const headIndex = (toks) => {
|
|
466
|
+
let i = 0;
|
|
467
|
+
while (i < toks.length) {
|
|
468
|
+
const w = fold(toks[i]);
|
|
469
|
+
if (ASSIGNMENT_WORD.test(w) || SHELL_CONTROL_PREFIXES.has(w) || SHELL_BLOCK_WORDS.has(w) || /^[{}]$/.test(w)) {
|
|
470
|
+
i++;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (/^(?:<<<|<<|<|>>?|&>>?)$/.test(w)) {
|
|
474
|
+
i += 2;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (/^(?:>>?|<)&\d*$/.test(w)) {
|
|
478
|
+
i++;
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
return i;
|
|
484
|
+
};
|
|
485
|
+
const runProgramOf = (toks, i) => {
|
|
486
|
+
let head = programOf(toks[i] ?? "");
|
|
487
|
+
let guard = 0;
|
|
488
|
+
while (COMMAND_LAUNCHERS.has(head) && guard++ < 8) {
|
|
489
|
+
let j = i + 1;
|
|
490
|
+
while (j < toks.length && (ASSIGNMENT_WORD.test(fold(toks[j])) || /^\d+[smhd]?$/.test(fold(toks[j]))))
|
|
491
|
+
j++;
|
|
492
|
+
const next = toks[j];
|
|
493
|
+
if (next === undefined || fold(next).startsWith("-"))
|
|
494
|
+
return undefined;
|
|
495
|
+
i = j;
|
|
496
|
+
head = programOf(next);
|
|
497
|
+
}
|
|
498
|
+
return head;
|
|
499
|
+
};
|
|
500
|
+
const baseMoved = baseMovedOutside ||
|
|
501
|
+
segments.some((toks) => {
|
|
502
|
+
const i = headIndex(toks);
|
|
503
|
+
if (i >= toks.length)
|
|
504
|
+
return false;
|
|
505
|
+
const head = runProgramOf(toks, i);
|
|
506
|
+
return head === undefined || head === "cd" || UNMODELLED_BASE_MOVERS.has(head);
|
|
507
|
+
});
|
|
508
|
+
for (const body of bodies) {
|
|
509
|
+
const walked = classifyCompoundReadonlyDetailed(body, allow, boundary);
|
|
510
|
+
const fullyGreen = !baseMoved && walked.reason === undefined && walked.readDenied !== true && walked.outOfRootRead !== true && walked.refusedWhole !== true &&
|
|
511
|
+
(walked.undecidedPaths?.length ?? 0) === 0 && (walked.unresolvedOperands?.length ?? 0) === 0 && (walked.recursiveReadPaths?.length ?? 0) === 0;
|
|
512
|
+
if (fullyGreen)
|
|
513
|
+
continue;
|
|
514
|
+
if (!refusedCommandExempt(body, allow, boundary, depth + 1, baseMoved))
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
const stdinFileJudgedInside = (raw) => {
|
|
518
|
+
if (raw === undefined || raw === "" || raw.includes(SUBSTITUTION_PLACEHOLDER) || /[$`()*?[\]{}~]/.test(raw))
|
|
519
|
+
return false;
|
|
520
|
+
if (boundary === undefined || baseMoved)
|
|
521
|
+
return false;
|
|
522
|
+
const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], fold(raw), boundary.homeDir);
|
|
523
|
+
if (resolved === undefined || boundary.denyMatch?.(resolved) != null)
|
|
524
|
+
return false;
|
|
525
|
+
return withinAnyRoot(boundary.roots, resolved);
|
|
526
|
+
};
|
|
527
|
+
for (const toks of segments) {
|
|
528
|
+
for (let j = 0; j < toks.length; j++)
|
|
529
|
+
if (toks[j] === "<" && !stdinFileJudgedInside(toks[j + 1]))
|
|
530
|
+
return false;
|
|
531
|
+
let i = headIndex(toks);
|
|
532
|
+
if (i >= toks.length)
|
|
533
|
+
continue;
|
|
534
|
+
const headWord = fold(toks[i]);
|
|
535
|
+
if (headWord.includes(SUBSTITUTION_PLACEHOLDER) || /[$`]/.test(headWord))
|
|
536
|
+
return false;
|
|
537
|
+
let head = programOf(headWord);
|
|
538
|
+
if (COMMAND_LAUNCHERS.has(head)) {
|
|
539
|
+
const wrapped = runProgramOf(toks, i);
|
|
540
|
+
if (wrapped === undefined || /[$`]/.test(wrapped) || SHELL_REENTRY_PROGRAM_RE.test(wrapped))
|
|
541
|
+
return false;
|
|
542
|
+
const unwrapped = unwrapLauncher(toks.slice(i).map(fold), allow);
|
|
543
|
+
if (unwrapped.kind === "refused")
|
|
544
|
+
return false;
|
|
545
|
+
if (unwrapped.kind === "unlisted")
|
|
546
|
+
continue;
|
|
547
|
+
head = unwrapped.head;
|
|
548
|
+
i += unwrapped.index;
|
|
549
|
+
}
|
|
550
|
+
if (SHELL_REENTRY_PROGRAM_RE.test(head))
|
|
551
|
+
return false;
|
|
552
|
+
if (head === "cd" || UNMODELLED_BASE_MOVERS.has(head))
|
|
553
|
+
continue;
|
|
554
|
+
if (!allow.has(head) || NO_PATH_OPERAND_COMMANDS.has(head))
|
|
555
|
+
continue;
|
|
556
|
+
const flags = STDIN_ONLY_READER_FLAGS.get(head);
|
|
557
|
+
if (flags === undefined)
|
|
558
|
+
return false;
|
|
559
|
+
for (let j = i + 1; j < toks.length; j++) {
|
|
560
|
+
const t = toks[j];
|
|
561
|
+
if (t === "<<<" || t === "<<" || t === "<") {
|
|
562
|
+
j++;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (/^(?:>>?|&>>?)$/.test(t)) {
|
|
566
|
+
j++;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (/^(?:>>?|<)&\d*$/.test(t))
|
|
570
|
+
continue;
|
|
571
|
+
if (flags.test(fold(t)))
|
|
572
|
+
continue;
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
function unwrapLauncher(folded, allow) {
|
|
579
|
+
for (let i = 1; i < folded.length; i++) {
|
|
580
|
+
const t = folded[i];
|
|
581
|
+
if (ASSIGNMENT_WORD.test(t) || /^\d+[smhd]?$/.test(t) || COMMAND_LAUNCHERS.has(t))
|
|
582
|
+
continue;
|
|
583
|
+
if (t.startsWith("-"))
|
|
584
|
+
return { kind: "refused", reason: `"${folded[0]}" is given an option before the program it runs — which program runs is not readable without the launcher's option table ${NOT_AUTO_ALLOWED}` };
|
|
585
|
+
const head = t.includes("/") ? t.slice(t.lastIndexOf("/") + 1) : t;
|
|
586
|
+
if (SHELL_CONTROL_PREFIXES.has(head) || SHELL_BLOCK_WORDS.has(head))
|
|
587
|
+
return { kind: "refused", reason: `"${folded[0]}" is given shell control syntax ("${head}") where the program it runs would be ${NOT_AUTO_ALLOWED}` };
|
|
588
|
+
return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head } : { kind: "unlisted", head };
|
|
589
|
+
}
|
|
590
|
+
return { kind: "unlisted" };
|
|
591
|
+
}
|
|
235
592
|
function tokenizeSegment(segment) {
|
|
236
593
|
const raw = splitWordsQuoteAware(segment);
|
|
237
594
|
return { folded: raw.map((r) => { const f = foldQuoteRemovalToken(r); return tildeIsLiteral(r) ? literalTildeSpelling(f) : f; }), raw };
|
|
@@ -803,12 +1160,56 @@ function segmentCompoundForReadonly(command, homeDir) {
|
|
|
803
1160
|
export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts) {
|
|
804
1161
|
const split = segmentCompoundForReadonly(command, boundary?.homeDir);
|
|
805
1162
|
if ("reject" in split)
|
|
806
|
-
return { reason: split.reject };
|
|
1163
|
+
return { reason: split.reject, refusedWhole: true };
|
|
807
1164
|
const { segments, pipeFed } = split;
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1165
|
+
let nameRefusal;
|
|
1166
|
+
const unlistedSegments = new Set();
|
|
1167
|
+
const reheaded = new Map();
|
|
1168
|
+
for (let si = 0; si < segments.length; si++) {
|
|
1169
|
+
const toks = tokenizeSegment(segments[si]);
|
|
1170
|
+
const folded = toks.folded;
|
|
1171
|
+
let from = 0;
|
|
1172
|
+
while (from < folded.length && isBareControlWord(toks, from, SHELL_CONTROL_PREFIXES))
|
|
1173
|
+
from++;
|
|
1174
|
+
const written = folded[from];
|
|
1175
|
+
if (written === undefined)
|
|
1176
|
+
return { reason: "empty command" };
|
|
1177
|
+
if (isBareControlWord(toks, from, SHELL_BLOCK_WORDS)) {
|
|
1178
|
+
unlistedSegments.add(si);
|
|
1179
|
+
nameRefusal ??= `command "${written}" is not in the read-only allowlist`;
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
const parsed = parseLeadingCommandName(toks.raw.slice(from).join(" "), { quotedOperatorsAreText: true, pathPrefixedNameIsText: true });
|
|
1183
|
+
if ("reject" in parsed)
|
|
1184
|
+
return { reason: parsed.reject, refusedWhole: true };
|
|
1185
|
+
const pathPrefixed = parsed.name.includes("/");
|
|
1186
|
+
const name = pathPrefixed ? parsed.name.slice(parsed.name.lastIndexOf("/") + 1) : parsed.name;
|
|
1187
|
+
if (pathPrefixed)
|
|
1188
|
+
nameRefusal ??= "the command must be a bare name resolved via PATH (no path prefix)";
|
|
1189
|
+
if (allow.has(name)) {
|
|
1190
|
+
if (pathPrefixed || from > 0)
|
|
1191
|
+
reheaded.set(si, { from, head: name });
|
|
1192
|
+
if (from > 0)
|
|
1193
|
+
nameRefusal ??= `command "${folded[0]}" is not in the read-only allowlist`;
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
nameRefusal ??= `command "${parsed.name}" is not in the read-only allowlist`;
|
|
1197
|
+
if (SHELL_REENTRY_PROGRAM_RE.test(name))
|
|
1198
|
+
return { reason: `command "${parsed.name}" runs its argument as shell text, which this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
|
|
1199
|
+
if (COMMAND_LAUNCHERS.has(name)) {
|
|
1200
|
+
const unwrapped = unwrapLauncher(folded.slice(from), allow);
|
|
1201
|
+
if (unwrapped.kind === "refused")
|
|
1202
|
+
return { reason: unwrapped.reason, refusedWhole: true };
|
|
1203
|
+
if (unwrapped.kind === "unlisted" && unwrapped.head !== undefined && SHELL_REENTRY_PROGRAM_RE.test(unwrapped.head)) {
|
|
1204
|
+
return { reason: `"${written}" runs "${unwrapped.head}", which runs its argument as shell text this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
|
|
1205
|
+
}
|
|
1206
|
+
if (unwrapped.kind === "unlisted")
|
|
1207
|
+
unlistedSegments.add(si);
|
|
1208
|
+
else
|
|
1209
|
+
reheaded.set(si, { from: from + unwrapped.index, head: unwrapped.head });
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
unlistedSegments.add(si);
|
|
812
1213
|
}
|
|
813
1214
|
const HEAD_AUTO_ALLOW_MAX = 1_000_000;
|
|
814
1215
|
const headBoundIsSmall = (name, toks) => {
|
|
@@ -840,9 +1241,12 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
|
|
|
840
1241
|
};
|
|
841
1242
|
const STDIN_FILE_FLOOR = { cat: 1, grep: 2, head: 1, tail: 1, wc: 1, cut: 1, tr: Infinity, diff: 2, cmp: 2, comm: 2, sed: 2 };
|
|
842
1243
|
const foldedSegments = [];
|
|
843
|
-
let shapeRefusal;
|
|
1244
|
+
let shapeRefusal = nameRefusal;
|
|
844
1245
|
for (let si = 0; si < segments.length; si++) {
|
|
845
|
-
|
|
1246
|
+
if (unlistedSegments.has(si))
|
|
1247
|
+
continue;
|
|
1248
|
+
const rehead = reheaded.get(si);
|
|
1249
|
+
const segmentTokens = rehead === undefined ? tokenizeSegment(segments[si]) : reheadSegment(tokenizeSegment(segments[si]), rehead.head, rehead.from);
|
|
846
1250
|
const toks = segmentTokens.folded;
|
|
847
1251
|
if (toks.length === 0)
|
|
848
1252
|
continue;
|
|
@@ -1187,10 +1591,7 @@ export function classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary
|
|
|
1187
1591
|
if (readSegments.length === 0) {
|
|
1188
1592
|
return { reason: "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed" };
|
|
1189
1593
|
}
|
|
1190
|
-
const bodyHasCd = readSegments.some((seg) =>
|
|
1191
|
-
const p = parseLeadingCommandName(seg);
|
|
1192
|
-
return "name" in p && p.name === "cd";
|
|
1193
|
-
});
|
|
1594
|
+
const bodyHasCd = readSegments.some((seg) => tokensMoveBase(tokenizeSegment(seg), allow));
|
|
1194
1595
|
const modelled = bodyHasCd ? Array.from({ length: beats }, () => readSegments.join("; ")).join("; ") : readSegments.join("; ");
|
|
1195
1596
|
const verdict = classifyCompoundReadonlyDetailed(modelled, allow, boundary, { iterated: bodyHasCd });
|
|
1196
1597
|
if (verdict.reason !== undefined)
|
package/dist/tools/fs/fs-bash.js
CHANGED
|
@@ -11,7 +11,7 @@ import { isRemoteExecutionEnv, hasDestroy, isIsolated } from "../../core/remote-
|
|
|
11
11
|
import { ghRateLimitHint } from "./gh-rate-limit.js";
|
|
12
12
|
import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashTimeoutArgRefusal, bashTimeoutParamDescription, envErrorDetail, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, createShellOverflowSpoolFence, shellRecoveryHint, CWD_SENTINEL, BASH_READONLY_CONFINEMENT_NOTE, } from "./fs-shared.js";
|
|
13
13
|
import { PROBE_CAUSE_PATH_MAX, inlineUntrusted } from "../../core/untrusted-text.js";
|
|
14
|
-
import { BASH_CLASSIFY_DEFAULT_ALLOW, BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoopDetailed, classifyCompoundReadonlyDetailed, classifyOutOfRootReadGate, classifySimpleCommandReadBoundary, NOT_AUTO_ALLOWED, } from "./bash-readonly-classifier.js";
|
|
14
|
+
import { BASH_CLASSIFY_DEFAULT_ALLOW, BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoopDetailed, classifyCompoundReadonlyDetailed, classifyOutOfRootReadGate, refusedCommandMayRead, classifySimpleCommandReadBoundary, NOT_AUTO_ALLOWED, } from "./bash-readonly-classifier.js";
|
|
15
15
|
import { toolFace } from "../../core/tool-catalog-entries.js";
|
|
16
16
|
const RECURSIVE_CAUSE_MAX_PATHS = 3;
|
|
17
17
|
const RECURSIVE_READ_CAUSE_CODE = "shell.recursive_read_unbounded";
|
|
@@ -47,13 +47,16 @@ export function bashReversibilityProbe(allow, boundary) {
|
|
|
47
47
|
const loop = classifyBoundedReadonlyPollLoopDetailed(command, allowSet, resolved);
|
|
48
48
|
if (loop.reason === undefined)
|
|
49
49
|
return { reversible: true };
|
|
50
|
-
if (boundaryDeclared(loop))
|
|
50
|
+
if (boundaryDeclared(loop) || unjudgedRead(command, allowSet, resolved, loop))
|
|
51
51
|
return { reversible: false, mandated: true };
|
|
52
52
|
return { reversible: false, ...boundaryGate(detailed) };
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
function boundaryUnread(verdict) {
|
|
56
|
-
return (verdict
|
|
56
|
+
return boundaryUndecided(verdict) || boundaryUnjudged(verdict);
|
|
57
|
+
}
|
|
58
|
+
function boundaryUndecided(verdict) {
|
|
59
|
+
return verdict.undecidedPaths !== undefined && verdict.undecidedPaths.length > 0;
|
|
57
60
|
}
|
|
58
61
|
function boundaryDeclared(verdict) {
|
|
59
62
|
return denyJudgeSpoke(verdict) || verdict.outOfRootRead === true;
|
|
@@ -69,9 +72,23 @@ function isBackgroundShellCall(args) {
|
|
|
69
72
|
return args?.run_in_background === true;
|
|
70
73
|
}
|
|
71
74
|
function readBoundaryMandate(command, allowSet, resolved, verdict) {
|
|
72
|
-
if (
|
|
75
|
+
if (resolved === undefined)
|
|
76
|
+
return {};
|
|
77
|
+
if (denyJudgeSpoke(verdict) || unjudgedRead(command, allowSet, resolved, verdict))
|
|
73
78
|
return { mandated: true };
|
|
74
|
-
return
|
|
79
|
+
return classifyOutOfRootReadGate(command, allowSet, resolved).gated ? { mandated: true } : {};
|
|
80
|
+
}
|
|
81
|
+
function boundaryUnjudged(verdict) {
|
|
82
|
+
return verdict.refusedWhole === true || (verdict.unresolvedOperands !== undefined && verdict.unresolvedOperands.length > 0);
|
|
83
|
+
}
|
|
84
|
+
function unjudgedRead(command, allowSet, resolved, verdict) {
|
|
85
|
+
if (verdict.unresolvedOperands !== undefined && verdict.unresolvedOperands.length > 0)
|
|
86
|
+
return true;
|
|
87
|
+
if (verdict.refusedWhole !== true)
|
|
88
|
+
return false;
|
|
89
|
+
if (refusedCommandMayRead(command, allowSet, resolved))
|
|
90
|
+
return true;
|
|
91
|
+
return resolved !== undefined && classifyOutOfRootReadGate(command, allowSet, resolved).gated;
|
|
75
92
|
}
|
|
76
93
|
function recursiveReadCause(detailed) {
|
|
77
94
|
const recursive = detailed.recursiveReadPaths;
|
|
@@ -94,23 +111,31 @@ export function bashReadBoundaryProbe(boundary) {
|
|
|
94
111
|
const resolved = typeof boundary === "function" ? boundary() : boundary;
|
|
95
112
|
if (resolved === undefined)
|
|
96
113
|
return { reversible: true };
|
|
114
|
+
const seatVerdict = (verdict) => {
|
|
115
|
+
if (boundaryDeclared(verdict) || unjudgedRead(command, allowSet, resolved, verdict)) {
|
|
116
|
+
const cause = recursiveReadCause(verdict);
|
|
117
|
+
return { reversible: false, mandated: true, ...(cause !== undefined ? { cause } : {}) };
|
|
118
|
+
}
|
|
119
|
+
if (boundaryUndecided(verdict))
|
|
120
|
+
return { reversible: false };
|
|
121
|
+
if (verdict.reason === undefined)
|
|
122
|
+
return { reversible: true };
|
|
123
|
+
return undefined;
|
|
124
|
+
};
|
|
97
125
|
const detailed = classifyCompoundReadonlyDetailed(command, allowSet, resolved);
|
|
98
|
-
if (boundaryDeclared(detailed)) {
|
|
99
|
-
const cause = recursiveReadCause(detailed);
|
|
100
|
-
return { reversible: false, mandated: true, ...(cause !== undefined ? { cause } : {}) };
|
|
101
|
-
}
|
|
102
|
-
if (boundaryUnread(detailed))
|
|
103
|
-
return { reversible: false };
|
|
104
126
|
if (detailed.reason === undefined)
|
|
105
|
-
return { reversible: true };
|
|
127
|
+
return seatVerdict(detailed) ?? { reversible: true };
|
|
106
128
|
const loop = classifyBoundedReadonlyPollLoopDetailed(command, allowSet, resolved);
|
|
107
129
|
if (loop.reason === undefined)
|
|
108
|
-
return { reversible: true };
|
|
109
|
-
|
|
130
|
+
return seatVerdict(loop) ?? { reversible: true };
|
|
131
|
+
const compoundVerdict = seatVerdict(detailed);
|
|
132
|
+
const loopVerdict = seatVerdict(loop);
|
|
133
|
+
const mandated = [compoundVerdict, loopVerdict].find((verdict) => verdict?.mandated === true);
|
|
134
|
+
if (mandated !== undefined)
|
|
135
|
+
return mandated;
|
|
136
|
+
if (readBoundaryMandate(command, allowSet, resolved, detailed).mandated === true)
|
|
110
137
|
return { reversible: false, mandated: true };
|
|
111
|
-
|
|
112
|
-
return { reversible: false };
|
|
113
|
-
return { reversible: true };
|
|
138
|
+
return compoundVerdict ?? loopVerdict ?? { reversible: true };
|
|
114
139
|
};
|
|
115
140
|
}
|
|
116
141
|
export { FULL_SHELL_CONTRACT_ID } from "../../core/tool-catalog-entries.js";
|