@sema-agent/core 7.23.3 → 7.23.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.23.4 — 2026-09-19
4
+
5
+ ### Read boundary — one deny judge for every read face: the `bash_readonly` shell wires the same seat (#937; P1; design/199 v1 exception retired; @server @cli)
6
+ - **Before.** Under `handsReadOnly:true` (the mount that carries `bash_readonly` and no write faces at all) a wired `readDenyPatterns` table judged NOTHING on the shell face. Measured on `563c1e45` with `["**/.env","**/secrets/**"]`: `cat .env`, `cat .en?`, `grep -r <needle> .` and `grep -r <needle> secrets` each cost ZERO cards and each returned the guarded file's bytes — in the headless and the wired-approver deployment alike. The SAME deployment on the full Bash face mandates an ask for every one of them. The cause was one carve-out stated in three places (design/199 v1): this face's roots containment plus its reader allowlist were ruled to BE the deployment's read-safety declaration, so it wired no deny seat — neither on the boundary it classifies with nor on its own execute-time canonical recheck's `ask`.
7
+ - **After — the exception is retired, and it is one rule fewer.** Roots answer WHERE a command may read; the allowlist answers WHICH verb runs; neither answers WHICH FILES are off the table, and a deny row holds INSIDE the roots by definition — so the carve-out did not narrow the table on read-only mounts, it deleted it. The face now holds ONE boundary for BOTH of its judging moments (the pre-execution classify walk and the canonical recheck), assembled once per mount from the SAME compiled matcher `createHandsToolkit` hands Read/Grep/Glob/RepoMap. No read-only-face branch was added: the classify call takes the boundary it always took with its deny half filled in, and the recheck's `ask` is the same `{ deny, containment }` pair the full shell's leg asks. Rules: −1 face exception, −1 seat-wiring spelling (`readDenySeat`, the one adapter from a compiled matcher to the boundary's deny half, now used by `prepare-hands-readface` too), −1 card spelling (`readDenyRefusal`, the one `read_path_denied` mint for both passes).
8
+ - **Every finding lands on the rule this face already had.** It has no approval channel, so a demotion is a refusal: a judge MATCH refuses with the fs boundary's own `read_path_denied` card (target + pattern, whichever pass caught it — minting a deny finding as `readonly_out_of_root` would tell a consumer the read left the roots); a contents-disclosing recursive form under a wired judge (`recursiveReadPaths` — `grep -r/-R`, `diff -r`, and `rg`/`ag`/`ack`/`tar c` where a deployment allowlists them) is the face's UNESTABLISHABLE arm, the same finding as a pattern the expander cannot enumerate, and carries the same `readonly_out_of_root` code with the check's own sentence. Names-only traversals (`ls -R`, `du`, `find`, `tree`) mint no such finding and are unmoved.
9
+ - **Reach.** `TaskSpec.handsReadOnly` mounts + a non-empty deny set (task `readDenyPatterns`, deployment `readDenyPatterns`, or an active built-in tier). With NO deny set wired the judge is not mounted at all and both judging moments are byte-identical — including `grep -r`, whose demotion is minted only under a wired judge. In-root ordinary reads, in-root globs, the containment refusals (#775 / RB-413) and the full Bash face's answers are all unmoved; `test/backlog937-readonly-face-deny-judge.test.ts` pins the contrast column.
10
+ - **Ops.** A deployment that configures a read deny table AND mounts a read-only band (server's autonomy read-only lane; plan-mode research turns; verifier runs) will see its read-only shell REFUSE a read of a deny-listed file where it previously executed silently, and refuse `grep -r`-family commands while that table is in force. The refusals name the matched pattern (match arm) or the traversal roots the check took (traversal arm). No deny table ⇒ no change.
11
+ - **A second leak of the same shape, found by the adversarial round and confirmed by construction.** This face RESCUES a containment refusal when every out-of-root candidate canonicalizes back INSIDE the roots (the aliased-root case, `/tmp` → `/private/tmp`) — and the released operand rode `outOfRootPaths`, which the execute-time leg is handed neither as a literal nor as a pattern. So a proof about WHERE a read lands stood in for the question of WHICH file it is: `cat <outside>/alias/notes.txt`, where `notes.txt` is a symlink to a deny-listed file, ran at zero cards. The full shell's leg may drop what the boundary raised — there a PERSON answered it — and nobody is asked here, so the rescued candidates now go back through the same leg. The rescue itself is unchanged (pinned in both directions). The overflow-spool exemption is deliberately NOT collected: those paths are outside the roots by construction, and the containment half would refuse the engine's own recovery file.
12
+ - **The second grant site, from the adversarial round's round 2.** This face also grants itself a read of its OWN truncated-output file (the result trailer advertises `tail -c … <file>`), and that grant answered containment only. A deployment whose deny table names the engine's recovery spelling had a route out of the boundary: an out-of-root operand with an innocent name whose canonical target is the spool file clears the lexical table, fails the root rescue, and passes the fence. Both grant sites now obey ONE rule — **a grant answers containment, never the deny half** — and the recovery route itself is unchanged wherever no row names it (pinned in both directions). Rules: −1 (the two grant arms had two different downstream obligations; now one).
13
+ - **One accepted over-fire, declared.** Released candidates enter the leg as LITERALS, and an operand carrying a bracket metacharacter is reported by the classifier as both a candidate and a pattern — so a real file literally named `[ab].txt` that links out of the roots is judged although bash would expand the word instead. Fail-CLOSED, and the same family as the over-fires this leg's scope note already declares. Subtracting the pattern spellings instead would drop the QUOTED occurrence of the same word, which is a real read, and fail open; provenance per OCCURRENCE rather than per de-duplicated path is what would answer both, and no consumer carries it today.
14
+ - **Precedence, stated honestly.** Deny outranks containment among the findings ONE pass has in hand; it is not a promise across passes. A command the lexical pass refuses on containment (or on the traversal arm) keeps that sentence even where a later canonical resolution would have found a deny row on some other operand. Every such command is refused either way; only the account differs.
15
+ - **Contract sentences changed:** `TaskSpec.readDenyPatterns` now names `bash_readonly` among the judged faces; `read-deny.ts`'s scope note says every read face rather than the structured ones; `BashReadonlyRootBoundary.denyMatch` / `CompoundReadonlyVerdict.recursiveReadPaths` / the `DENY_REFUSAL_NOTE` single-spelling note all retire their v1-ruling clauses. `CompoundReadonlyVerdict` gains `readDeniedPath` / `readDeniedPattern` (present-iff `readDenied`, the `outOfRootRead`/`outOfRootPaths` idiom) so a consumer names the target without re-parsing prose — additive, breaking 0.
16
+
3
17
  ## 7.23.3 — 2026-09-19
4
18
 
5
19
  ### Read boundary — an operand the shell EXPANDS is judged by the deployment's deny judge, at the one moment the expansion exists (#934; P1; execute-time recheck; @cli @server @client-core)
@@ -3,7 +3,7 @@ import { StubExecutionEnv } from "../stub-env.js";
3
3
  import { hasBackgroundShell } from "../background-shell.js";
4
4
  import { boundaryIsTheSandbox } from "../remote-env.js";
5
5
  import { defaultTaskRegistry } from "../task-registry.js";
6
- import { bashReadBoundaryProbe, bashReversibilityProbe, activeReadDenyBuiltinTiers, compileReadDeny, createHandsToolkit, deploymentFaceClampNotice, pdfModelCapabilitiesOf, inheritSessionReadFileState, resolveContainmentFace, seedReadFileStateFromContext, seedReadFileStateFromTranscript, } from "../../tools/fs/index.js";
6
+ import { bashReadBoundaryProbe, bashReversibilityProbe, activeReadDenyBuiltinTiers, compileReadDeny, readDenySeat, createHandsToolkit, deploymentFaceClampNotice, pdfModelCapabilitiesOf, inheritSessionReadFileState, resolveContainmentFace, seedReadFileStateFromContext, seedReadFileStateFromTranscript, } from "../../tools/fs/index.js";
7
7
  import { resolveKey, sha256 } from "../../tools/fs/safety.js";
8
8
  import { normalizeFileText } from "../../tools/fs/encoding.js";
9
9
  import { wholeFileRecordsFromTranscript } from "./session-file-state-replay.js";
@@ -327,16 +327,12 @@ export async function prepareHandsMount(input) {
327
327
  sessionSeats.reanchor(sessionId, readFileState, ownCommittedTailEntryId);
328
328
  }
329
329
  : undefined;
330
- const compiledDeny = readDenyMatcher;
331
- const denySeatJudge = compiledDeny !== undefined && compiledDeny.entries.length > 0
332
- ? (p) => compiledDeny.matchPath(p)?.pattern ?? null
333
- : undefined;
330
+ const denySeat = readDenySeat(readDenyMatcher);
334
331
  const shellReadBoundary = () => ({
335
332
  roots: [rootCanonical, ...additionalRootsCanonical, ...additionalReadRootsCanonical],
336
333
  ...(handsCwdRef?.current !== undefined ? { cwd: handsCwdRef.current } : {}),
337
334
  ...(executionEnv.homeDir !== undefined ? { homeDir: executionEnv.homeDir } : {}),
338
- ...(denySeatJudge !== undefined ? { denyMatch: denySeatJudge } : {}),
339
- denyEntries: (compiledDeny?.entries ?? []).map((e) => e.pattern),
335
+ ...denySeat,
340
336
  ...(resolvedReadFace !== undefined ? { face: resolvedReadFace } : {}),
341
337
  });
342
338
  const band = createHandsToolkit(executionEnv, readFileState, rootCanonical, {
@@ -784,7 +784,8 @@ export interface TaskSpec {
784
784
  * design/199 件B — TASK-layer ADDITIONS to the built-in sensitive-path READ deny set
785
785
  * ({@link import("../tools/fs/read-deny.js").READ_FACE_DEFAULT_DENY_ENTRIES}). Judged by the
786
786
  * structured read faces (Read/Grep/Glob/RepoMap, their traversals, the classify shell gate's
787
- * auto-allow probe and the compaction attachment reader) in BOTH containment modes. Add-only at
787
+ * auto-allow probe, the compaction attachment reader and the `bash_readonly` shell mounted by
788
+ * {@link handsReadOnly} — one deny judge for every read face) in BOTH containment modes. Add-only at
788
789
  * the task layer: entries here UNION with the active built-ins and the deployment's
789
790
  * {@link RunnerDeps.readDenyPatterns}; `[]` ≡ absent (union identity); NOTHING at the task layer
790
791
  * can remove a built-in — the one removal channel is the DEPLOYMENT's built-in configuration
@@ -272,28 +272,24 @@ export interface BashReadonlyRootBoundary {
272
272
  * and demotes (with no out-of-root signal — the classifier does not know where it points, which is a
273
273
  * different verdict from knowing it points outside). */
274
274
  homeDir?: string;
275
- /** The sensitive-path read deny judge over LEXICALLY RESOLVED operands: a hit
276
- * demotes the command (ask, never auto-allow), independently of the roots — in-root operands are
277
- * judged too. Returns the matched pattern, or null. TWO named residuals, both inherited from this
278
- * classifier's declared purity (synchronous, zero I/O — the same scope stated for the
279
- * containment half): ① operand TARGET matching only — no ancestor intersection: the judge sees
280
- * the operand's own resolved spelling, never its subtree. The RECURSIVE-reach half of that
281
- * residual has since been narrowed: a listed recursive/expanding verb's path
282
- * operand under this wired seat rides {@link CompoundReadonlyVerdict.recursiveReadPaths}
283
- * (⊂ undecidedPaths), so `grep -r x ~/` no longer auto-allows — it demotes to ask through the
284
- * undecided contract. What REMAINS of ① is the form table's stated open set (see the KNOWN OPEN
285
- * SET note at {@link RECURSIVE_READ_FORMS}: a traversal verb absent there that a deployment
286
- * allowlists classifies by its ordinary form and can auto-run a traversal unjudged);
287
- * ② LEXICAL only HERE — an in-root symlink whose target is a
288
- * guarded path reads as its innocent spelling at this seat, exactly as it does for the containment half,
289
- * because the classify auto-allow lane has no I/O seat by contract. The CANONICAL view is judged by the
290
- * full shell's execute-time recheck, which asks THIS judge (the same function object, forwarded) where
291
- * each candidate really lands — a `checkedPaths` literal and a `undecidedPaths` pattern's expansions
292
- * alike. The `bash_readonly` face wires no deny seat at all, so its own recheck carries nothing. The structured read faces judge BOTH
293
- * views (canonical + lexical); what this seat is and is not is stated once, at
294
- * `shell.read_boundary.friction_floor`. The `bash_readonly` face deliberately does NOT wire this seat (its
295
- * roots containment + command allowlist double gate is the deployment's own read-safety
296
- * declaration — v1 ruling). */
275
+ /** The sensitive-path read deny judge over LEXICALLY RESOLVED operands: a hit demotes the command (ask,
276
+ * never auto-allow), independently of the roots — in-root operands are judged too. Returns the matched
277
+ * pattern, or null. TWO named residuals, both inherited from this classifier's declared purity
278
+ * (synchronous, zero I/O — the same scope stated for the containment half): ① operand TARGET matching
279
+ * only, no ancestor intersection: the judge sees the operand's own resolved spelling, never its subtree.
280
+ * The RECURSIVE-reach half of ① is narrowed — a listed recursive verb's path operand under this wired
281
+ * seat rides {@link CompoundReadonlyVerdict.recursiveReadPaths} (⊂ undecidedPaths), so `grep -r x ~/` no
282
+ * longer auto-allows; what REMAINS is the form table's stated open set (the KNOWN OPEN SET note at
283
+ * {@link RECURSIVE_READ_FORMS}: a traversal verb absent there that a deployment allowlists classifies by
284
+ * its ordinary form and can auto-run a traversal unjudged). ② LEXICAL only HERE — an in-root symlink
285
+ * whose target is guarded reads as its innocent spelling at this seat, as it does for the containment
286
+ * half, because the classify auto-allow lane has no I/O seat by contract; the CANONICAL view is judged by
287
+ * an execute-time recheck that asks THIS judge (the same function object, forwarded) where each candidate
288
+ * really lands, on a `checkedPaths` literal and a pattern's expansions alike.
289
+ * EVERY read face wires this one seat, the `bash_readonly` shell included, at BOTH of its judging
290
+ * moments — roots answer WHERE a command may read and the allowlist WHICH verb runs, neither answers
291
+ * WHICH FILES are off the table. What this seat is and is not is stated once, at
292
+ * `shell.read_boundary.friction_floor`. */
297
293
  denyMatch?: (resolvedPath: string) => string | null;
298
294
  /** What the wired {@link denyMatch} judge would apply — the ACTIVE deny patterns. Read for ONE question:
299
295
  * is there anything for a traversal to defeat? EMPTY ⇒ the judge refuses nothing, so a walk under it
@@ -333,6 +329,10 @@ export interface CompoundReadonlyVerdict {
333
329
  * reads it to mint `mandated` — a stored allow rule or the read-only shell arm may retire a classify-
334
330
  * tier question, never one the read face itself asked. Structural sibling of `outOfRootRead`. */
335
331
  readDenied?: true;
332
+ /** The matched operand (resolved spelling) and the pattern — present iff {@link readDenied} (the
333
+ * `outOfRootRead`/`outOfRootPaths` idiom), so a refusal CARD names the target without parsing prose. */
334
+ readDeniedPath?: string;
335
+ readDeniedPattern?: string;
336
336
  /**
337
337
  * The paths this scan resolved INSIDE the roots (de-duplicated, first-seen order), i.e. the
338
338
  * candidates a caller holding a filesystem should re-check with symlinks resolved. Absent when there
@@ -419,20 +419,19 @@ export interface CompoundReadonlyVerdict {
419
419
  /**
420
420
  * Operands of a RECURSIVE/EXPANDING read form (`grep -r`, `ls -R`, `du`, … — see
421
421
  * {@link RECURSIVE_READ_FORMS}) judged with a {@link BashReadonlyRootBoundary.denyMatch} seat wired.
422
- * The deny judge sees only the operand's own resolved spelling, but a recursive verb reads the
423
- * operand's whole SUBTREE — `grep -r x /home/user` touches `/home/user/.ssh/*` while the judged
424
- * spelling `/home/user` matches no deny pattern. The traversal's reach set is therefore not covered
425
- * by the lexical check at all, and with zero I/O "provably not a directory" does not exist — so
426
- * every such operand is UNDECIDED (no directory guessing, no bounded pre-check: both would be a
427
- * false "resolved, inside" of exactly the kind {@link undecidedPaths} exists to prevent).
422
+ * The judge sees only the operand's own resolved spelling, but a recursive verb reads the operand's whole
423
+ * SUBTREE — `grep -r x /home/user` touches `/home/user/.ssh/*` while the judged spelling matches nothing.
424
+ * The traversal's reach set is therefore not covered by the lexical check at all, and with zero I/O
425
+ * "provably not a directory" does not exist — so every such operand is UNDECIDED (no directory guessing,
426
+ * no bounded pre-check: both would be a false "resolved, inside" of exactly the kind
427
+ * {@link undecidedPaths} exists to prevent).
428
428
  *
429
- * Subset of {@link undecidedPaths} (same consumer contract: ask, never auto-allow); carried
430
- * separately so a consumer minting prose can name the recursive-reach cause rather than the glob
431
- * one. Minted ONLY when `denyMatch` is wired: without a deny judge there is nothing the traversal
432
- * bypasses — the containment half already judges the operand itself, and its lexical residuals are
433
- * recorded on {@link checkedPaths}. The `bash_readonly` face never wires `denyMatch` (v1 ruling,
434
- * see that seat's note), so this field never appears there and its expand-and-verify execution
435
- * path is unchanged.
429
+ * Subset of {@link undecidedPaths} (same consumer contract: ask, never auto-allow); carried separately so
430
+ * a consumer minting prose can name the recursive-reach cause rather than the glob one. Minted ONLY when
431
+ * `denyMatch` is wired: without a judge there is nothing the traversal bypasses. EVERY face wiring the
432
+ * seat sees this field, the `bash_readonly` shell included, and answers it by its OWN rule for a demotion
433
+ * — that face has no approval channel, so what it cannot establish it REFUSES (an unbounded traversal is
434
+ * the same "the read set is not in the text" finding as a pattern the expander cannot enumerate).
436
435
  */
437
436
  recursiveReadPaths?: readonly string[];
438
437
  /**
@@ -2171,6 +2171,8 @@ function evaluateReadBoundary(foldedSegments, boundary, mintGrantCandidate, reac
2171
2171
  return {
2172
2172
  reason: `a command operand resolves to "${finding.path}", which matches the sensitive-path read deny list (pattern "${denied}") ${NOT_AUTO_ALLOWED}`,
2173
2173
  readDenied: true,
2174
+ readDeniedPath: finding.path,
2175
+ readDeniedPattern: denied,
2174
2176
  };
2175
2177
  }
2176
2178
  if (finding.kind === "inside") {
@@ -4,6 +4,7 @@ import { type CwdRef } from "./fs-shared.js";
4
4
  import type { ReversibilityVerdict } from "../../core/types.js";
5
5
  import { type BashReadonlyRootBoundary } from "./bash-readonly-classifier.js";
6
6
  import { type ShellScanDecline } from "./bash-program-position.js";
7
+ import { type ReadDenyMatcher } from "./read-deny.js";
7
8
  /**
8
9
  * A parsed-command classifier for the `bash` tool, exposed as a
9
10
  * `ToolSpec.reversibilityProbe`. A full shell is treated as egress+irreversible by DEFAULT; this probe is the
@@ -306,12 +307,17 @@ export declare function createBashTool(env: ExecutionEnv, rootCanonical: string,
306
307
  * read-only band), so an out-of-root read here is not something to escalate, it is outside what the tool
307
308
  * is. It is refused, fail-closed, before anything executes — including the conservative direction, where
308
309
  * the operand cannot be resolved statically (there is nothing to degrade to).
310
+ *
311
+ * The boundary has TWO halves and this face wires both: roots answer WHERE a command may read and the
312
+ * allowlist WHICH verb runs, neither answers WHICH FILES are off the table (a deny row holds INSIDE the
313
+ * roots). The judge is the structured read faces' own, asked at both judging moments; every finding lands
314
+ * on the standing rule above — a demotion here is a refusal.
309
315
  */
310
316
  export declare function createBashReadonlyTool(env: ExecutionEnv, rootCanonical: string, allow: ReadonlySet<string>, opts?: {
311
317
  /** Same timeout-cap overrides the full `bash` leg takes — see
312
318
  * {@link import("./index.js").HandsToolkitOptions.bashDefaultTimeoutMs} /
313
- * {@link import("./index.js").HandsToolkitOptions.bashMaxTimeoutMs} (类修完整域: both foreground
314
- * legs share runShell's clamp, so both must share the configurable resolution). */
319
+ * {@link import("./index.js").HandsToolkitOptions.bashMaxTimeoutMs} — both foreground legs share
320
+ * runShell's clamp, so both must share the configurable resolution. */
315
321
  bashDefaultTimeoutMs?: number;
316
322
  bashMaxTimeoutMs?: number;
317
323
  /** Structured sink for the timeout-cap discard announcements — same seat the full `bash` leg
@@ -321,6 +327,9 @@ export declare function createBashReadonlyTool(env: ExecutionEnv, rootCanonical:
321
327
  * canonical). A read this deployment already sanctions for Read/Grep/Glob is equally sanctioned
322
328
  * here — the two faces must not disagree about which directories exist for this session. */
323
329
  additionalRoots?: readonly string[];
330
+ /** The deployment's compiled read deny set — the SAME matcher object the structured read faces of this
331
+ * mount hold (one compile, one table). Absent ⇒ no deny judge, and both judging moments are unchanged. */
332
+ readDeny?: ReadDenyMatcher;
324
333
  /** The run's tool-progress sink — the SAME seat the full shell leg takes, for the same reason its
325
334
  * two neighbours above are shared: both foreground legs run through `runShell`, so a long command
326
335
  * must breathe on both or a person watching one of them is told nothing. This face has no detach
@@ -15,6 +15,7 @@ import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashTimeoutArgRefusal, bash
15
15
  import { PROBE_CAUSE_PATH_MAX, inlineUntrusted } from "../../core/untrusted-text.js";
16
16
  import { bashClassifyDefaultAllow, BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoopDetailed, classifyCompoundReadonlyDetailed, classifyOutOfRootReadGate, refusedCommandMayRead, classifySimpleCommandReadBoundary, } from "./bash-readonly-classifier.js";
17
17
  import { NOT_AUTO_ALLOWED, SHELL_SCAN_DECLINES } from "./bash-program-position.js";
18
+ import { readDenySeat } from "./read-deny.js";
18
19
  import { toolFace } from "../../core/tool-catalog-entries.js";
19
20
  import { thrownText } from "../../core/thrown-value.js";
20
21
  const RECURSIVE_CAUSE_MAX_PATHS = 3;
@@ -1333,24 +1334,30 @@ function createCanonicalReadBoundary(env, readRoots) {
1333
1334
  async function canonicalEscapeRefusal(canonical, candidates, readRoots, face, ask, signal) {
1334
1335
  const resolved = await canonical.escapesAfterResolution(candidates, ask, signal);
1335
1336
  if (resolved.denied !== undefined) {
1336
- const content = `Error (Bash): reading "${resolved.denied.path}" is refused: the target matches the sensitive-path ` +
1337
- `read deny list (pattern "${resolved.denied.pattern}"). This list guards credential-class paths and ` +
1338
- `applies regardless of the containment roots. ${DENY_REFUSAL_NOTE}`;
1339
- return errorResult(content, violationDetails({ code: "read_path_denied", message: content, target: resolved.denied.path, pattern: resolved.denied.pattern }));
1337
+ return readDenyRefusal(resolved.denied.path, resolved.denied.pattern);
1340
1338
  }
1341
1339
  if (resolved.unverifiable !== undefined) {
1342
- return errorResult(`Error (Bash): ${resolved.unverifiable}, so this command cannot be confirmed to read only inside the allowed directories for this session: ${readRoots.join(", ")}. ` +
1343
- face.note, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths: [] });
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(", ")}. ` +
1341
+ face.note);
1344
1342
  }
1345
1343
  if (resolved.escaping.length > 0) {
1346
1344
  const quoted = resolved.escaping.map((p) => `"${p}"`).join(", ");
1347
- return errorResult(`Error (Bash): ${face.subject} resolves through a symlink to ${quoted}, outside the allowed directories for this session: ${readRoots.join(", ")}${face.verdictPhrase}. ` +
1348
- face.note, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths: resolved.escaping });
1345
+ return readContainmentRefusal(`Error (Bash): ${face.subject} resolves through a symlink to ${quoted}, outside the allowed directories for this session: ${readRoots.join(", ")}${face.verdictPhrase}. ` +
1346
+ face.note, resolved.escaping);
1349
1347
  }
1350
1348
  return undefined;
1351
1349
  }
1352
- const DENY_REFUSAL_NOTE = "The read boundary judged this command's written spelling before the call was approved; what the shell " +
1353
- "would actually read was established only at execution, and a deny-listed read is refused here rather than escalated.";
1350
+ const DENY_REFUSAL_NOTE = "The read boundary judges this command's written spelling and, where a filesystem can be consulted, the " +
1351
+ "paths it would actually read; a deny-listed read is refused here rather than escalated.";
1352
+ function readContainmentRefusal(content, paths = []) {
1353
+ return errorResult(content, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths });
1354
+ }
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}`;
1359
+ return errorResult(content, violationDetails({ code: "read_path_denied", message: content, target, pattern }));
1360
+ }
1354
1361
  const READONLY_FACE_ESCAPE_PROSE = {
1355
1362
  subject: "a path this command reads",
1356
1363
  verdictPhrase: ` ${NOT_AUTO_ALLOWED}`,
@@ -1366,6 +1373,8 @@ export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
1366
1373
  const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
1367
1374
  const sample = [...allow].slice(0, 6).join(", ");
1368
1375
  const readRoots = [rootCanonical, ...(opts?.additionalRoots ?? [])];
1376
+ const denySeat = readDenySeat(opts?.readDeny);
1377
+ const readBoundary = { roots: readRoots, cwd: rootCanonical, ...denySeat };
1369
1378
  const overflowSpoolFence = createShellOverflowSpoolFence(env);
1370
1379
  const readsOnlyEngineOverflowSpool = async (verdict) => {
1371
1380
  if (verdict.outOfRootRead !== true)
@@ -1377,6 +1386,9 @@ export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
1377
1386
  return false;
1378
1387
  for (const p of [...paths, ...(verdict.outOfRootSpellings ?? [])]) {
1379
1388
  const real = await canonicalBoundary.canonicalOf(p);
1389
+ for (const view of [real, p])
1390
+ if (view !== undefined && denySeat.denyMatch?.(normalizeAbsPathLexically(view)) != null)
1391
+ return false;
1380
1392
  if (!(await overflowSpoolFence(real ?? p)))
1381
1393
  return false;
1382
1394
  }
@@ -1404,17 +1416,30 @@ export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
1404
1416
  const reason = coarseReadonlyCheck(command, allow);
1405
1417
  if (reason)
1406
1418
  return errorResult(`Error (Bash): ${reason}`);
1407
- const boundary = classifySimpleCommandReadBoundary(command, { roots: readRoots, cwd: rootCanonical });
1419
+ const boundary = classifySimpleCommandReadBoundary(command, readBoundary);
1420
+ let releasedByGrant = [];
1421
+ if (boundary.readDeniedPath !== undefined && boundary.readDeniedPattern !== undefined)
1422
+ return readDenyRefusal(boundary.readDeniedPath, boundary.readDeniedPattern);
1423
+ const traversal = boundary.recursiveReadPaths ?? [];
1424
+ if (traversal.length > 0) {
1425
+ const shown = traversal.slice(0, RECURSIVE_CAUSE_MAX_PATHS);
1426
+ return readContainmentRefusal(`Error (Bash): this lexical check cannot bound what a recursive read form in this command traverses, and a deny list is in force — ` +
1427
+ `${traversal.length} path(s) taken as traversal roots: ${shown.join(", ")}${traversal.length > shown.length ? `, and ${traversal.length - shown.length} more` : ""}. ` +
1428
+ `Use the Grep tool to search a directory tree — it applies the same deny list to every file it reads. ` +
1429
+ `${BASH_READONLY_CONFINEMENT_NOTE}`);
1430
+ }
1408
1431
  if (boundary.reason !== undefined) {
1409
1432
  const rescueCandidates = [...(boundary.outOfRootPaths ?? []), ...(boundary.outOfRootSpellings ?? [])];
1410
1433
  const rescued = boundary.outOfRootRead === true &&
1411
1434
  (boundary.unresolvedOperands ?? []).length === 0 &&
1412
1435
  (await canonicalBoundary.allResolveInside(rescueCandidates, ctx.signal));
1436
+ if (rescued)
1437
+ releasedByGrant = rescueCandidates;
1413
1438
  if (!rescued && !(await readsOnlyEngineOverflowSpool(boundary))) {
1414
- return errorResult(`Error (Bash): ${boundary.reason}. ${BASH_READONLY_CONFINEMENT_NOTE}`, { type: "readonly_out_of_root", code: "readonly_out_of_root", paths: boundary.outOfRootPaths ?? [] });
1439
+ return readContainmentRefusal(`Error (Bash): ${boundary.reason}. ${BASH_READONLY_CONFINEMENT_NOTE}`, boundary.outOfRootPaths ?? []);
1415
1440
  }
1416
1441
  }
1417
- const escape = await canonicalEscapeRefusal(canonicalBoundary, { literal: [...(boundary.checkedPaths ?? []), ...(boundary.checkedSpellings ?? [])], patterns: [...(boundary.undecidedPaths ?? []), ...(boundary.undecidedSpellings ?? [])] }, readRoots, READONLY_FACE_ESCAPE_PROSE, { deny: undefined, containment: true }, ctx.signal);
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);
1418
1443
  if (escape !== undefined)
1419
1444
  return escape;
1420
1445
  const readOnlyTick = opts?.onToolProgress === undefined ? undefined : (t) => describeSafely(opts.onToolProgress, { toolCallId: ctx.toolCallId, toolName: "Bash", ...t });
@@ -46,7 +46,8 @@ export interface HandsToolkitOptions {
46
46
  * (see `createBashTool`'s `readBoundary`). The caller passes the SAME seat it gives the gate's shell
47
47
  * probes, so the boundary that judges a command's written spelling before approval and the leg that
48
48
  * re-judges its symlink-resolved targets before execution are one declaration. Ignored in `readOnly`
49
- * mode — `bash_readonly` builds its own from the roots it is confined to.
49
+ * mode — `bash_readonly` builds its own: containment from the roots it is confined to, DENY from the same
50
+ * compiled table this band hands the structured read faces.
50
51
  *
51
52
  * ABSENT ⇒ the leg does not run and costs no I/O: with no boundary declared there is no "outside" for
52
53
  * a read to be outside of, which is the same reading the probe seats take of an absent boundary. It
@@ -72,6 +72,7 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
72
72
  ...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
73
73
  ...(opts.onNotice !== undefined ? { onNotice: opts.onNotice } : {}),
74
74
  ...(readFaceRoots !== undefined ? { additionalRoots: readFaceRoots } : {}),
75
+ readDeny,
75
76
  ...(opts.onToolProgress !== undefined ? { onToolProgress: opts.onToolProgress } : {}),
76
77
  })
77
78
  : createBashTool(env, rootCanonical, commitCoAuthor, cwdRef, {
@@ -61,6 +61,20 @@ export interface ReadDenyMatcher {
61
61
  * matching the deny list exist under the scope and were excluded). */
62
62
  readonly rgProbeGlobs: readonly ReadDenyRgGlob[];
63
63
  }
64
+ /** The DENY HALF of a shell read boundary, in the shape `BashReadonlyRootBoundary` declares it (structural:
65
+ * this module is the leaf every face depends on). `denyMatch` judges one LEXICALLY RESOLVED operand,
66
+ * returning the matched pattern or null; PRESENT-IFF there is something to judge. */
67
+ export interface ReadDenySeat {
68
+ denyMatch?: (resolvedPath: string) => string | null;
69
+ denyEntries: readonly string[];
70
+ }
71
+ /** The ONE adapter from a compiled matcher to that seat, so every face mounts it the SAME way and the "wire
72
+ * the judge only when it has something to judge" rule is spelled once. That rule is load-bearing: the
73
+ * seat's PRESENCE is itself read as a fact (the recursive-read demotion mints on "a deny judge is wired" =
74
+ * the check a traversal would outflank is in place), so a judge over an EMPTY set must not be wired. Call
75
+ * ONCE per mount and reuse the result — the returned closure is then the function OBJECT every face mounted
76
+ * from that matcher shares, which makes "the same judge" checkable rather than argued. */
77
+ export declare function readDenySeat(matcher: ReadDenyMatcher | undefined): ReadDenySeat;
64
78
  /**
65
79
  * The closed set of built-in tiers. A tier is the deployment's selection unit
66
80
  * ({@link ReadDenyBuiltinConfig.tiers}); every {@link READ_FACE_BUILTIN_DENY_TABLE} row belongs to
@@ -1,3 +1,9 @@
1
+ export function readDenySeat(matcher) {
2
+ const entries = (matcher?.entries ?? []).map((e) => e.pattern);
3
+ if (matcher === undefined || entries.length === 0)
4
+ return { denyEntries: entries };
5
+ return { denyMatch: (p) => matcher.matchPath(p)?.pattern ?? null, denyEntries: entries };
6
+ }
1
7
  export const READ_DENY_BUILTIN_TIERS = ["credentials", "shell-history", "browser", "wallet", "agent-config"];
2
8
  export const READ_FACE_BUILTIN_DENY_TABLE = [
3
9
  { pattern: ".ssh", tier: "credentials" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.23.3",
3
+ "version": "7.23.4",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -0,0 +1,58 @@
1
+ {
2
+ "repo": "core",
3
+ "version": "7.23.4",
4
+ "from": "141b1687631b53cf2998a56c7541bb4aab5ca9af",
5
+ "to": "v7.23.4",
6
+ "added": [],
7
+ "removed": [],
8
+ "changed": [],
9
+ "narrowed": [
10
+ {
11
+ "id": "F1",
12
+ "source": "behaviorFlips.summary",
13
+ "text": "NARROWING, the `bash_readonly` read-only shell face (`handsReadOnly`), deployments with a deny judge wired (#937 = sema-internal #67, P1; stock since design/199 — measured identical on 7.22.0 and 7.23.3): ONE deny judge for every read face. The v1 exception that kept `denyMatch` off the read-only shell face is retired: `readDenySeat()` (`src/tools/fs/read-deny.ts`) is the single adapter from the compiled deny set to a boundary's deny half, mounted by the structured read faces and now by `bash_readonly` at both of its judging moments — the lexical verdict and the execute-time recheck (`src/tools/fs/fs-bash.ts`, `{ deny: denySeat.denyMatch, containment: true }`). One mint for the deny refusal (`readDenyRefusal`) and one for the containment refusal (`readContainmentRefusal`). A containment GRANT (root rescue, overflow-spool recovery) answers containment only, never deny (codex r1/r2). Rules −4. WHO NEEDS IT: a deployment that mounts the read-only shell (server autonomy read-only tier) AND configures `readDenyPatterns` / `READ_DENY_BUILTIN_TIERS` — until now `cat .env` ran there at zero cards with the bytes returned. WHO IS AFFECTED: (a) on that face, a deny-listed read is REFUSED (`read_path_denied`, naming the pattern) — named spelling, glob, symlink target, and a recursive walk that would enter a deny row; (b) a recursive verb (`grep -r <needle> .`) on such a deployment is refused as a whole with the Grep tool named as the way out (KL-937b); (c) a bracket-named glob spelling may over-fire fail-closed (KL-937a). Deployments with no deny judge: byte-identical on this face; the full Bash face: unchanged (contrast column pinned)."
14
+ }
15
+ ],
16
+ "closedSetMembers": [],
17
+ "closedSetsUnreadable": [],
18
+ "typeKeys": [
19
+ {
20
+ "type": "CompoundReadonlyVerdict",
21
+ "at": [
22
+ "src/tools/fs/bash-readonly-classifier.ts:644"
23
+ ],
24
+ "added": [
25
+ "readDeniedPath",
26
+ "readDeniedPattern"
27
+ ],
28
+ "removed": [],
29
+ "breaking": false,
30
+ "criteriaCovered": false
31
+ }
32
+ ],
33
+ "declarationMoves": [],
34
+ "mintMoves": [],
35
+ "criteriaObserves": [
36
+ {
37
+ "kind": "taskEventField",
38
+ "name": "tool_end.isError",
39
+ "assertions": [
40
+ "G1.a",
41
+ "G1.b"
42
+ ]
43
+ }
44
+ ],
45
+ "consumersHint": {
46
+ "cli": [
47
+ "CompoundReadonlyVerdict"
48
+ ],
49
+ "server": [
50
+ "CompoundReadonlyVerdict"
51
+ ],
52
+ "sdk": [],
53
+ "note": "scanned cli@4e3b6980 server@866e273b sdk@0a6aeb7 — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
54
+ },
55
+ "breakingCount": 0,
56
+ "breakingItems": [],
57
+ "toResolvedAt": "2a5d3b7f6c6d633510d8866e95d66e7b2b85446a"
58
+ }