@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/core/fs-write-gate-policy.js +4 -0
  3. package/dist/core/memory-engine/engine.js +2 -1
  4. package/dist/core/memory-engine/layout.d.ts +18 -6
  5. package/dist/core/memory-engine/layout.js +40 -21
  6. package/dist/core/physical-path.d.ts +37 -0
  7. package/dist/core/physical-path.js +30 -0
  8. package/dist/core/runner/contracts.d.ts +3 -1
  9. package/dist/core/runner/prepare-artifact.d.ts +4 -5
  10. package/dist/core/runner/prepare-artifact.js +2 -16
  11. package/dist/core/runner/prepare-ask-lane.d.ts +3 -0
  12. package/dist/core/runner/prepare-ask-lane.js +3 -3
  13. package/dist/core/runner/prepare-park-ask.d.ts +3 -0
  14. package/dist/core/runner/prepare-park-ask.js +3 -2
  15. package/dist/core/runner/prepare-policy-chain.js +2 -2
  16. package/dist/core/runner/prepare-question-face.js +2 -1
  17. package/dist/core/runner/prepare-task.js +5 -5
  18. package/dist/core/runner/run-harness-handlers.js +4 -1
  19. package/dist/core/runner/run-leg.js +4 -1
  20. package/dist/core/sensitive-path-policy.js +7 -8
  21. package/dist/core/skills-directory.js +4 -3
  22. package/dist/core/spec-contract.js +5 -4
  23. package/dist/core/task-registry-shared.d.ts +5 -1
  24. package/dist/core/task-registry-shared.js +1 -0
  25. package/dist/core/tool-catalog-entries.js +1 -1
  26. package/dist/core/tool-policy.d.ts +56 -0
  27. package/dist/core/tool-policy.js +6 -0
  28. package/dist/engine/execution-env/node-execution-env.js +4 -3
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/orchestration/workflow-script-store.js +9 -25
  32. package/dist/orchestration/workflow.js +6 -3
  33. package/dist/stores/cc/task-list-store.js +2 -10
  34. package/dist/stores/file/fs-atomic.d.ts +15 -18
  35. package/dist/stores/file/fs-atomic.js +4 -14
  36. package/dist/stores/file/mailbox-store.d.ts +7 -11
  37. package/dist/stores/file/mailbox-store.js +4 -11
  38. package/dist/tools/artifact/local-stub.js +4 -3
  39. package/dist/tools/fs/bash-readonly-classifier.d.ts +19 -1
  40. package/dist/tools/fs/bash-readonly-classifier.js +413 -12
  41. package/dist/tools/fs/fs-bash.js +42 -17
  42. package/package.json +1 -1
  43. 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, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
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 = realpathSyncSafe(lexicalDir);
365
- this.tmpDir = realpathSyncSafe(lexicalTmp);
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, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
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 = realpathSync(base);
116
+ const realBase = physicalPathOf(base);
116
117
  const files = row.files.map((f) => {
117
- const target = realpathSync(resolve(base, ...f.path.split("/")));
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: nothing was declared), never a vouch.
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
- for (const segment of segments) {
809
- const reason = coarseReadonlyCheck(segment, allow, { quotedOperatorsAreText: true });
810
- if (reason !== undefined)
811
- return { reason };
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
- const segmentTokens = tokenizeSegment(segments[si]);
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)
@@ -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.undecidedPaths !== undefined && verdict.undecidedPaths.length > 0) || (verdict.unresolvedOperands !== undefined && verdict.unresolvedOperands.length > 0);
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 (denyJudgeSpoke(verdict))
75
+ if (resolved === undefined)
76
+ return {};
77
+ if (denyJudgeSpoke(verdict) || unjudgedRead(command, allowSet, resolved, verdict))
73
78
  return { mandated: true };
74
- return resolved !== undefined && classifyOutOfRootReadGate(command, allowSet, resolved).gated ? { mandated: true } : {};
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
- if (boundaryDeclared(loop))
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
- if (boundaryUnread(loop))
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.17.0",
3
+ "version": "7.17.2",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",