@sema-agent/core 5.63.0 → 5.64.0

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 (39) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/agents/cascade.d.ts +5 -1
  3. package/dist/agents/cascade.js +6 -1
  4. package/dist/agents/subagent.js +1 -0
  5. package/dist/agents/verify.d.ts +5 -1
  6. package/dist/agents/verify.js +5 -2
  7. package/dist/core/checkpoint-store.d.ts +5 -1
  8. package/dist/core/fs-write-gate-policy.d.ts +21 -0
  9. package/dist/core/fs-write-gate-policy.js +14 -3
  10. package/dist/core/remote-env.d.ts +34 -2
  11. package/dist/core/runner/prepare-task.d.ts +20 -0
  12. package/dist/core/runner/prepare-task.js +12 -3
  13. package/dist/core/runner/prepare-workspace-restore.js +13 -0
  14. package/dist/core/runner/runtask.js +23 -17
  15. package/dist/core/types.d.ts +13 -3
  16. package/dist/core/usage-window-store.d.ts +44 -12
  17. package/dist/core/usage-window-store.js +11 -3
  18. package/dist/core/workflow-run-store-contract.js +17 -0
  19. package/dist/core/workflow-run-store.d.ts +22 -1
  20. package/dist/core/workflow-run-store.js +1 -0
  21. package/dist/engine/harness/agent-harness.d.ts +8 -3
  22. package/dist/engine/harness/agent-harness.js +9 -4
  23. package/dist/engine/harness/types.d.ts +89 -3
  24. package/dist/engine/loop/agent-loop.js +39 -15
  25. package/dist/engine/loop/types.d.ts +43 -22
  26. package/dist/index.d.ts +1 -1
  27. package/dist/internal/harness-types.d.ts +1 -1
  28. package/dist/orchestration/run-workflow-tool.d.ts +6 -0
  29. package/dist/orchestration/run-workflow-tool.js +1 -0
  30. package/dist/orchestration/workflow.d.ts +6 -0
  31. package/dist/orchestration/workflow.js +1 -0
  32. package/dist/tools/fs/bash-readonly-classifier.d.ts +44 -1
  33. package/dist/tools/fs/bash-readonly-classifier.js +132 -5
  34. package/dist/tools/fs/fs-bash.js +9 -2
  35. package/dist/tools/fs/fs-write.js +19 -8
  36. package/dist/tools/fs/index.d.ts +1 -0
  37. package/dist/tools/fs/index.js +1 -0
  38. package/package.json +1 -1
  39. package/test/export-surface.snapshot.json +5 -1
@@ -492,6 +492,7 @@ export async function createRunWorkflowTool(d) {
492
492
  ...(sourceTaskId !== undefined ? { sourceTaskId } : {}),
493
493
  ...(d.originatingSessionId !== undefined ? { originatingSessionId: d.originatingSessionId } : {}),
494
494
  ...((ctx.rootSessionId ?? d.rootSessionId ?? d.originatingSessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? d.rootSessionId ?? d.originatingSessionId } : {}),
495
+ ...((ctx.placementRoot ?? d.placementRoot) !== undefined ? { placementRoot: ctx.placementRoot ?? d.placementRoot } : {}),
495
496
  ...(workflowTaskId !== undefined ? { runId: workflowTaskId } : {}),
496
497
  journalStore: d.journalStore,
497
498
  ...(resumeFromRunId !== undefined ? { resumeFromRunId } : {}),
@@ -297,6 +297,12 @@ export interface RunWorkflowOptions {
297
297
  * `ctx.rootSessionId ?? originatingSessionId`); rides the wa* observer frames so a recovery face
298
298
  * groups workflow-agent rows under the root host session too. */
299
299
  rootSessionId?: string;
300
+ /** design/380 O1② (C12) — the host run's EXPLICIT placement fixed point
301
+ * (`RunInternals.placementRoot`, threaded by the run-workflow tool when the host carried one):
302
+ * every workflow-spawned agent inherits it verbatim so descendants of a cascade/verify leg stay
303
+ * placed at the ladder/gate root. Absent ⇒ nothing extra travels (the rootSessionId chain is the
304
+ * placement root through prepare's mint middle segment). */
305
+ placementRoot?: string;
300
306
  /** design/149 — the PROCESS-level child observer (same seam as
301
307
  * {@link import("../core/types.js").RunnerDeps.onBackgroundChildEvent}). When set, every workflow
302
308
  * agent emits spawn/tick/terminal {@link import("../core/types.js").BackgroundChildEvent} frames with a
@@ -526,6 +526,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
526
526
  ...(opts.onForwardEvent ? { onForwardEvent: opts.onForwardEvent } : {}),
527
527
  ...(opts.inheritedGate !== undefined ? { inheritedGate: opts.inheritedGate } : {}),
528
528
  ...((opts.rootSessionId ?? opts.originatingSessionId) !== undefined ? { rootSessionId: opts.rootSessionId ?? opts.originatingSessionId } : {}),
529
+ ...(opts.placementRoot !== undefined ? { placementRoot: opts.placementRoot } : {}),
529
530
  };
530
531
  const bceSink = opts.onBackgroundChildEvent;
531
532
  const bceEmit = (e) => {
@@ -15,11 +15,46 @@
15
15
  * but state-MUTATING with args, which an argv[0]-only filter cannot tell apart. Leaving them in defeated
16
16
  * both the `effect:read` truthfulness here and the classifier's irreversibility promise (a `date -s` would
17
17
  * auto-allow an irreversible clock jump under `shellGate:"classify"`).
18
+ *
19
+ * backlog #482 (CC parity): rows 3-4 are the CC-anchored expansion — the members of CC's safe-command
20
+ * set (pretty223.js:420513-420565 `e6s`; identical in the 220 corpus; the 220→245 campaign diff records
21
+ * no change on this face) that satisfy THIS list's curation bar (no write/mutation mode under any args)
22
+ * without new stdin-floor/option-value modeling: pure status/computation printers (`cal`…`sleep` — none
23
+ * reads stdin when bare, none takes a path it READS except realpath/readlink, which stay boundary-judged)
24
+ * plus the floor-2 content comparers `diff`/`cmp`/`comm` (STDIN_FILE_FLOOR rows added alongside; diff's
25
+ * recursive form was already modeled in RECURSIVE_READ_FORMS). Still deliberately excluded, each for a
26
+ * stated reason: `env` (an executor: `env FOO=1 cmd` runs cmd — CC excludes it too), `sort` (`-o` writes),
27
+ * `uniq` (second positional is an OUTPUT file; CC allows a flags-only regex form this filter cannot
28
+ * express), `printf` (builtin `-v var` assigns), `find` (CC allows only a regex excluding
29
+ * `-delete`/`-exec`/…), and the bare-stdin text filters (`od`/`strings`/`nl`/`tsort`/`pr`/…) whose
30
+ * value-taking options defeat the stdin-floor operand count without a per-verb option-value model (#109)
31
+ * — those are a follow-on slice, not a silent drop.
18
32
  */
19
33
  /** The verdict every unresolvable / out-of-root / unbounded finding ends on. The findings differ in what
20
34
  * they found; the consequence is one consequence, and a copy that drifts reads as a second rule. */
21
35
  export declare const NOT_AUTO_ALLOWED = "\u2014 not auto-allowed";
22
36
  export declare const BASH_READONLY_DEFAULT_ALLOW: readonly string[];
37
+ /**
38
+ * backlog #482 slice 2 — the CLASSIFY face's default allow set: the shared list PLUS the verbs that are
39
+ * admissible only where the compound classifier's per-verb judgment runs, and NOT on the shared list's
40
+ * other consumer faces:
41
+ * · `find` — read-only only when no CC `aCy` dangerous predicate token is present (guard arm);
42
+ * · `sed` — read-only only in CC's `_Ld` grammar (guard arm);
43
+ * · `cd` — never touches the filesystem, but it MUTATES the persistent shell's committed cwd, so it
44
+ * must not reach the shared list's `isConcurrencySafe` consumer (a parallel-batch `cd` reorders
45
+ * every sibling's working directory) or the `effect:"read"` declaration face; the classify face's
46
+ * EXISTING cd boundary arm (glob/no-arg/`-` conservative, target judged against the roots) is what
47
+ * makes it admissible here.
48
+ * The bash_readonly DECLARATION face checks names without argument vetting (its own header says so), so
49
+ * none of the three may join {@link BASH_READONLY_DEFAULT_ALLOW}: `find . -delete` / `sed -i` under an
50
+ * `effect:"read"` declaration would falsify it. Consumed as
51
+ * {@link import("./fs-bash.js").bashReversibilityProbe}'s default when the caller passes NO allow list —
52
+ * a caller-narrowed list is honored verbatim (naming these verbs there opts into the same guard/boundary
53
+ * arms; omitting them keeps them refused), so the superset can never widen a narrowed deployment. The
54
+ * poll-loop face inherits the guard arms for free: its body vetting delegates to the same compound
55
+ * classifier.
56
+ */
57
+ export declare const BASH_CLASSIFY_DEFAULT_ALLOW: readonly string[];
23
58
  /**
24
59
  * G2 — options for {@link parseLeadingCommandName}. Omitting them keeps the parser byte-identical to
25
60
  * what every existing caller (the `bash_readonly` declaration face, the coarse command-name policy, the
@@ -293,7 +328,15 @@ export declare function formatOutOfRootReadApprovalOption(directory: string): st
293
328
  * out-of-root signal; the boundary scan below only ever converts a would-be READ-ONLY verdict into a
294
329
  * demotion, which is why omitting `boundary` reproduces the pre-RB-412 behaviour exactly.
295
330
  */
296
- export declare function classifyCompoundReadonlyDetailed(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): CompoundReadonlyVerdict;
331
+ export declare function classifyCompoundReadonlyDetailed(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary,
332
+ /** backlog #482 slice 3 (C4): the poll-loop face passes `iterated:true` over a body REPLICATED
333
+ * `beats` times — a single cd repeated per iteration is a legitimate accumulating shift, not the
334
+ * ">1 cd = ask for clarity" compound CC refuses (a `for` loop is a sema-only face; CC asks for any
335
+ * loop). It suppresses ONLY that multi-cd refuse; every read is still threaded + boundary-judged
336
+ * against its real per-iteration base, so the escape a climbing loop opens is still caught. */
337
+ opts?: {
338
+ readonly iterated?: boolean;
339
+ }): CompoundReadonlyVerdict;
297
340
  /**
298
341
  * RB-413 — the read boundary ALONE, for the `bash_readonly` face.
299
342
  *
@@ -3,7 +3,10 @@ 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",
5
5
  "grep", "cut", "tr", "basename", "dirname", "stat", "du", "df", "which",
6
+ "cal", "uptime", "id", "groups", "nproc", "locale", "free", "realpath", "readlink",
7
+ "true", "false", "seq", "expr", "type", "sleep", "diff", "cmp", "comm",
6
8
  ];
9
+ export const BASH_CLASSIFY_DEFAULT_ALLOW = [...BASH_READONLY_DEFAULT_ALLOW, "find", "sed", "cd"];
7
10
  const SHELL_OPERATORS = /[;&|<>$()`\n\r\\]/;
8
11
  function quoteMask(s) {
9
12
  const quoted = new Array(s.length).fill(false);
@@ -166,7 +169,10 @@ export function formatOutOfRootReadApprovalOption(directory) {
166
169
  const leaf = cut >= 0 ? trimmed.slice(cut + 1) : trimmed;
167
170
  return `Yes, allow reading from ${leaf || directory}/ from this project`;
168
171
  }
169
- const NO_PATH_OPERAND_COMMANDS = new Set(["pwd", "echo", "whoami", "uname", "which", "tr", "basename", "dirname"]);
172
+ const NO_PATH_OPERAND_COMMANDS = new Set([
173
+ "pwd", "echo", "whoami", "uname", "which", "tr", "basename", "dirname",
174
+ "cal", "uptime", "id", "groups", "nproc", "locale", "free", "true", "false", "seq", "expr", "type", "sleep",
175
+ ]);
170
176
  function isPathShapedToken(tok) {
171
177
  return tok.includes("/") || tok.startsWith("~") || tok === "." || tok === "..";
172
178
  }
@@ -311,6 +317,8 @@ const SEPARATED_VALUE_OPTIONS = {
311
317
  "exclude-dir",
312
318
  ],
313
319
  },
320
+ cmp: { short: "ni", long: ["bytes", "ignore-initial"] },
321
+ diff: { short: "UCWIFxL", ownsValueButNotSkippable: "SX", long: ["unified", "context", "width", "ignore-matching-lines", "show-function-line", "exclude", "label", "horizon-lines", "tabsize"] },
314
322
  };
315
323
  function takesSeparatedValue(name, tok) {
316
324
  const model = SEPARATED_VALUE_OPTIONS[name];
@@ -353,6 +361,98 @@ const RECURSIVE_READ_FORMS = {
353
361
  tar: { shortLetters: "cru", valueOwners: "fCTXbg", longNames: ["create", "append", "update"], bundledModeLetters: "cru", dashIsStdin: true },
354
362
  diff: { shortLetters: "r", valueOwners: "UCWISFXx", longNames: ["recursive"], dashIsStdin: true },
355
363
  };
364
+ const FIND_DANGEROUS_PREDICATES = new Set([
365
+ "-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fls", "-fprintf", "-files0-from",
366
+ ]);
367
+ const SED_READONLY_FLAGS = new Set([
368
+ "-n", "--quiet", "--silent", "-E", "--regexp-extended", "-r", "-z", "--zero-terminated", "--posix",
369
+ ]);
370
+ const SED_READONLY_CLUSTER_LETTERS = new Set(["n", "E", "r", "z"]);
371
+ const SED_READONLY_SCRIPT_PIECE = /^(?:\d+|\d+,\d+)?p$/;
372
+ function guardedVerbFormReason(name, toks, raw) {
373
+ if (name === "find") {
374
+ for (const t of toks) {
375
+ if (FIND_DANGEROUS_PREDICATES.has(t)) {
376
+ return `\`find ${t}\` executes a command or writes a file — not a read ${NOT_AUTO_ALLOWED}`;
377
+ }
378
+ }
379
+ for (const rt of raw.slice(1)) {
380
+ if (hasUnquotedGlobMetachar(rt) || hasUnquotedExpansionMetachar(rt)) {
381
+ return `\`find\` is given the unquoted pattern "${rt}", which the shell may expand into other arguments before find runs ${NOT_AUTO_ALLOWED}`;
382
+ }
383
+ }
384
+ return undefined;
385
+ }
386
+ if (name === "sed") {
387
+ for (const rt of raw.slice(1)) {
388
+ if (hasUnquotedGlobMetachar(rt) || hasUnquotedExpansionMetachar(rt)) {
389
+ return `\`sed\` is given the unquoted pattern "${rt}", which the shell may expand into other arguments (a file named like a flag becomes a live option) before sed runs ${NOT_AUTO_ALLOWED}`;
390
+ }
391
+ }
392
+ const flags = [];
393
+ const operands = [];
394
+ let endOfOptions = false;
395
+ for (const t of toks.slice(1)) {
396
+ if (!endOfOptions && t === "--") {
397
+ endOfOptions = true;
398
+ continue;
399
+ }
400
+ if (!endOfOptions && t.startsWith("-")) {
401
+ flags.push(t);
402
+ continue;
403
+ }
404
+ operands.push(t);
405
+ }
406
+ for (const f of flags) {
407
+ if (SED_READONLY_FLAGS.has(f))
408
+ continue;
409
+ if (/^-[A-Za-z]+$/.test(f) && f.length > 2 && [...f.slice(1)].every((ch) => SED_READONLY_CLUSTER_LETTERS.has(ch)))
410
+ continue;
411
+ return `\`sed ${f}\` is outside the read-only sed form (-n plus print-only script) ${NOT_AUTO_ALLOWED}`;
412
+ }
413
+ const quiet = flags.some((f) => f === "-n" || f === "--quiet" || f === "--silent" || (/^-[A-Za-z]+$/.test(f) && f.includes("n")));
414
+ if (!quiet)
415
+ return `\`sed\` without \`-n\` echoes its whole input — only the -n print-only form is read-classified ${NOT_AUTO_ALLOWED}`;
416
+ const script = operands[0];
417
+ if (script === undefined)
418
+ return `\`sed\` with no script ${NOT_AUTO_ALLOWED}`;
419
+ for (const piece of script.split(";")) {
420
+ if (!SED_READONLY_SCRIPT_PIECE.test(piece.trim())) {
421
+ return `\`sed\` script "${script}" is not a pure print command (N[,M]p) ${NOT_AUTO_ALLOWED}`;
422
+ }
423
+ }
424
+ return undefined;
425
+ }
426
+ if (name === "free") {
427
+ for (const t of toks.slice(1)) {
428
+ if (/^--seconds(=.*)?$/.test(t) || /^-[A-Za-z0-9]*s[A-Za-z0-9]*$/.test(t)) {
429
+ return `\`free ${t}\` repeats until killed — it never terminates on its own ${NOT_AUTO_ALLOWED}`;
430
+ }
431
+ }
432
+ return undefined;
433
+ }
434
+ if (name === "sleep") {
435
+ return pollLoopSleepReason(toks.join(" "));
436
+ }
437
+ if (name === "seq") {
438
+ for (const t of toks.slice(1)) {
439
+ if (t.startsWith("-") && t !== "-" && !/^-\d/.test(t))
440
+ continue;
441
+ if (!/^-?\d{1,6}$/.test(t)) {
442
+ return `\`seq ${t}\` is not a small literal integer bound — the output cannot be bounded statically ${NOT_AUTO_ALLOWED}`;
443
+ }
444
+ }
445
+ return undefined;
446
+ }
447
+ if (name === "diff") {
448
+ for (const t of toks.slice(1)) {
449
+ if (/^--[A-Za-z-]+=-$/.test(t) || /^-[A-Za-z]*[SX]-$/.test(t)) {
450
+ return `\`diff ${t}\` names stdin inside a fused option — a read this check cannot see through ${NOT_AUTO_ALLOWED}`;
451
+ }
452
+ }
453
+ }
454
+ return undefined;
455
+ }
356
456
  function segmentSelectsRecursiveRead(name, args) {
357
457
  const model = RECURSIVE_READ_FORMS[name];
358
458
  if (model === undefined)
@@ -577,7 +677,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
577
677
  }
578
678
  return findings;
579
679
  }
580
- export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
680
+ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts) {
581
681
  const trimmed = command.trim();
582
682
  if (!trimmed)
583
683
  return { reason: "empty command" };
@@ -622,7 +722,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
622
722
  }
623
723
  return byteBound;
624
724
  };
625
- const STDIN_FILE_FLOOR = { cat: 1, grep: 2, head: 1, tail: 1, wc: 1, cut: 1, tr: Infinity };
725
+ 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 };
626
726
  const foldedSegments = [];
627
727
  for (let si = 0; si < segments.length; si++) {
628
728
  const segmentTokens = tokenizeSegment(segments[si]);
@@ -631,6 +731,9 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
631
731
  continue;
632
732
  foldedSegments.push(segmentTokens);
633
733
  const name = toks[0];
734
+ const guardReason = guardedVerbFormReason(name, toks, segmentTokens.raw);
735
+ if (guardReason !== undefined)
736
+ return { reason: guardReason };
634
737
  if (!(pipeFed[si] ?? false)) {
635
738
  const floor = STDIN_FILE_FLOOR[name];
636
739
  const restArgs = toks.slice(1);
@@ -696,8 +799,17 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
696
799
  return { reason: "reads a device/special file that is either unbounded (/dev/zero, /dev/stdin, /proc/<pid>/fd/0, … — blocks the pipeline until the tool timeout) or process-private (/proc/<pid>/environ, /proc/<pid>/mem, …) " + NOT_AUTO_ALLOWED };
697
800
  }
698
801
  }
802
+ if (opts?.iterated !== true) {
803
+ const cdSegments = foldedSegments.filter((st) => st.folded[0] === "cd");
804
+ if (cdSegments.length > 1) {
805
+ return { reason: `multiple directory changes in one command require approval for clarity ${NOT_AUTO_ALLOWED}` };
806
+ }
807
+ }
699
808
  if (boundary !== undefined)
700
809
  return evaluateReadBoundary(foldedSegments, boundary);
810
+ if (foldedSegments.some((st) => st.folded[0] === "cd")) {
811
+ return { reason: `\`cd\` cannot be judged without a read boundary (no base to resolve the directory change against) ${NOT_AUTO_ALLOWED}` };
812
+ }
701
813
  return {};
702
814
  }
703
815
  function evaluateReadBoundary(foldedSegments, boundary) {
@@ -705,8 +817,10 @@ function evaluateReadBoundary(foldedSegments, boundary) {
705
817
  const inside = [];
706
818
  const undecided = [];
707
819
  const recursive = [];
820
+ let currentBase = boundary.cwd ?? boundary.roots[0];
708
821
  for (const toks of foldedSegments) {
709
- for (const finding of collectSegmentBoundaryFindings(toks, boundary)) {
822
+ const segBoundary = currentBase === (boundary.cwd ?? boundary.roots[0]) ? boundary : { ...boundary, cwd: currentBase };
823
+ for (const finding of collectSegmentBoundaryFindings(toks, segBoundary)) {
710
824
  if (finding.kind === "unresolvable")
711
825
  return { reason: finding.reason };
712
826
  const denied = boundary.denyMatch?.(finding.path);
@@ -738,6 +852,14 @@ function evaluateReadBoundary(foldedSegments, boundary) {
738
852
  if (!outside.some((o) => o.path === finding.path))
739
853
  outside.push(finding);
740
854
  }
855
+ if (toks.folded[0] === "cd") {
856
+ const cdArgs = toks.folded.slice(1);
857
+ const cdTargetIdx = cdArgs.findIndex((t) => !t.startsWith("-") || t === "-");
858
+ const cdTarget = cdTargetIdx === -1 ? undefined : cdArgs[cdTargetIdx];
859
+ if (cdTarget !== undefined && cdTarget !== "-") {
860
+ currentBase = resolveOperandLexically(currentBase, cdTarget, boundary.homeDir) ?? currentBase;
861
+ }
862
+ }
741
863
  }
742
864
  const undecidedAll = [...undecided, ...recursive.filter((p) => !undecided.includes(p))];
743
865
  const undecidedField = {
@@ -851,7 +973,12 @@ export function classifyBoundedReadonlyPollLoop(command, allow, boundary) {
851
973
  if (readSegments.length === 0) {
852
974
  return "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed";
853
975
  }
854
- const verdict = classifyCompoundReadonlyDetailed(readSegments.join("; "), allow, boundary);
976
+ const bodyHasCd = readSegments.some((seg) => {
977
+ const p = parseLeadingCommandName(seg);
978
+ return "name" in p && p.name === "cd";
979
+ });
980
+ const modelled = bodyHasCd ? Array.from({ length: beats }, () => readSegments.join("; ")).join("; ") : readSegments.join("; ");
981
+ const verdict = classifyCompoundReadonlyDetailed(modelled, allow, boundary, { iterated: bodyHasCd });
855
982
  if (verdict.reason !== undefined)
856
983
  return verdict.reason;
857
984
  if (verdict.recursiveReadPaths !== 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_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoop, classifyCompoundReadonlyDetailed, classifySimpleCommandReadBoundary, NOT_AUTO_ALLOWED, } from "./bash-readonly-classifier.js";
14
+ import { BASH_CLASSIFY_DEFAULT_ALLOW, BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoop, classifyCompoundReadonlyDetailed, classifySimpleCommandReadBoundary, NOT_AUTO_ALLOWED, } from "./bash-readonly-classifier.js";
15
15
  const RECURSIVE_CAUSE_MAX_PATHS = 3;
16
16
  const RECURSIVE_READ_CAUSE_CODE = "shell.recursive_read_unbounded";
17
17
  function operandFamily(paths) {
@@ -22,7 +22,7 @@ function operandFamily(paths) {
22
22
  };
23
23
  }
24
24
  export function bashReversibilityProbe(allow, boundary) {
25
- const allowSet = new Set(allow ?? BASH_READONLY_DEFAULT_ALLOW);
25
+ const allowSet = new Set(allow ?? BASH_CLASSIFY_DEFAULT_ALLOW);
26
26
  return (args) => {
27
27
  const a = args;
28
28
  const command = a?.command;
@@ -312,6 +312,13 @@ async function runShell(env, cwd, toolName, command, timeoutSec, caps, signal, c
312
312
  isError: true,
313
313
  };
314
314
  }
315
+ if (res.error.code === "target_unavailable") {
316
+ return {
317
+ content: `Error (${toolName.slice(0, 64)}): the execution target is unavailable (offline/busy/not bound), so the command did NOT start and no effects landed. Retrying the same command is safe once the target is reachable again. (${res.error.message.slice(0, 512)})`,
318
+ details: { type: "bash", targetUnavailable: true },
319
+ isError: true,
320
+ };
321
+ }
315
322
  if (res.error.code === "timeout" || res.error.code === "aborted" || res.error.code === "callback_error") {
316
323
  const rawStdout = res.error.partialStdout ?? "";
317
324
  const rawStderr = res.error.partialStderr ?? "";
@@ -4,6 +4,16 @@ import { defineTool, errorResult } from "../../core/tools.js";
4
4
  import { sha256, resolveKey, violationText, violationDetails, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, resolveEscapeMatch, adaptNewStringEscapes, escapeMatchWasAttempted, ESCAPE_MATCH_MISS_NOTE, deletionOldString, countOccurrences, READ_REFUSED_ESCAPE_HINT, } from "./safety.js";
5
5
  import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText } from "./encoding.js";
6
6
  import { MAX_EDIT_BYTES, decodeEditBytes, tooLargeToEditMessage, truncatedUtf16BodyMessage, persistedTextOf, notReadRefusalText, enoentMessage, FILE_STATE_TRAILER, FILE_PATH_PARAMS, ipynbRedirect, countLines, } from "./fs-shared.js";
7
+ async function envFinalWrite(env, key, content, signal, opts) {
8
+ if (env.writeFileGuarded !== undefined) {
9
+ const r = await env.writeFileGuarded(key, content, { canonicalPath: key, ...(opts?.exclusive === true ? { exclusive: true } : {}) }, signal);
10
+ return r.ok ? { ok: true } : r;
11
+ }
12
+ if (opts?.exclusive === true && env.writeFileExclusive !== undefined) {
13
+ return env.writeFileExclusive(key, content, signal);
14
+ }
15
+ return env.writeFile(key, content, signal);
16
+ }
7
17
  async function gateToolWrite(hook, tool, path, key, content) {
8
18
  if (hook === undefined)
9
19
  return undefined;
@@ -71,13 +81,14 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
71
81
  const gated = await gateToolWrite(beforeWrite, "Edit", path, r.key, created);
72
82
  if (gated !== undefined)
73
83
  return errorResult(gated);
74
- const write = env.writeFileExclusive !== undefined
75
- ? await env.writeFileExclusive(r.key, created, ctx.signal)
76
- : await env.writeFile(r.key, created, ctx.signal);
84
+ const write = await envFinalWrite(env, r.key, created, ctx.signal, { exclusive: true });
77
85
  if (!write.ok) {
78
86
  if (write.error.code === "already_exists") {
79
87
  return errorResult(`Error (Edit): cannot create "${path}": file already exists (created concurrently since the existence check). Read the file first, then edit it normally (or Write after the Read to overwrite it).`);
80
88
  }
89
+ if (write.error.code === "precondition_failed") {
90
+ return errorResult(`Error (Edit): cannot create "${path.slice(0, 512)}": the atomic write refused its precondition (${write.error.message.slice(0, 512)}) — the on-disk state at that path changed between the existence check and the write (something may now exist there, or the path may now resolve somewhere else, e.g. through a symlink). Nothing was written. Read the path to see what is actually there before acting on it.`);
91
+ }
81
92
  return errorResult(`Error (Edit): cannot write "${path}": ${write.error.message}`);
82
93
  }
83
94
  const createdNorm = normalizeFileText(created);
@@ -121,7 +132,7 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
121
132
  if (gated !== undefined)
122
133
  return errorResult(gated);
123
134
  const encodedOverwrite = encodeTextForFile(newContent, preDec.encoding, preDec.endings);
124
- const write = await env.writeFile(r.key, encodedOverwrite, ctx.signal);
135
+ const write = await envFinalWrite(env, r.key, encodedOverwrite, ctx.signal);
125
136
  if (!write.ok)
126
137
  return errorResult(`Error (Edit): cannot write "${path}": ${write.error.message}`);
127
138
  const persistedOverwrite = persistedTextOf(encodedOverwrite);
@@ -194,7 +205,7 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
194
205
  if (gated !== undefined)
195
206
  return errorResult(gated);
196
207
  const encodedEdit = encodeTextForFile(working, decoded.encoding, decoded.endings);
197
- const write = await env.writeFile(r.key, encodedEdit, ctx.signal);
208
+ const write = await envFinalWrite(env, r.key, encodedEdit, ctx.signal);
198
209
  if (!write.ok)
199
210
  return errorResult(`Error (Edit): cannot write "${path}": ${write.error.message}`);
200
211
  const persistedEdit = persistedTextOf(encodedEdit);
@@ -265,7 +276,7 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
265
276
  if (gated !== undefined)
266
277
  return errorResult(gated);
267
278
  const encodedWrite = encodeTextForFile(content, decodedPrev.encoding, "preserve");
268
- const write = await env.writeFile(r.key, encodedWrite, ctx.signal);
279
+ const write = await envFinalWrite(env, r.key, encodedWrite, ctx.signal);
269
280
  if (!write.ok)
270
281
  return errorResult(`Error (Write): cannot write "${path}": ${write.error.message}`);
271
282
  const persistedWrite = persistedTextOf(encodedWrite);
@@ -278,7 +289,7 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
278
289
  const gatedCreate = await gateToolWrite(beforeWrite, "Write", path, r.key, content);
279
290
  if (gatedCreate !== undefined)
280
291
  return errorResult(gatedCreate);
281
- const write = await env.writeFile(r.key, content, ctx.signal);
292
+ const write = await envFinalWrite(env, r.key, content, ctx.signal);
282
293
  if (!write.ok)
283
294
  return errorResult(`Error (Write): cannot write "${path}": ${write.error.message}`);
284
295
  const totalLines = countLines(content);
@@ -409,7 +420,7 @@ export function createNotebookEditTool(env, state, rootCanonical, cwdRef, additi
409
420
  const gated = await gateToolWrite(beforeWrite, "NotebookEdit", notebook_path, r.key, updated);
410
421
  if (gated !== undefined)
411
422
  return errorResult(gated);
412
- const w = await env.writeFile(r.key, encodeTextForFile(updated, decodedNb.encoding, decodedNb.endings), ctx.signal);
423
+ const w = await envFinalWrite(env, r.key, encodeTextForFile(updated, decodedNb.encoding, decodedNb.endings), ctx.signal);
413
424
  if (!w.ok)
414
425
  return errorResult(`Error (NotebookEdit): cannot write "${notebook_path}": ${w.error.message}`);
415
426
  state.set(r.key, { hash: sha256(updated), totalLines: countLines(updated), truncated: false, lastReadAt: Date.now() });
@@ -11,6 +11,7 @@ export * from "./fs-search-tools.js";
11
11
  export * from "./bash-readonly-classifier.js";
12
12
  export * from "./fs-bash.js";
13
13
  export * from "./read-deny.js";
14
+ export { BASH_CLASSIFY_DEFAULT_ALLOW } from "./bash-readonly-classifier.js";
14
15
  import { type ReadDenyEntry } from "./read-deny.js";
15
16
  export * from "./read-face.js";
16
17
  import { type CwdRef, type ReadImageDownsamplerOption } from "./fs-shared.js";
@@ -12,6 +12,7 @@ export * from "./bash-readonly-classifier.js";
12
12
  export * from "./fs-bash.js";
13
13
  export * from "./read-deny.js";
14
14
  import { BASH_READONLY_DEFAULT_ALLOW, } from "./bash-readonly-classifier.js";
15
+ export { BASH_CLASSIFY_DEFAULT_ALLOW } from "./bash-readonly-classifier.js";
15
16
  import { compileReadDeny } from "./read-deny.js";
16
17
  import { deploymentReadFaceClampNotice, resolveReadFace } from "./read-face.js";
17
18
  export * from "./read-face.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.63.0",
3
+ "version": "5.64.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "_comment": "design/87 L3 — frozen public export surface of src/index.ts (name -> kind). DO NOT edit by hand to silence a red test. A removed/changed entry = a SemVer-BREAKING change; bump MAJOR and update this fixture in the SAME commit (design/87 §4.2 / §5.2). Regenerate via REGEN in test/export-surface.test.ts.",
3
3
  "_tierComment": "#435 v1 — machine-readable layering of the public surface: stable = demonstrated by README.md / src/examples; internal = an `Internal`-marked name or a runner/engine deep-subtree declaration (the model seam src/engine/llm is excluded — it is the BYOM contract, not an engine internal); advanced = a supported export the front door does not walk you through. THIS IS AN INITIAL HEURISTIC, derived mechanically and expected to be refined ticket by ticket: no human reviewed these 1700+ entries one by one, and nothing here claims otherwise. Known bias: a short or English-word export name (ok, err, Result, Usage) can match ordinary prose in README.md and land `stable` on a coincidence. Every export MUST carry a tier — a new export with no row fails the gate in export-surface.test.ts.",
4
- "count": 1764,
4
+ "count": 1766,
5
5
  "exports": {
6
6
  "A2ATaskState": "type",
7
7
  "A2ATaskStateReversal": "type",
@@ -1192,11 +1192,13 @@
1192
1192
  "WorktreeSessionRef": "interface",
1193
1193
  "WorktreeToolsOptions": "interface",
1194
1194
  "WritablePermissionRuleStore": "interface",
1195
+ "WriteExpectation": "interface",
1195
1196
  "WriteProtectedEntry": "type",
1196
1197
  "WriteProtectedHit": "interface",
1197
1198
  "WriteProtectedKind": "type",
1198
1199
  "WriteProtectedRow": "interface",
1199
1200
  "WriteProtectionMatcher": "interface",
1201
+ "WriteReceipt": "interface",
1200
1202
  "ackAdoptionConfig": "function",
1201
1203
  "acquireCcLock": "function",
1202
1204
  "addDotsOf": "function",
@@ -2958,11 +2960,13 @@
2958
2960
  "WorktreeSessionRef": "advanced",
2959
2961
  "WorktreeToolsOptions": "advanced",
2960
2962
  "WritablePermissionRuleStore": "advanced",
2963
+ "WriteExpectation": "internal",
2961
2964
  "WriteProtectedEntry": "advanced",
2962
2965
  "WriteProtectedHit": "advanced",
2963
2966
  "WriteProtectedKind": "advanced",
2964
2967
  "WriteProtectedRow": "advanced",
2965
2968
  "WriteProtectionMatcher": "advanced",
2969
+ "WriteReceipt": "internal",
2966
2970
  "ackAdoptionConfig": "advanced",
2967
2971
  "acquireCcLock": "advanced",
2968
2972
  "addDotsOf": "advanced",