@sema-agent/core 7.23.4 → 7.24.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 (89) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/dist/agents/list-agents-tool.d.ts +8 -1
  3. package/dist/agents/list-agents-tool.js +26 -3
  4. package/dist/agents/observer.js +5 -8
  5. package/dist/agents/subagent.js +2 -2
  6. package/dist/agents/team.js +9 -0
  7. package/dist/brain/anthropic.js +3 -2
  8. package/dist/brain/circuit-breaker.js +3 -3
  9. package/dist/brain/degrading.js +11 -14
  10. package/dist/brain/errors.d.ts +35 -18
  11. package/dist/brain/errors.js +49 -31
  12. package/dist/brain/failover.js +2 -3
  13. package/dist/brain/open-responses.js +6 -5
  14. package/dist/brain/openai.js +6 -5
  15. package/dist/brain/route-adjudicator.d.ts +50 -15
  16. package/dist/brain/route-adjudicator.js +112 -20
  17. package/dist/brain/route-conformance.js +9 -0
  18. package/dist/brain/stream-engine.js +18 -10
  19. package/dist/core/arg-summary.js +17 -6
  20. package/dist/core/auto-compaction.d.ts +42 -1
  21. package/dist/core/auto-compaction.js +21 -1
  22. package/dist/core/auto-mode.js +2 -8
  23. package/dist/core/checkpoint-store.d.ts +4 -6
  24. package/dist/core/checkpoint-store.js +2 -2
  25. package/dist/core/exec-gate.js +3 -5
  26. package/dist/core/file-history-store.js +11 -10
  27. package/dist/core/hooks.d.ts +7 -0
  28. package/dist/core/mcp.js +7 -5
  29. package/dist/core/memory-engine/distiller.d.ts +7 -4
  30. package/dist/core/memory-engine/distiller.js +11 -3
  31. package/dist/core/memory.js +1 -0
  32. package/dist/core/permission-rule-model.js +8 -5
  33. package/dist/core/runner/assemble-result.js +12 -19
  34. package/dist/core/runner/contracts.d.ts +1 -0
  35. package/dist/core/runner/prepare-delegation-surface.d.ts +3 -0
  36. package/dist/core/runner/prepare-delegation-surface.js +1 -0
  37. package/dist/core/runner/prepare-file-history.js +19 -0
  38. package/dist/core/runner/prepare-hands-readface.d.ts +4 -1
  39. package/dist/core/runner/prepare-hands-readface.js +4 -0
  40. package/dist/core/runner/prepare-task.js +2 -2
  41. package/dist/core/runner/run-leg.js +2 -13
  42. package/dist/core/runner/run-recovery-lanes.js +4 -20
  43. package/dist/core/runner/run-settle-and-teardown.js +2 -16
  44. package/dist/core/runner/run-turn-boundary.js +4 -20
  45. package/dist/core/runner/runtask.js +3 -5
  46. package/dist/core/runner/stream-settle-backstop.js +5 -5
  47. package/dist/core/runner/tool-end-body.js +5 -4
  48. package/dist/core/runner/tool-output-projection.js +3 -2
  49. package/dist/core/runner/turn-attachments.d.ts +0 -20
  50. package/dist/core/runner/turn-attachments.js +41 -36
  51. package/dist/core/session-store.js +2 -4
  52. package/dist/core/session.d.ts +4 -1
  53. package/dist/core/session.js +2 -1
  54. package/dist/core/task-event.d.ts +19 -9
  55. package/dist/core/task-registry-shared.js +1 -0
  56. package/dist/core/thrown-value.d.ts +52 -11
  57. package/dist/core/thrown-value.js +35 -1
  58. package/dist/core/tool-catalog-entries.js +1 -1
  59. package/dist/core/tool-errors.d.ts +6 -0
  60. package/dist/core/tool-errors.js +6 -14
  61. package/dist/core/tool-model-gate.js +3 -15
  62. package/dist/core/tools.js +15 -16
  63. package/dist/core/untrusted-text.d.ts +6 -0
  64. package/dist/core/untrusted-text.js +1 -0
  65. package/dist/core/workflow-run-store-contract.js +13 -0
  66. package/dist/core/workflow-run-store.d.ts +15 -0
  67. package/dist/core/workflow-run-store.js +1 -0
  68. package/dist/engine/llm/types.d.ts +4 -4
  69. package/dist/engine/loop/agent-loop.js +10 -24
  70. package/dist/index.d.ts +1 -1
  71. package/dist/index.js +1 -1
  72. package/dist/orchestration/workflow.js +44 -23
  73. package/dist/stores/file/file-history-store.js +12 -11
  74. package/dist/tools/fs/bash-program-position.d.ts +23 -0
  75. package/dist/tools/fs/bash-program-position.js +11 -0
  76. package/dist/tools/fs/bash-readonly-classifier.d.ts +30 -7
  77. package/dist/tools/fs/bash-readonly-classifier.js +31 -10
  78. package/dist/tools/fs/fs-bash.d.ts +30 -1
  79. package/dist/tools/fs/fs-bash.js +123 -54
  80. package/dist/tools/fs/read-deny.d.ts +14 -0
  81. package/dist/tools/fs/read-deny.js +5 -0
  82. package/dist/tools/fs/safety.d.ts +1 -1
  83. package/dist/tools/fs/safety.js +2 -1
  84. package/dist/tools/monitor.d.ts +5 -0
  85. package/dist/tools/monitor.js +7 -0
  86. package/package.json +1 -1
  87. package/test/export-surface.snapshot.json +7 -3
  88. package/wire-changes/core@7.23.5.json +133 -0
  89. package/wire-changes/core@7.24.0.json +249 -0
@@ -9,6 +9,7 @@
9
9
  * does (an option skipped, a chain member missed) is how one face used to vouch for a program another refused.
10
10
  */
11
11
  import type { SegmentTokens } from "./bash-lexer.js";
12
+ import type { AssertAllKeysHandled } from "../../core/ask-origin.js";
12
13
  /** The verdict every unresolvable / out-of-root / unbounded finding ends on. The findings differ in what
13
14
  * they found; the consequence is one consequence, and a copy that drifts reads as a second rule. */
14
15
  export declare const NOT_AUTO_ALLOWED = "\u2014 not auto-allowed";
@@ -170,6 +171,28 @@ export declare function argvReentryUnjudged(pos: ProgramPosition, acceptedTarget
170
171
  */
171
172
  export declare const SHELL_SCAN_DECLINES: readonly ["unreadable_program_position", "stream_fed_operands"];
172
173
  export type ShellScanDecline = (typeof SHELL_SCAN_DECLINES)[number];
174
+ /** The two SEAT TABLES over that set — ROW PER MEMBER behind a compile-time fence, registered in
175
+ * `docs/CLOSED-SETS.md` so `gate:closed-sets` is their runtime twin. The ARGUMENT for the rows is at the
176
+ * seats that read them (`fs-bash.ts`), which DERIVE the `Set` shape their readers take. Rows and not two
177
+ * sets because a derived set beside an empty literal made a third decline SILENTLY asymmetric, in the
178
+ * widening direction; a member with no row now reds `tsc` at both fences with the word spelled out. */
179
+ export declare const BOUNDARY_SEAT_READS_DECLINE: {
180
+ readonly unreadable_program_position: true;
181
+ readonly stream_fed_operands: true;
182
+ };
183
+ /** The fence over the read-boundary seat's table: `never` while every decline has a row. */
184
+ export type BoundarySeatTableCoversEveryDecline = AssertAllKeysHandled<Exclude<ShellScanDecline, keyof typeof BOUNDARY_SEAT_READS_DECLINE>>;
185
+ /** The classify doctrine's table — every row `false` BY THE ARGUMENT (stated at the seat), not by omission. */
186
+ export declare const CLASSIFY_SEAT_READS_DECLINE: {
187
+ readonly unreadable_program_position: false;
188
+ readonly stream_fed_operands: false;
189
+ };
190
+ /** The fence over the classify seat's table. */
191
+ export type ClassifySeatTableCoversEveryDecline = AssertAllKeysHandled<Exclude<ShellScanDecline, keyof typeof CLASSIFY_SEAT_READS_DECLINE>>;
192
+ /** A seat's table in the shape its readers take — the members it answers YES for. DERIVED, so the ROWS are
193
+ * the single source and a set can never say something its table does not. The parameter is TOTAL, not
194
+ * `Partial`: that stops a third seat being written `seatDeclineEvidence({})`, one level up from the fences. */
195
+ export declare function seatDeclineEvidence(table: Record<ShellScanDecline, boolean>): ReadonlySet<ShellScanDecline>;
173
196
  /** Does a SHELL stand where this segment's program would be — as the program itself (`sh -c`), or as a launcher
174
197
  * ROW handing its operands to one (`watch "…"`)? ONE predicate for the re-entry class, so the two spellings
175
198
  * cannot be answered differently; which names are shells is the caller's declaration. Not a decline. */
@@ -159,6 +159,17 @@ export function argvReentryUnjudged(pos, acceptedTargets) {
159
159
  return runs === undefined || !acceptedTargets.has(runs);
160
160
  }
161
161
  export const SHELL_SCAN_DECLINES = ["unreadable_program_position", "stream_fed_operands"];
162
+ export const BOUNDARY_SEAT_READS_DECLINE = {
163
+ unreadable_program_position: true,
164
+ stream_fed_operands: true,
165
+ };
166
+ export const CLASSIFY_SEAT_READS_DECLINE = {
167
+ unreadable_program_position: false,
168
+ stream_fed_operands: false,
169
+ };
170
+ export function seatDeclineEvidence(table) {
171
+ return new Set(SHELL_SCAN_DECLINES.filter((decline) => table[decline] === true));
172
+ }
162
173
  export function programPositionRunsShell(pos, shellProgram) {
163
174
  if (pos.refusal !== undefined)
164
175
  return pos.refusal.kind === "control_syntax_at_program_position" && pos.refusal.runsShell === true;
@@ -76,11 +76,13 @@ export interface LeadingCommandNameOptions {
76
76
  * Read a shell operator that sits inside a QUOTED region as ordinary argument TEXT rather than as an
77
77
  * operator, so `grep -E "a|b" f.log` parses as the single simple command it is.
78
78
  *
79
- * OPT-IN, and used by ONE caller: the compound classify face ({@link classifyCompoundReadonlyDetailed}),
80
- * which has already rejected the whole command for backslashes / `$` / backticks / subshells / line
81
- * breaks and so is inside {@link quoteMask}'s exactness envelope. The declaration and rule faces do
82
- * NOT set it — their contract is "a single simple command with no operator characters at all", which
83
- * is a spelling rule about what a person can read at a glance, not a claim about bash's grammar.
79
+ * OPT-IN, and the two callers that set it share one premise: the whole command has ALREADY been refused
80
+ * for backslashes / an expanding `$` / backticks / subshells / line breaks, so they are inside
81
+ * {@link quoteMask}'s exactness envelope — the compound classify face
82
+ * ({@link classifyCompoundReadonlyDetailed}) and the persisted permission-RULE lane (`RULE_LANE_FLOOR`),
83
+ * whose segments come out of that same gate ({@link splitShellCompoundSegments}). The `bash_readonly`
84
+ * DECLARATION face does NOT set it: its contract is "a single simple command with no operator characters
85
+ * at all", and it runs on raw tool input with no whole-string gate in front of it.
84
86
  *
85
87
  * Fail-closed on unbalanced quoting: with a region left open, the mask is not trusted and every
86
88
  * operator character counts, exactly as without this option.
@@ -617,8 +619,29 @@ export interface OutOfRootReadGateVerdict {
617
619
  readonly paths: readonly string[];
618
620
  /** Did the deployment's DENY judge speak about an operand this command names? The other half of the same
619
621
  * question, over the very same readers, in a second pass WITH the deny judge — which is what lets the
620
- * containment answer above stay deny-free and keep its `paths` contract. Absent ⇔ it said nothing. */
621
- readonly denied?: true;
622
+ * containment answer above stay deny-free and keep its `paths` contract. Absent ⇔ it said nothing.
623
+ *
624
+ * It carries the FINDING, not a boolean: this is the arm a command the grammar gate refused WHOLE
625
+ * (`cat .env > out.txt`, a subshell, a substitution) reaches, and there the compound verdict carries no
626
+ * deny fields at all — so a caller disclosing the hit has nowhere else to read the path and the pattern
627
+ * from, and said nothing. The second pass already computed both; throwing them away was the whole bug. */
628
+ readonly denied?: {
629
+ readonly path: string;
630
+ readonly pattern: string;
631
+ };
632
+ /** The reader operands this pass RESOLVED and placed inside the roots, plus the expansions it parsed but
633
+ * cannot enumerate — the candidates an execute-time recheck must canonicalize.
634
+ *
635
+ * Published because this pass reads SHAPES the compound walk hands nothing out for: a launcher whose
636
+ * program it cannot name (`env -- cat f`, `sudo -u root cat f`), a stream-fed one (`… | xargs cat f`).
637
+ * There the compound verdict carries no candidates at all, so the recheck had nothing to resolve and an
638
+ * in-root symlink to a deny-listed target RAN after an approval — while the same read spelled without
639
+ * the launcher was refused. Same operands, same judge, one more pass allowed to hand them over.
640
+ *
641
+ * NOT the out-of-root ones: those ride {@link paths}, they are what the boundary RAISED, and a leg that
642
+ * re-refused them would be a second adjudicator of a decision already made. */
643
+ readonly checked: readonly string[];
644
+ readonly undecided: readonly string[];
622
645
  }
623
646
  /** Test seam for the lockstep pin that the launcher table (the ONE launcher truth, in the program-position
624
647
  * reader's module) stays a SUBSET of the rule lane's curated `BARE_INTERPRETER_NAMES`. Exported for that one
@@ -1,3 +1,4 @@
1
+ import { sensitivePathDenyList } from "./read-deny.js";
1
2
  import { hasWin32AliasedComponent, isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf, withinAnyRoot } from "./safety.js";
2
3
  import { lexShellText, literalTildeSpelling, quoteMask, substitutionsInHeredocBody, SUBSTITUTION_PLACEHOLDER, wordsOf, } from "./bash-lexer.js";
3
4
  import { ASSIGNMENT_WORD, COMMAND_LAUNCHERS, NOT_AUTO_ALLOWED, SHELL_BLOCK_WORDS, argvReentryUnjudged, isBareControlWord, programPositionRunsShell, shellScanDeclineOf, programBasename, programPositionRefusalSentence, readProgramPosition, } from "./bash-program-position.js";
@@ -19,11 +20,16 @@ function hasBareShellOperator(s, quotedOperatorsAreText) {
19
20
  if (!mask.balanced)
20
21
  return SHELL_OPERATORS.test(s);
21
22
  for (let i = 0; i < s.length; i++) {
22
- if (!mask.quoted[i] && SHELL_OPERATORS.test(s[i]))
23
+ const ch = s[i];
24
+ if (!SHELL_OPERATORS.test(ch))
25
+ continue;
26
+ const literal = SHELL_EXPANDS_IN_DOUBLE_QUOTES.test(ch) ? mask.openedBy[i] === "'" : mask.quoted[i];
27
+ if (!literal)
23
28
  return true;
24
29
  }
25
30
  return false;
26
31
  }
32
+ const SHELL_EXPANDS_IN_DOUBLE_QUOTES = /[$`]/;
27
33
  export function parseLeadingCommandName(command, options) {
28
34
  const trimmed = command.trim();
29
35
  if (!trimmed)
@@ -2169,7 +2175,7 @@ function evaluateReadBoundary(foldedSegments, boundary, mintGrantCandidate, reac
2169
2175
  const denied = boundary.denyMatch?.(finding.path);
2170
2176
  if (denied != null) {
2171
2177
  return {
2172
- reason: `a command operand resolves to "${finding.path}", which matches the sensitive-path read deny list (pattern "${denied}") ${NOT_AUTO_ALLOWED}`,
2178
+ reason: `a command operand resolves to "${finding.path}", which matches ${sensitivePathDenyList(denied)} ${NOT_AUTO_ALLOWED}`,
2173
2179
  readDenied: true,
2174
2180
  readDeniedPath: finding.path,
2175
2181
  readDeniedPattern: denied,
@@ -2301,7 +2307,7 @@ export function classifySimpleCommandReadBoundary(command, boundary) {
2301
2307
  }
2302
2308
  return evaluateReadBoundary([toks], boundary, true);
2303
2309
  }
2304
- const NOT_GATED = { gated: false, paths: [] };
2310
+ const NOT_GATED = { gated: false, paths: [], checked: [], undecided: [] };
2305
2311
  const UNREADABLE_WHITELIST_WORD = /[$`()*?[\]{}]/;
2306
2312
  const UNMODELLED_BASE_MOVERS = new Set(["pushd", "popd"]);
2307
2313
  export const COMMAND_LAUNCHERS_FOR_TEST = COMMAND_LAUNCHERS;
@@ -2359,18 +2365,33 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
2359
2365
  if (readers.length === 0)
2360
2366
  return NOT_GATED;
2361
2367
  if (baseMoved)
2362
- return { gated: true, paths: [] };
2368
+ return { gated: true, paths: [], checked: [], undecided: [] };
2363
2369
  const verdict = evaluateReadBoundary(readers, containmentOnly, false, readerReach);
2364
- const denied = boundary.denyMatch === undefined ? false : evaluateReadBoundary(readers, boundary, false, readerReach).readDenied === true;
2365
- if (verdict.outOfRootRead === true)
2366
- return { gated: true, paths: verdict.outOfRootPaths ?? [], ...(denied ? { denied: true } : {}) };
2367
- return denied ? { gated: false, paths: [], denied: true } : NOT_GATED;
2370
+ const denyPass = boundary.denyMatch === undefined ? undefined : evaluateReadBoundary(readers, boundary, false, readerReach);
2371
+ const denied = denyPass?.readDenied === true && denyPass.readDeniedPath !== undefined && denyPass.readDeniedPattern !== undefined
2372
+ ? { path: denyPass.readDeniedPath, pattern: denyPass.readDeniedPattern }
2373
+ : undefined;
2374
+ const resolvedOperands = {
2375
+ checked: [...(verdict.checkedPaths ?? []), ...(verdict.checkedSpellings ?? [])],
2376
+ undecided: [...(verdict.undecidedPaths ?? []), ...(verdict.undecidedSpellings ?? [])],
2377
+ };
2378
+ if (verdict.outOfRootRead === true) {
2379
+ return { gated: true, paths: verdict.outOfRootPaths ?? [], ...resolvedOperands, ...(denied !== undefined ? { denied } : {}) };
2380
+ }
2381
+ if (denied !== undefined)
2382
+ return { gated: false, paths: [], ...resolvedOperands, denied };
2383
+ return resolvedOperands.checked.length === 0 && resolvedOperands.undecided.length === 0 ? NOT_GATED : { gated: false, paths: [], ...resolvedOperands };
2368
2384
  };
2369
2385
  const direct = judge(command);
2370
- if (direct.gated || direct.denied === true)
2386
+ if (direct.gated || direct.denied !== undefined)
2371
2387
  return direct;
2372
2388
  const shape = POLL_LOOP_SHAPE.exec(command.trim());
2373
- return shape !== null ? judge(shape[3]) : NOT_GATED;
2389
+ if (shape === null)
2390
+ return direct;
2391
+ const loop = judge(shape[3]);
2392
+ return loop.gated || loop.denied !== undefined
2393
+ ? loop
2394
+ : { ...loop, checked: [...direct.checked, ...loop.checked], undecided: [...direct.undecided, ...loop.undecided] };
2374
2395
  }
2375
2396
  export function classifyCompoundReadonly(command, allow, boundary) {
2376
2397
  return classifyCompoundReadonlyDetailed(command, allow, boundary).reason;
@@ -66,6 +66,10 @@ export declare function bashReversibilityProbe(allow?: readonly string[], bounda
66
66
  * ONE rule, TWO TABLES: which {@link ShellScanDecline} a SEAT reads as read evidence. A decline names no word,
67
67
  * so it is never the boundary DECLARING anything about a path.
68
68
  *
69
+ * BOTH tables are ROW-PER-MEMBER objects behind a compile-time fence ({@link BOUNDARY_SEAT_READS_DECLINE} /
70
+ * {@link CLASSIFY_SEAT_READS_DECLINE}); the sets below are DERIVED from those rows and the ARGUMENT for the
71
+ * rows is this block. "Empty by the argument" is a row saying `false`, not a literal nobody can judge.
72
+ *
69
73
  * The READ-BOUNDARY seat is what the default/off doctrine and a bypass mode mount ALONE: there "not mandated"
70
74
  * is VOUCHED, so a decline must count as evidence or a read nobody judged runs silently (`<out-of-root> |
71
75
  * xargs cat` at zero asks — a closed P0). It keeps both. The CLASSIFY seat composes the same boundary with the
@@ -87,8 +91,13 @@ export declare function bashReversibilityProbe(allow?: readonly string[], bounda
87
91
  * DID read naming a path outside the roots still gates the call ({@link classifyOutOfRootReadGate}).
88
92
  */
89
93
  export declare const BOUNDARY_SEAT_DECLINE_EVIDENCE: ReadonlySet<ShellScanDecline>;
90
- /** The classify doctrine's table — see above; empty by the argument. */
94
+ /** The classify doctrine's table in the shape its readers take — see above; empty because every row says `false`. */
91
95
  export declare const CLASSIFY_SEAT_DECLINE_EVIDENCE: ReadonlySet<ShellScanDecline>;
96
+ export declare function fitMiddle(s: string, budget: number): string;
97
+ /** `s` shortened to `budget` by removing its MIDDLE, marked — cut through the repo's surrogate-safe
98
+ * primitive, never a raw `slice` (a raw cut leaves half an astral code point on a card and in a durable
99
+ * row). Under a dozen characters the HEAD is taken. */
100
+ export declare function elideMiddle(s: string, budget: number): string;
92
101
  /**
93
102
  * The READ-BOUNDARY seat of the full shell: a `ToolSpec.reversibilityProbe` that answers exactly ONE
94
103
  * question — does the deployment's read boundary object to where this command reads? — and nothing about
@@ -292,6 +301,26 @@ export declare function createBashTool(env: ExecutionEnv, rootCanonical: string,
292
301
  * Absent ⇒ historic bare tag. */
293
302
  reminderMark?: string;
294
303
  }): AgentTool;
304
+ type ShellCanonicalRefusal = {
305
+ content: string;
306
+ isError: true;
307
+ details?: unknown;
308
+ };
309
+ /** What a face contributes to the shared refusal cards: the TOOL refusing, and the three prose slots. */
310
+ export interface ShellFaceProse {
311
+ readonly tool: string;
312
+ readonly subject: string;
313
+ readonly verdictPhrase: string;
314
+ readonly note: string;
315
+ }
316
+ export type ShellCanonicalRecheck = (command: string, signal?: AbortSignal) => Promise<ShellCanonicalRefusal | undefined>;
317
+ /**
318
+ * THE execute-time canonical recheck, mounted by every face that spawns a shell command — the full `Bash`
319
+ * tool and `Monitor`, whose approval-time seats judge the WRITTEN spelling while this re-judges where the
320
+ * kernel takes those words. The SAME leg, not a second one; the contract below is the leg's own. */
321
+ export declare function createShellCanonicalRecheck(env: ExecutionEnv, readBoundary: BashReadonlyRootBoundary | (() => BashReadonlyRootBoundary | undefined) | undefined, face?: ShellFaceProse): ShellCanonicalRecheck;
322
+ /** The `Monitor` face's prose — the full shell's with its own tool name: one leg, one finding, two tools. */
323
+ export declare const MONITOR_ESCAPE_PROSE: ShellFaceProse;
295
324
  /**
296
325
  * `bash_readonly` (effect:read) — a restricted shell for the verifier read-only boundary: a single
297
326
  * allowlisted, bare command with no shell operators. effect:read lets it survive the verifier's
@@ -7,15 +7,16 @@ import { RollingTailBuffer } from "../../core/exec-output-tail.js";
7
7
  import { TASK_OUTPUT_TOOL_NAME, TASK_STOP_TOOL_NAME, TASK_OUTPUT_MISSING_ID_MESSAGE, TASK_STOP_MISSING_ID_MESSAGE, TASK_STOP_PARAMS, resolveTaskIdArg, ENV_DIRECT_TASK_TOOL_CAPS, composeTaskOutputDescription, composeTaskOutputParams, composeTaskStopDescription, } from "../../core/task-tool-shape.js";
8
8
  import { hasBackgroundShell } from "../../core/background-shell.js";
9
9
  import { delimitUntrusted } from "../../core/untrusted-text.js";
10
+ import { sliceHeadSafe, sliceTailSafe } from "../../core/surrogate-safe-slice.js";
10
11
  import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
11
12
  import { imageMagicMatches, normalizeAbsPathLexically, violationDetails, withinAnyRoot } from "./safety.js";
12
13
  import { boundaryIsTheSandbox, canonicalPathIsAuthoritative, hasDestroy, isIsolated } from "../../core/remote-env.js";
13
14
  import { ghRateLimitHint } from "./gh-rate-limit.js";
14
15
  import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashTimeoutArgRefusal, bashTimeoutParamDescription, envErrorDetail, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, createShellOverflowSpoolFence, shellRecoveryHint, CWD_SENTINEL, stripCwdSentinel, BASH_READONLY_CONFINEMENT_NOTE, } from "./fs-shared.js";
15
- import { PROBE_CAUSE_PATH_MAX, inlineUntrusted } from "../../core/untrusted-text.js";
16
+ import { PROBE_CAUSE_PATH_MAX, PROBE_REASON_MAX, inlineUntrusted } from "../../core/untrusted-text.js";
16
17
  import { bashClassifyDefaultAllow, BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoopDetailed, classifyCompoundReadonlyDetailed, classifyOutOfRootReadGate, refusedCommandMayRead, classifySimpleCommandReadBoundary, } from "./bash-readonly-classifier.js";
17
- import { NOT_AUTO_ALLOWED, SHELL_SCAN_DECLINES } from "./bash-program-position.js";
18
- import { readDenySeat } from "./read-deny.js";
18
+ import { BOUNDARY_SEAT_READS_DECLINE, CLASSIFY_SEAT_READS_DECLINE, NOT_AUTO_ALLOWED, seatDeclineEvidence, } from "./bash-program-position.js";
19
+ import { readDenySeat, sensitivePathDenyList, SENSITIVE_PATH_DENY_SCOPE } from "./read-deny.js";
19
20
  import { toolFace } from "../../core/tool-catalog-entries.js";
20
21
  import { thrownText } from "../../core/thrown-value.js";
21
22
  const RECURSIVE_CAUSE_MAX_PATHS = 3;
@@ -35,13 +36,13 @@ export function bashReversibilityProbe(allow, boundary) {
35
36
  const backgrounded = isBackgroundShellCall(args);
36
37
  const detailed = classifyCompoundReadonlyDetailed(command, allowSet, resolved);
37
38
  if (backgrounded)
38
- return { reversible: false, ...boundaryGate(detailed) };
39
+ return { reversible: false, ...boundaryGate(detailed), ...probeAccountOf(command, allowSet, resolved, detailed) };
39
40
  if (detailed.reason === undefined) {
40
41
  if (boundaryUnread(detailed)) {
41
- const cause = recursiveReadCause(detailed);
42
- if (cause !== undefined)
43
- return { reversible: false, ...boundaryGate(detailed), cause };
44
- return { reversible: false, ...outOfRootGate() };
42
+ const account = probeAccountOf(command, allowSet, resolved, detailed);
43
+ if (account.cause !== undefined)
44
+ return { reversible: false, ...boundaryGate(detailed), ...account };
45
+ return { reversible: false, ...outOfRootGate(), ...account };
45
46
  }
46
47
  return { reversible: true };
47
48
  }
@@ -51,9 +52,8 @@ export function bashReversibilityProbe(allow, boundary) {
51
52
  const seatOffer = (verdict) => (offers ? readRootCandidateOf(verdict) : {});
52
53
  const loopScan = refusedCommandScan(command, allowSet, resolved, loop, CLASSIFY_SEAT_DECLINE_EVIDENCE);
53
54
  if (boundaryDeclared(loop) || unjudgedRead(command, allowSet, resolved, loop, loopScan))
54
- return { reversible: false, mandated: true, ...seatOffer(loop) };
55
- const refusedCause = recursiveReadCause(detailed);
56
- return { reversible: false, ...boundaryGate(detailed), ...seatOffer(detailed), ...(refusedCause !== undefined ? { cause: refusedCause } : {}) };
55
+ return { reversible: false, mandated: true, ...probeAccountOf(command, allowSet, resolved, loop), ...seatOffer(loop) };
56
+ return { reversible: false, ...boundaryGate(detailed), ...seatOffer(detailed), ...probeAccountOf(command, allowSet, resolved, detailed) };
57
57
  };
58
58
  return (args) => {
59
59
  const command = shellCommandOf(args);
@@ -90,8 +90,8 @@ function shellCommandOf(args) {
90
90
  function isBackgroundShellCall(args) {
91
91
  return args?.run_in_background === true;
92
92
  }
93
- export const BOUNDARY_SEAT_DECLINE_EVIDENCE = new Set(SHELL_SCAN_DECLINES);
94
- export const CLASSIFY_SEAT_DECLINE_EVIDENCE = new Set();
93
+ export const BOUNDARY_SEAT_DECLINE_EVIDENCE = seatDeclineEvidence(BOUNDARY_SEAT_READS_DECLINE);
94
+ export const CLASSIFY_SEAT_DECLINE_EVIDENCE = seatDeclineEvidence(CLASSIFY_SEAT_READS_DECLINE);
95
95
  function readBoundaryMandate(command, allowSet, resolved, verdict, declineEvidence) {
96
96
  if (resolved === undefined)
97
97
  return {};
@@ -99,7 +99,7 @@ function readBoundaryMandate(command, allowSet, resolved, verdict, declineEviden
99
99
  if (denyJudgeSpoke(verdict) || unjudgedRead(command, allowSet, resolved, verdict, scan))
100
100
  return { mandated: true };
101
101
  const named = classifyOutOfRootReadGate(command, allowSet, resolved);
102
- if (named.gated || named.denied === true)
102
+ if (named.gated || named.denied !== undefined)
103
103
  return { mandated: true };
104
104
  return declinedRung(scan);
105
105
  }
@@ -122,7 +122,7 @@ function unjudgedRead(command, allowSet, resolved, verdict, scan) {
122
122
  if (resolved === undefined)
123
123
  return false;
124
124
  const named = classifyOutOfRootReadGate(command, allowSet, resolved);
125
- return named.gated || named.denied === true;
125
+ return named.gated || named.denied !== undefined;
126
126
  }
127
127
  function recursiveReadCause(detailed) {
128
128
  const recursive = detailed.recursiveReadPaths;
@@ -136,17 +136,65 @@ function recursiveReadCause(detailed) {
136
136
  ...(others.length > 0 ? { further: operandFamily(others) } : {}),
137
137
  };
138
138
  }
139
+ function denyHitReason(finding) {
140
+ if (finding === undefined)
141
+ return {};
142
+ const { path, pattern } = finding;
143
+ const composed = (target, pat) => `reading "${target}" matches ${sensitivePathDenyList(pat)}.`;
144
+ const room = PROBE_REASON_MAX - composed("", "").length;
145
+ const patternRoom = Math.min(pattern.length, Math.max(Math.floor(room / 2), room - path.length));
146
+ return { reason: composed(fitMiddle(path, room - patternRoom), fitMiddle(pattern, patternRoom)) };
147
+ }
148
+ export function fitMiddle(s, budget) {
149
+ return s.length <= budget ? s : elideMiddle(s, budget);
150
+ }
151
+ export function elideMiddle(s, budget) {
152
+ if (budget < 12)
153
+ return sliceHeadSafe(s, Math.max(0, budget));
154
+ const keep = budget - 1;
155
+ const head = Math.ceil(keep / 2);
156
+ return `${sliceHeadSafe(s, head)}\u2026${sliceTailSafe(s, keep - head)}`;
157
+ }
158
+ function denyFindingOf(command, allowSet, resolved, verdict) {
159
+ if (verdict.readDenied === true && verdict.readDeniedPath !== undefined && verdict.readDeniedPattern !== undefined) {
160
+ return { path: verdict.readDeniedPath, pattern: verdict.readDeniedPattern };
161
+ }
162
+ return resolved === undefined ? undefined : classifyOutOfRootReadGate(command, allowSet, resolved).denied;
163
+ }
164
+ function recursiveDenyReason(resolved, verdict) {
165
+ const roots = verdict.recursiveReadPaths;
166
+ const entries = resolved?.denyEntries;
167
+ if (roots === undefined || roots.length === 0 || entries === undefined || entries.length === 0)
168
+ return {};
169
+ const composed = (root, rows) => `a recursive read form traverses "${root}", which this walk does not enumerate, past ${sensitivePathDenyList(rows)}.`;
170
+ const root = roots[0] ?? "";
171
+ const label = (rows) => rows.length < entries.length ? [...rows, `+${entries.length - rows.length} more`] : rows;
172
+ let rows = entries.slice(0, MAX_DENY_ROWS_NAMED);
173
+ while (rows.length > 1 && PROBE_REASON_MAX - composed("", label(rows)).length < MIN_TRAVERSAL_ROOT_ROOM)
174
+ rows = rows.slice(0, -1);
175
+ let shown = label(rows);
176
+ if (PROBE_REASON_MAX - composed("", shown).length < MIN_TRAVERSAL_ROOT_ROOM) {
177
+ const over = MIN_TRAVERSAL_ROOT_ROOM - (PROBE_REASON_MAX - composed("", shown).length);
178
+ shown = label([fitMiddle(rows[0] ?? "", Math.max((rows[0] ?? "").length - over, 8))]);
179
+ }
180
+ return { reason: composed(fitMiddle(root, Math.max(PROBE_REASON_MAX - composed("", shown).length, 0)), shown) };
181
+ }
182
+ const MAX_DENY_ROWS_NAMED = 3;
183
+ const MIN_TRAVERSAL_ROOT_ROOM = 24;
184
+ function probeAccountOf(command, allowSet, resolved, verdict) {
185
+ const cause = recursiveReadCause(verdict);
186
+ const hit = denyHitReason(denyFindingOf(command, allowSet, resolved, verdict));
187
+ return { ...(hit.reason !== undefined ? hit : recursiveDenyReason(resolved, verdict)), ...(cause !== undefined ? { cause } : {}) };
188
+ }
139
189
  export function bashReadBoundaryProbe(boundary) {
140
190
  const allowSet = new Set(bashClassifyDefaultAllow());
141
191
  const judge = (command, resolved, offers) => {
142
192
  const seatVerdict = (verdict) => {
143
193
  if (boundaryDeclared(verdict) || unjudgedRead(command, allowSet, resolved, verdict, refusedCommandScan(command, allowSet, resolved, verdict, BOUNDARY_SEAT_DECLINE_EVIDENCE))) {
144
- const cause = recursiveReadCause(verdict);
145
- return { reversible: false, mandated: true, ...(cause !== undefined ? { cause } : {}) };
194
+ return { reversible: false, mandated: true, ...probeAccountOf(command, allowSet, resolved, verdict) };
146
195
  }
147
196
  if (boundaryUndecided(verdict)) {
148
- const cause = recursiveReadCause(verdict);
149
- return { reversible: false, ...(cause !== undefined ? { cause } : {}) };
197
+ return { reversible: false, ...probeAccountOf(command, allowSet, resolved, verdict) };
150
198
  }
151
199
  if (verdict.reason === undefined)
152
200
  return { reversible: true };
@@ -165,7 +213,7 @@ export function bashReadBoundaryProbe(boundary) {
165
213
  if (mandated !== undefined)
166
214
  return { ...mandated, ...offerFrom(detailed), ...offerFrom(loop) };
167
215
  if (readBoundaryMandate(command, allowSet, resolved, detailed, BOUNDARY_SEAT_DECLINE_EVIDENCE).mandated === true)
168
- return { reversible: false, mandated: true };
216
+ return { reversible: false, mandated: true, ...probeAccountOf(command, allowSet, resolved, detailed) };
169
217
  return compoundVerdict ?? loopVerdict ?? { reversible: true };
170
218
  };
171
219
  return (args) => {
@@ -763,30 +811,7 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
763
811
  const bgRetained = hasBackgroundShell(env) && env.backgroundCapabilities.retainBackgroundProcesses === true;
764
812
  const bgSessionScoped = taskOpts.sessionId !== undefined && taskOpts.taskRegistry !== undefined;
765
813
  const bgEnvIsolatedOwned = hasDestroy(env) && isIsolated(env);
766
- const recheckReaders = new Set(bashClassifyDefaultAllow());
767
- let canonicalForRoots;
768
- const canonicalBoundaryFor = (roots) => {
769
- const key = roots.join("\u0000");
770
- if (canonicalForRoots?.key !== key)
771
- canonicalForRoots = { key, boundary: createCanonicalReadBoundary(env, roots) };
772
- return canonicalForRoots.boundary;
773
- };
774
- const canonicalEscape = async (command, signal) => {
775
- const resolvedBoundary = typeof taskOpts.readBoundary === "function" ? taskOpts.readBoundary() : taskOpts.readBoundary;
776
- if (resolvedBoundary === undefined)
777
- return undefined;
778
- const ask = { deny: resolvedBoundary.denyMatch, containment: resolvedBoundary.face !== "open" };
779
- if (ask.deny === undefined && !ask.containment)
780
- return undefined;
781
- const faces = [
782
- classifyCompoundReadonlyDetailed(command, recheckReaders, resolvedBoundary),
783
- classifyBoundedReadonlyPollLoopDetailed(command, recheckReaders, resolvedBoundary),
784
- ];
785
- const literal = [...new Set(faces.flatMap((v) => [...(v.checkedPaths ?? []), ...(v.checkedSpellings ?? [])]))];
786
- const asked = new Set(faces.flatMap((v) => v.outOfRootPaths ?? []));
787
- const patterns = [...new Set(faces.flatMap((v) => [...(v.undecidedPaths ?? []), ...(v.undecidedSpellings ?? [])]))].filter((p) => !asked.has(normalizeAbsPathLexically(p)));
788
- return await canonicalEscapeRefusal(canonicalBoundaryFor(resolvedBoundary.roots), { literal, patterns }, resolvedBoundary.roots, FULL_SHELL_FACE_ESCAPE_PROSE, ask, signal);
789
- };
814
+ const canonicalEscape = createShellCanonicalRecheck(env, taskOpts.readBoundary);
790
815
  return defineTool({
791
816
  name: "Bash",
792
817
  ...toolFace("bash"),
@@ -1259,13 +1284,15 @@ function createCanonicalReadBoundary(env, readRoots) {
1259
1284
  if (deny === undefined || denied !== undefined)
1260
1285
  return false;
1261
1286
  const folded = normalizeAbsPathLexically(p);
1287
+ if (ask.denyAnswered.has(folded))
1288
+ return false;
1262
1289
  const pattern = deny(folded);
1263
1290
  if (pattern === null)
1264
1291
  return false;
1265
1292
  denied = { path: folded, pattern };
1266
1293
  return true;
1267
1294
  };
1268
- const judge = async (p, canonicalKnown) => {
1295
+ const judge = async (p, canonicalKnown, origin) => {
1269
1296
  if (denyHit(p))
1270
1297
  return;
1271
1298
  const real = canonicalKnown !== undefined ? { value: canonicalKnown, exact: true } : await resolve(p, signal);
@@ -1280,6 +1307,8 @@ function createCanonicalReadBoundary(env, readRoots) {
1280
1307
  }
1281
1308
  if (denyHit(real.value))
1282
1309
  return;
1310
+ if (ask.containmentAnswered.has(normalizeAbsPathLexically(origin ?? p)))
1311
+ return;
1283
1312
  if (ask.containment && !withinAnyRoot(roots, real.value) && !escaping.includes(real.value))
1284
1313
  escaping.push(real.value);
1285
1314
  };
@@ -1300,7 +1329,7 @@ function createCanonicalReadBoundary(env, readRoots) {
1300
1329
  for (const t of expanded.paths) {
1301
1330
  if (denied !== undefined)
1302
1331
  break;
1303
- await judge(t, expanded.canonical.get(t));
1332
+ await judge(t, expanded.canonical.get(t), p);
1304
1333
  }
1305
1334
  }
1306
1335
  else
@@ -1331,18 +1360,56 @@ function createCanonicalReadBoundary(env, readRoots) {
1331
1360
  },
1332
1361
  };
1333
1362
  }
1363
+ const NOTHING_ANSWERED = new Set();
1364
+ export function createShellCanonicalRecheck(env, readBoundary, face) {
1365
+ const recheckReaders = new Set(bashClassifyDefaultAllow());
1366
+ let canonicalForRoots;
1367
+ const canonicalBoundaryFor = (roots) => {
1368
+ const key = roots.join("\u0000");
1369
+ if (canonicalForRoots?.key !== key)
1370
+ canonicalForRoots = { key, boundary: createCanonicalReadBoundary(env, roots) };
1371
+ return canonicalForRoots.boundary;
1372
+ };
1373
+ return async (command, signal) => {
1374
+ const resolvedBoundary = typeof readBoundary === "function" ? readBoundary() : readBoundary;
1375
+ if (resolvedBoundary === undefined)
1376
+ return undefined;
1377
+ const containmentAsked = resolvedBoundary.face !== "open";
1378
+ if (resolvedBoundary.denyMatch === undefined && !containmentAsked)
1379
+ return undefined;
1380
+ const faces = [
1381
+ classifyCompoundReadonlyDetailed(command, recheckReaders, resolvedBoundary),
1382
+ classifyBoundedReadonlyPollLoopDetailed(command, recheckReaders, resolvedBoundary),
1383
+ ];
1384
+ const named = classifyOutOfRootReadGate(command, recheckReaders, resolvedBoundary);
1385
+ const literalAll = [
1386
+ ...new Set([
1387
+ ...faces.flatMap((v) => [...(v.checkedPaths ?? []), ...(v.checkedSpellings ?? []), ...(v.outOfRootPaths ?? [])]),
1388
+ ...named.checked,
1389
+ ...named.paths,
1390
+ ]),
1391
+ ];
1392
+ const folded = (ps) => new Set(ps.map((p) => normalizeAbsPathLexically(p)));
1393
+ const answered = {
1394
+ containment: folded([...faces.flatMap((v) => v.outOfRootPaths ?? []), ...named.paths]),
1395
+ deny: folded([...faces.flatMap((v) => (v.readDeniedPath !== undefined ? [v.readDeniedPath] : [])), ...(named.denied !== undefined ? [named.denied.path] : [])]),
1396
+ };
1397
+ const patterns = [...new Set([...faces.flatMap((v) => [...(v.undecidedPaths ?? []), ...(v.undecidedSpellings ?? [])]), ...named.undecided])];
1398
+ return await canonicalEscapeRefusal(canonicalBoundaryFor(resolvedBoundary.roots), { literal: literalAll, patterns }, resolvedBoundary.roots, face ?? FULL_SHELL_FACE_ESCAPE_PROSE, { deny: resolvedBoundary.denyMatch, containment: containmentAsked, denyAnswered: answered.deny, containmentAnswered: answered.containment }, signal);
1399
+ };
1400
+ }
1334
1401
  async function canonicalEscapeRefusal(canonical, candidates, readRoots, face, ask, signal) {
1335
1402
  const resolved = await canonical.escapesAfterResolution(candidates, ask, signal);
1336
1403
  if (resolved.denied !== undefined) {
1337
- return readDenyRefusal(resolved.denied.path, resolved.denied.pattern);
1404
+ return readDenyRefusal(resolved.denied.path, resolved.denied.pattern, face.tool);
1338
1405
  }
1339
1406
  if (resolved.unverifiable !== undefined) {
1340
- return readContainmentRefusal(`Error (Bash): ${resolved.unverifiable}, so this command cannot be confirmed to read only inside the allowed directories for this session: ${readRoots.join(", ")}. ` +
1407
+ return readContainmentRefusal(`Error (${face.tool}): ${resolved.unverifiable}, so this command cannot be confirmed to read only inside the allowed directories for this session: ${readRoots.join(", ")}. ` +
1341
1408
  face.note);
1342
1409
  }
1343
1410
  if (resolved.escaping.length > 0) {
1344
1411
  const quoted = resolved.escaping.map((p) => `"${p}"`).join(", ");
1345
- return readContainmentRefusal(`Error (Bash): ${face.subject} resolves through a symlink to ${quoted}, outside the allowed directories for this session: ${readRoots.join(", ")}${face.verdictPhrase}. ` +
1412
+ return readContainmentRefusal(`Error (${face.tool}): ${face.subject} resolves through a symlink to ${quoted}, outside the allowed directories for this session: ${readRoots.join(", ")}${face.verdictPhrase}. ` +
1346
1413
  face.note, resolved.escaping);
1347
1414
  }
1348
1415
  return undefined;
@@ -1352,22 +1419,24 @@ const DENY_REFUSAL_NOTE = "The read boundary judges this command's written spell
1352
1419
  function readContainmentRefusal(content, paths = []) {
1353
1420
  return errorResult(content, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths });
1354
1421
  }
1355
- function readDenyRefusal(target, pattern) {
1356
- const content = `Error (Bash): reading "${target}" is refused: the target matches the sensitive-path ` +
1357
- `read deny list (pattern "${pattern}"). This list guards credential-class paths and ` +
1358
- `applies regardless of the containment roots. ${DENY_REFUSAL_NOTE}`;
1422
+ function readDenyRefusal(target, pattern, tool = "Bash") {
1423
+ const content = `Error (${tool}): reading "${target}" is refused: the target matches ${sensitivePathDenyList(pattern)}. ` +
1424
+ `${SENSITIVE_PATH_DENY_SCOPE} ${DENY_REFUSAL_NOTE}`;
1359
1425
  return errorResult(content, violationDetails({ code: "read_path_denied", message: content, target, pattern }));
1360
1426
  }
1361
1427
  const READONLY_FACE_ESCAPE_PROSE = {
1428
+ tool: "Bash",
1362
1429
  subject: "a path this command reads",
1363
1430
  verdictPhrase: ` ${NOT_AUTO_ALLOWED}`,
1364
1431
  note: BASH_READONLY_CONFINEMENT_NOTE,
1365
1432
  };
1366
1433
  const FULL_SHELL_FACE_ESCAPE_PROSE = {
1434
+ tool: "Bash",
1367
1435
  subject: "a path this command names",
1368
1436
  verdictPhrase: "",
1369
1437
  note: "The read boundary judged this command's written spelling before the call was approved, and execution is past the approval step — a target reached only through symlink resolution is refused here rather than escalated.",
1370
1438
  };
1439
+ export const MONITOR_ESCAPE_PROSE = { ...FULL_SHELL_FACE_ESCAPE_PROSE, tool: "Monitor" };
1371
1440
  export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
1372
1441
  const timeoutCaps = resolveBashTimeoutCaps(opts);
1373
1442
  const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
@@ -1439,7 +1508,7 @@ export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
1439
1508
  return readContainmentRefusal(`Error (Bash): ${boundary.reason}. ${BASH_READONLY_CONFINEMENT_NOTE}`, boundary.outOfRootPaths ?? []);
1440
1509
  }
1441
1510
  }
1442
- const escape = await canonicalEscapeRefusal(canonicalBoundary, { literal: [...(boundary.checkedPaths ?? []), ...(boundary.checkedSpellings ?? []), ...releasedByGrant], patterns: [...(boundary.undecidedPaths ?? []), ...(boundary.undecidedSpellings ?? [])] }, readRoots, READONLY_FACE_ESCAPE_PROSE, { deny: denySeat.denyMatch, containment: true }, ctx.signal);
1511
+ const escape = await canonicalEscapeRefusal(canonicalBoundary, { literal: [...(boundary.checkedPaths ?? []), ...(boundary.checkedSpellings ?? []), ...releasedByGrant], patterns: [...(boundary.undecidedPaths ?? []), ...(boundary.undecidedSpellings ?? [])] }, readRoots, READONLY_FACE_ESCAPE_PROSE, { deny: denySeat.denyMatch, containment: true, denyAnswered: NOTHING_ANSWERED, containmentAnswered: NOTHING_ANSWERED }, ctx.signal);
1443
1512
  if (escape !== undefined)
1444
1513
  return escape;
1445
1514
  const readOnlyTick = opts?.onToolProgress === undefined ? undefined : (t) => describeSafely(opts.onToolProgress, { toolCallId: ctx.toolCallId, toolName: "Bash", ...t });
@@ -75,6 +75,20 @@ export interface ReadDenySeat {
75
75
  * ONCE per mount and reuse the result — the returned closure is then the function OBJECT every face mounted
76
76
  * from that matcher shares, which makes "the same judge" checkable rather than argued. */
77
77
  export declare function readDenySeat(matcher: ReadDenyMatcher | undefined): ReadDenySeat;
78
+ /** The ONE naming of the deny table and the pattern that matched. Every face composes its own SUBJECT and
79
+ * VERB in front of it and its own DISPOSITION behind it (the verb is not shared: one face names the
80
+ * target, another the REQUESTED SPELLING under a symlink alias). What must not fork is the naming — a
81
+ * reader comparing two surfaces about one finding must not be reading two findings. A LIST of patterns is
82
+ * the same naming in the plural, for the one finding that names no single row: a traversal that goes PAST
83
+ * the judge names the table IN FORCE, never a match it did not make. Four composers: the
84
+ * structured read faces' refusal (`safety.ts`), the shell faces' refusal card (`readDenyRefusal`), the
85
+ * lexical walk's verdict sentence (`bash-readonly-classifier.ts`), and the full shell's MANDATED ask card,
86
+ * which is approvable and so states the finding without claiming a refusal. NOT composers, each stating
87
+ * its own fact: the compaction lane's withheld-attachment HTML attribute, and the traversal notes'
88
+ * withheld-COUNT lines, which name the table and a pattern LIST. */
89
+ export declare function sensitivePathDenyList(pattern: string | readonly string[]): string;
90
+ /** The deny table's REACH: a deny row holds INSIDE the containment roots, so no root grant answers it. */
91
+ export declare const SENSITIVE_PATH_DENY_SCOPE = "This list guards credential-class paths and applies regardless of the containment roots.";
78
92
  /**
79
93
  * The closed set of built-in tiers. A tier is the deployment's selection unit
80
94
  * ({@link ReadDenyBuiltinConfig.tiers}); every {@link READ_FACE_BUILTIN_DENY_TABLE} row belongs to
@@ -4,6 +4,11 @@ export function readDenySeat(matcher) {
4
4
  return { denyEntries: entries };
5
5
  return { denyMatch: (p) => matcher.matchPath(p)?.pattern ?? null, denyEntries: entries };
6
6
  }
7
+ export function sensitivePathDenyList(pattern) {
8
+ const rows = typeof pattern === "string" ? [pattern] : pattern;
9
+ return `the sensitive-path read deny list (pattern${rows.length === 1 ? "" : "s"} ${rows.map((p) => `"${p}"`).join(", ")})`;
10
+ }
11
+ export const SENSITIVE_PATH_DENY_SCOPE = "This list guards credential-class paths and applies regardless of the containment roots.";
7
12
  export const READ_DENY_BUILTIN_TIERS = ["credentials", "shell-history", "browser", "wallet", "agent-config"];
8
13
  export const READ_FACE_BUILTIN_DENY_TABLE = [
9
14
  { pattern: ".ssh", tier: "credentials" },
@@ -1,3 +1,4 @@
1
+ import type { AssertAllKeysHandled } from "../../core/ask-origin.js";
1
2
  import type { ExecutionEnv } from "../../internal/harness-types.js";
2
3
  import { protectivePathTargetOf, type ToolCallLike } from "../../core/tool-registry.js";
3
4
  /**
@@ -320,7 +321,6 @@ export declare const WIN32_NAMESPACE_REFUSAL_TEXT: {
320
321
  };
321
322
  /** The fence over the text table: `never` while every refusal word has a row. */
322
323
  export type NamespaceRefusalTextCoversEveryWord = AssertAllKeysHandled<Exclude<Win32NamespaceRefusal, keyof typeof WIN32_NAMESPACE_REFUSAL_TEXT>>;
323
- type AssertAllKeysHandled<T extends never> = T;
324
324
  /**
325
325
  * The Win32 namespace screen — run on every model-spelled target BEFORE the family join, at every site
326
326
  * (the fs throat, the effective-target reader, the rule side, the glob splitter), so one spelling is
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { sensitivePathDenyList, SENSITIVE_PATH_DENY_SCOPE } from "./read-deny.js";
2
3
  import { sliceHeadSafe } from "../../core/surrogate-safe-slice.js";
3
4
  import { pathTargetValue, protectivePathTargetOf } from "../../core/tool-registry.js";
4
5
  import { withGateTimeout, DEFAULT_GATE_AWAIT_TIMEOUT_MS } from "../../core/gate-bounded-await.js";
@@ -468,7 +469,7 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
468
469
  ok: false,
469
470
  violation: {
470
471
  code: "read_path_denied",
471
- message: `reading "${path}" ${key !== path ? `(canonical target: "${key}") ` : ""}is refused: ${judged} the sensitive-path read deny list (pattern "${hit.pattern}"). This list guards credential-class paths and applies regardless of the containment roots.`,
472
+ message: `reading "${path}" ${key !== path ? `(canonical target: "${key}") ` : ""}is refused: ${judged} ${sensitivePathDenyList(hit.pattern)}. ${SENSITIVE_PATH_DENY_SCOPE}`,
472
473
  target: key,
473
474
  pattern: hit.pattern,
474
475
  },