@sema-agent/core 7.23.2 → 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 +33 -1
- package/dist/core/runner/prepare-hands-readface.js +3 -7
- package/dist/core/task-spec.d.ts +2 -1
- package/dist/tools/fs/bash-readonly-classifier.d.ts +34 -33
- package/dist/tools/fs/bash-readonly-classifier.js +2 -0
- package/dist/tools/fs/fs-bash.d.ts +11 -2
- package/dist/tools/fs/fs-bash.js +97 -34
- package/dist/tools/fs/index.d.ts +2 -1
- package/dist/tools/fs/index.js +1 -0
- package/dist/tools/fs/read-deny.d.ts +14 -0
- package/dist/tools/fs/read-deny.js +6 -0
- package/package.json +1 -1
- package/wire-changes/core@7.23.3.json +25 -0
- package/wire-changes/core@7.23.4.json +58 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
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
|
+
|
|
17
|
+
## 7.23.3 — 2026-09-19
|
|
18
|
+
|
|
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)
|
|
20
|
+
- **Before.** Under `shellGate:"classify"` + `readFace:"roots"` with a deny table wired, `cat .en?` cost ZERO cards and returned `.env`'s bytes; so did `cat secrets*/app.txt` and `grep . secret?/app.txt`, in the headless and the wired-approver deployment alike. Three passes each declined, and each was right to: the classify walk judges an unexpanded glob on its UNEXPANDED spelling (`<root>/.en?` is on no deny table) and parks it as `undecidedPaths`; the read-only command tables then cleared that ordinary card (`cat` is a listed reader; the table knows nothing about paths); and the execute-time canonical recheck (#775) — the one pass that HOLDS a filesystem and does expand the pattern — asked only "does this land outside the roots?". **A deny-listed expansion is in-root by definition**, so the containment question can never be the one that catches it. Not a 7.23.1 regression: the same reading since 7.22.x.
|
|
21
|
+
- **After — one rule: an EXPANSION IS AN OPERAND.** The recheck leg hands every path it establishes to `BashReadonlyRootBoundary.denyMatch` — the SAME function object the approval-time walk holds, forwarded, never a second matcher — and asks it BEFORE containment, the order the walk itself applies (deny outranks; no root grant clears a deny row). Rules: ±0 judges, −1 drop. The expander's property-6 shortcut used to DELETE an in-root non-symlink match outright; what that shortcut buys is the `realpath` call, so the match is now handed over WITH the canonical location the enumeration already established, and the judge answers both questions on it at the same cost. The deny question is asked of the FOLDED view, because that is the form the walk decides on — `cat secrets/../sub/a.txt` reads `sub/a.txt` and is not refused.
|
|
22
|
+
- **Two views per path, and which is NEW is the whole of the change.** The operand's own spelling — already judged for a literal candidate, NEVER judged for an expansion the shell produces after the verdict — and the canonical TARGET, which no lexical pass can reach on either list. The second closes the deny seat's stated residual ② (an in-root symlink whose target is guarded: `cat notes.txt` → `<root>/.env` also disclosed at zero asks), which that seat's own contract already named this leg as the closer of. Judging the spelling of expansions ALONE would have been a second rule.
|
|
23
|
+
- **The same leak had a second site, and the face was the reason.** With `readFace:"open"` the leg exited before doing anything — a CONTAINMENT-shaped exit applied to the whole pass — so `cat .en?` and `cat notes.txt` disclosed the file at zero asks there too, on a face whose deny seat's own contract reads "the deny half above keeps judging in both faces" (the LITERAL `cat .env` was mandated there all along, which is what proves the floor is real). Which half still has a question is now one value the face decides member by member: containment OFF under open (an out-of-roots operand rides `checkedPaths` there as an ACCEPTED candidate; re-judging it would make the open face stricter than the closed one), deny ON in both. With neither question the leg does not run, so an open face with no deny table is answered exactly as before. Rules: −1 face special case. Both directions pinned — the deny cells AND the containment controls (an out-of-root read, plain and globbed, still runs under open with a deny table wired).
|
|
24
|
+
- **A third site, found by the adversarial round and confirmed by construction.** The expander had one more containment shortcut: when a glob's LAST directory canonicalized OUTSIDE the roots, it pushed that DIRECTORY and discarded the matches. Containment reached the right verdict through the directory, which is why the shortcut survived — and the deny half never can, because a deny row is about the FILE. Measured on the parent: under the open face, `cat vendor/.en?` (an in-root directory symlink pointing out) and `cat <outside>/.en?` both disclosed the file at zero asks. The expander now hands over the matches themselves with whatever canonical facts it already holds; containment may use those as shortcuts, it may not replace the operands. Consequence, disclosed: the closed face's `readonly_out_of_root` refusal for such a glob now names the FILES (`<outside>/secret`, `<outside>/second`) instead of the directory — strictly more precise, and `test/backlog775-glob-canonical-recheck.test.ts` A1-a is re-pinned to it. A consumer counting `details.paths` entries for this refusal sees one row per escaping match rather than one per escaping directory.
|
|
25
|
+
- **Precedence, made one rule.** An expansion the leg cannot establish used to RETURN immediately, which ranked it above a deny row a later operand would have produced — the one order this leg is not free to choose. It is now recorded like every other objection and the three are ranked once, at the end: deny > unestablished > containment. Every such command was refused before and is refused now; only which sentence it carries can change.
|
|
26
|
+
- **Not the other repair.** Widening the classify seat's weak rung to `boundaryUnread` would turn every in-root glob into an ordinary card — a narrowing for every deployment, deny table or not. RB-474b's ordinary card, the classify seat's card shape and the off/bypass seats are untouched.
|
|
27
|
+
- **Reach.** Only a deployment with a deny judge wired (`readDenyPatterns`, or a selected built-in tier — default OFF) sees anything: without one the boundary carries no `denyMatch` and this leg's answer is byte-identical, on both read faces. `bash_readonly` is unaffected — that face wires no deny judge at all (v1 ruling). No second adjudication: a deny row the walk RAISED returns a verdict carrying no candidates, so an approver's yes still runs the command. An execute-time deny refusal is NOT clearable by a stored allow rule or by the auto classifier — it is past the approval step, which is what makes it stronger than the accepted reach #891 states for a retired ordinary card.
|
|
28
|
+
- **Stated reach on the open face.** The leg's fail-closed arm travels with the deny half: under `readFace:"open"` WITH a deny table, a candidate this leg cannot ESTABLISH — a glob nobody can expand (an uncompilable range, an unenumerable directory, an expansion past the enumeration caps), or a literal whose chain the resolver could neither answer nor rule absent — is now refused rather than run, because a location that cannot be established cannot be deny-judged either. The closed face always answered that way; an open face with no deny table does not run the leg at all and is unchanged. Measured in both directions.
|
|
29
|
+
- **Still open, stated rather than assumed gone.** A DECLARED isolated sandbox keeps the lexical-only leg (property 3): its `canonicalPath` is an RPC into a namespace this process cannot resolve, so no expansion can be established there and the deny half stays at the approval walk's unexpanded-spelling reading. The sandbox is that deployment's declared floor. A RECURSIVE verb's directory operand (`grep -r . <dir>`) is judged on the operand, never on its subtree — measured at one mandated ask with no disclosure, so it is asked rather than leaked, but what a yes then opens is the execution environment's floor.
|
|
30
|
+
- **headless.** Restrictive direction: a deny-listed read that reached a headless run through a glob, or through an in-root symlink, is now refused instead of executed. The refusal is the fs boundary's own card (`read_path_denied`, with `target` and the matched `pattern`), the same one `Read`/`Grep`/`Glob` mint — one rule, learned once.
|
|
31
|
+
- Pins: `test/backlog934-glob-expansion-deny-judge.test.ts` (3 shapes × 2 approver arms, judge-identity via a case-folded pattern, the symlink view, the wire card, and five controls: RB-474b unchanged, no deny table, #775 unflipped, two `..`-over-fire shapes, and the approved deny row).
|
|
32
|
+
|
|
1
33
|
## 7.23.2 — 2026-09-19
|
|
2
34
|
|
|
3
35
|
### Rendering a thrown value is one total rule — a hook could kill the engine with its choice of rejection value (#926; P1, failure face; @cli @server @client-core: text only, no wire key)
|
|
@@ -19,7 +51,7 @@
|
|
|
19
51
|
- **Difference from 7.23.0.** 7.23.0 refused these shapes with a MANDATE (no classifier, no stored rule, confirm-or-refuse). This version is strictly looser than that and strictly tighter than 7.23.1: an ordinary card an answerer may settle.
|
|
20
52
|
- **Reach.** Any deployment that wires a read boundary (`readFace` + roots, with or without `readDenyPatterns`) under `shellGate:"classify"`: a shell command whose operands the walk could not read — a stream-fed operand list, an unreadable program position — now reaches the person/classifier instead of being auto-allowed by the read-only tables. In-root spellings of the same shapes (`echo sub/x.txt | xargs grep .`) are in that set too: the boundary declined for them as well, and "in root" is a fact nobody established. Commands with no decline are untouched — `git status`, `ls -la` and every other read-only shape the tables clear keep costing zero questions.
|
|
21
53
|
- **headless.** Restrictive direction, and the reason this is a P1: a headless classify-tier run that executed these stream-fed reads with no approver now refuses them (an ask with no approver is a refusal). A deployment relying on that auto-allow should widen its roots, store an allow rule for the command, or wire an approver/classifier.
|
|
22
|
-
- **
|
|
54
|
+
- **The other half, measured here and FIXED in this same version by #934 below.** "The boundary could not read where this command reads" has a second family — an UNEXPANDED GLOB (`undecidedPaths`), a plain card by ruling (RB-474b) and still cleared by the read-only tables. Its containment half was already caught at execute time by the canonical recheck (#775); its DENY half was not, because that recheck judged roots only, so `cat .en?` under `readDenyPatterns:["**/.env"]` disclosed `.env` at zero asks on 7.23.1 and on this tree alike. This version does not change the CARD (RB-474b stands); it gives the execute-time recheck the boundary's own deny judge — see the #934 section.
|
|
23
55
|
- Pins: `test/backlog930-decline-not-read-only-clearable.test.ts` (14 shapes × 2 arms, + the C-R67 (B) surface, the sandbox arm with its own three-way control, the off seat's mandate, the read-only arm still alive, and the `xargs cat` / in-root controls).
|
|
24
56
|
|
|
25
57
|
## 7.23.1 — 2026-09-18
|
|
@@ -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
|
|
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
|
-
...
|
|
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, {
|
package/dist/core/task-spec.d.ts
CHANGED
|
@@ -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
|
|
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,26 +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
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* `shell.read_boundary.friction_floor`.
|
|
293
|
-
* roots containment + command allowlist double gate is the deployment's own read-safety
|
|
294
|
-
* 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`. */
|
|
295
293
|
denyMatch?: (resolvedPath: string) => string | null;
|
|
296
294
|
/** What the wired {@link denyMatch} judge would apply — the ACTIVE deny patterns. Read for ONE question:
|
|
297
295
|
* is there anything for a traversal to defeat? EMPTY ⇒ the judge refuses nothing, so a walk under it
|
|
@@ -331,6 +329,10 @@ export interface CompoundReadonlyVerdict {
|
|
|
331
329
|
* reads it to mint `mandated` — a stored allow rule or the read-only shell arm may retire a classify-
|
|
332
330
|
* tier question, never one the read face itself asked. Structural sibling of `outOfRootRead`. */
|
|
333
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;
|
|
334
336
|
/**
|
|
335
337
|
* The paths this scan resolved INSIDE the roots (de-duplicated, first-seen order), i.e. the
|
|
336
338
|
* candidates a caller holding a filesystem should re-check with symlinks resolved. Absent when there
|
|
@@ -417,20 +419,19 @@ export interface CompoundReadonlyVerdict {
|
|
|
417
419
|
/**
|
|
418
420
|
* Operands of a RECURSIVE/EXPANDING read form (`grep -r`, `ls -R`, `du`, … — see
|
|
419
421
|
* {@link RECURSIVE_READ_FORMS}) judged with a {@link BashReadonlyRootBoundary.denyMatch} seat wired.
|
|
420
|
-
* The
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
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).
|
|
426
428
|
*
|
|
427
|
-
* Subset of {@link undecidedPaths} (same consumer contract: ask, never auto-allow); carried
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
* 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).
|
|
434
435
|
*/
|
|
435
436
|
recursiveReadPaths?: readonly string[];
|
|
436
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}
|
|
314
|
-
*
|
|
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
|
package/dist/tools/fs/fs-bash.js
CHANGED
|
@@ -8,13 +8,14 @@ import { TASK_OUTPUT_TOOL_NAME, TASK_STOP_TOOL_NAME, TASK_OUTPUT_MISSING_ID_MESS
|
|
|
8
8
|
import { hasBackgroundShell } from "../../core/background-shell.js";
|
|
9
9
|
import { delimitUntrusted } from "../../core/untrusted-text.js";
|
|
10
10
|
import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
|
|
11
|
-
import { imageMagicMatches, normalizeAbsPathLexically, withinAnyRoot } from "./safety.js";
|
|
11
|
+
import { imageMagicMatches, normalizeAbsPathLexically, violationDetails, withinAnyRoot } from "./safety.js";
|
|
12
12
|
import { boundaryIsTheSandbox, canonicalPathIsAuthoritative, hasDestroy, isIsolated } from "../../core/remote-env.js";
|
|
13
13
|
import { ghRateLimitHint } from "./gh-rate-limit.js";
|
|
14
14
|
import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashTimeoutArgRefusal, bashTimeoutParamDescription, envErrorDetail, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, createShellOverflowSpoolFence, shellRecoveryHint, CWD_SENTINEL, stripCwdSentinel, BASH_READONLY_CONFINEMENT_NOTE, } from "./fs-shared.js";
|
|
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;
|
|
@@ -772,7 +773,10 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
|
|
|
772
773
|
};
|
|
773
774
|
const canonicalEscape = async (command, signal) => {
|
|
774
775
|
const resolvedBoundary = typeof taskOpts.readBoundary === "function" ? taskOpts.readBoundary() : taskOpts.readBoundary;
|
|
775
|
-
if (resolvedBoundary === undefined
|
|
776
|
+
if (resolvedBoundary === undefined)
|
|
777
|
+
return undefined;
|
|
778
|
+
const ask = { deny: resolvedBoundary.denyMatch, containment: resolvedBoundary.face !== "open" };
|
|
779
|
+
if (ask.deny === undefined && !ask.containment)
|
|
776
780
|
return undefined;
|
|
777
781
|
const faces = [
|
|
778
782
|
classifyCompoundReadonlyDetailed(command, recheckReaders, resolvedBoundary),
|
|
@@ -781,7 +785,7 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
|
|
|
781
785
|
const literal = [...new Set(faces.flatMap((v) => [...(v.checkedPaths ?? []), ...(v.checkedSpellings ?? [])]))];
|
|
782
786
|
const asked = new Set(faces.flatMap((v) => v.outOfRootPaths ?? []));
|
|
783
787
|
const patterns = [...new Set(faces.flatMap((v) => [...(v.undecidedPaths ?? []), ...(v.undecidedSpellings ?? [])]))].filter((p) => !asked.has(normalizeAbsPathLexically(p)));
|
|
784
|
-
return await canonicalEscapeRefusal(canonicalBoundaryFor(resolvedBoundary.roots), { literal, patterns }, resolvedBoundary.roots, FULL_SHELL_FACE_ESCAPE_PROSE, signal);
|
|
788
|
+
return await canonicalEscapeRefusal(canonicalBoundaryFor(resolvedBoundary.roots), { literal, patterns }, resolvedBoundary.roots, FULL_SHELL_FACE_ESCAPE_PROSE, ask, signal);
|
|
785
789
|
};
|
|
786
790
|
return defineTool({
|
|
787
791
|
name: "Bash",
|
|
@@ -1189,6 +1193,7 @@ function createCanonicalReadBoundary(env, readRoots) {
|
|
|
1189
1193
|
let frontier = [p.slice(0, segs[first].start).replace(/[\\/]+$/, "") || "/"];
|
|
1190
1194
|
let dirsUsed = 0;
|
|
1191
1195
|
let matches = 0;
|
|
1196
|
+
const canonicalOfMatch = new Map();
|
|
1192
1197
|
for (let j = first; j < segs.length; j++) {
|
|
1193
1198
|
const seg = segs[j].text;
|
|
1194
1199
|
if (!GLOB_METACHAR_RE.test(seg)) {
|
|
@@ -1222,40 +1227,48 @@ function createCanonicalReadBoundary(env, readRoots) {
|
|
|
1222
1227
|
if (re.test(dot))
|
|
1223
1228
|
next.push(joinCandidate(dir, dot));
|
|
1224
1229
|
}
|
|
1225
|
-
|
|
1226
|
-
if (isLast) {
|
|
1227
|
-
if (canonDir !== undefined && !withinAnyRoot(roots, canonDir.value)) {
|
|
1228
|
-
next.push(canonDir.value);
|
|
1229
|
-
continue;
|
|
1230
|
-
}
|
|
1231
|
-
dirInside = canonDir !== undefined;
|
|
1232
|
-
}
|
|
1230
|
+
const canonDirHere = isLast ? canonDir?.value : undefined;
|
|
1233
1231
|
for (const entry of listed.value) {
|
|
1234
1232
|
if (!globSegmentMatches(re, seg, entry.name))
|
|
1235
1233
|
continue;
|
|
1236
1234
|
if (++matches > MAX_GLOB_MATCHES) {
|
|
1237
1235
|
return { unverifiable: `"${p}" expands to more than ${MAX_GLOB_MATCHES} names, too many to enumerate to verify` };
|
|
1238
1236
|
}
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1237
|
+
const candidate = joinCandidate(dir, entry.name);
|
|
1238
|
+
if (canonDirHere !== undefined && entry.kind !== "symlink")
|
|
1239
|
+
canonicalOfMatch.set(candidate, joinCandidate(canonDirHere, entry.name));
|
|
1240
|
+
next.push(candidate);
|
|
1242
1241
|
}
|
|
1243
1242
|
}
|
|
1244
1243
|
frontier = next;
|
|
1245
1244
|
if (frontier.length === 0)
|
|
1246
|
-
return { paths: [] };
|
|
1245
|
+
return { paths: [], canonical: canonicalOfMatch };
|
|
1247
1246
|
}
|
|
1248
|
-
return { paths: frontier };
|
|
1247
|
+
return { paths: frontier, canonical: canonicalOfMatch };
|
|
1249
1248
|
};
|
|
1250
1249
|
return {
|
|
1251
|
-
escapesAfterResolution: async (paths, signal) => {
|
|
1250
|
+
escapesAfterResolution: async (paths, ask, signal) => {
|
|
1251
|
+
const deny = ask.deny;
|
|
1252
1252
|
if (lexicalOnly || (paths.literal.length === 0 && paths.patterns.length === 0))
|
|
1253
1253
|
return { escaping: [] };
|
|
1254
1254
|
const roots = await canonicalRoots(signal);
|
|
1255
1255
|
const escaping = [];
|
|
1256
1256
|
let unresolvable;
|
|
1257
|
-
|
|
1258
|
-
|
|
1257
|
+
let denied;
|
|
1258
|
+
const denyHit = (p) => {
|
|
1259
|
+
if (deny === undefined || denied !== undefined)
|
|
1260
|
+
return false;
|
|
1261
|
+
const folded = normalizeAbsPathLexically(p);
|
|
1262
|
+
const pattern = deny(folded);
|
|
1263
|
+
if (pattern === null)
|
|
1264
|
+
return false;
|
|
1265
|
+
denied = { path: folded, pattern };
|
|
1266
|
+
return true;
|
|
1267
|
+
};
|
|
1268
|
+
const judge = async (p, canonicalKnown) => {
|
|
1269
|
+
if (denyHit(p))
|
|
1270
|
+
return;
|
|
1271
|
+
const real = canonicalKnown !== undefined ? { value: canonicalKnown, exact: true } : await resolve(p, signal);
|
|
1259
1272
|
if (real === undefined) {
|
|
1260
1273
|
if (resolverIsAuthoritative)
|
|
1261
1274
|
unresolvable ??= `"${p}" cannot be resolved to verify where it reads`;
|
|
@@ -1265,18 +1278,36 @@ function createCanonicalReadBoundary(env, readRoots) {
|
|
|
1265
1278
|
unresolvable ??= `"${p}" cannot be resolved to verify where it reads`;
|
|
1266
1279
|
return;
|
|
1267
1280
|
}
|
|
1268
|
-
if (
|
|
1281
|
+
if (denyHit(real.value))
|
|
1282
|
+
return;
|
|
1283
|
+
if (ask.containment && !withinAnyRoot(roots, real.value) && !escaping.includes(real.value))
|
|
1269
1284
|
escaping.push(real.value);
|
|
1270
1285
|
};
|
|
1271
|
-
for (const p of paths.literal)
|
|
1286
|
+
for (const p of paths.literal) {
|
|
1287
|
+
if (denied !== undefined)
|
|
1288
|
+
break;
|
|
1272
1289
|
await judge(p);
|
|
1290
|
+
}
|
|
1273
1291
|
for (const p of paths.patterns) {
|
|
1292
|
+
if (denied !== undefined)
|
|
1293
|
+
break;
|
|
1274
1294
|
const expanded = await expandGlobCandidate(p, roots, signal);
|
|
1275
|
-
if (expanded !== undefined && "unverifiable" in expanded)
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1295
|
+
if (expanded !== undefined && "unverifiable" in expanded) {
|
|
1296
|
+
unresolvable ??= expanded.unverifiable;
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
if (expanded !== undefined && expanded.paths.length > 0) {
|
|
1300
|
+
for (const t of expanded.paths) {
|
|
1301
|
+
if (denied !== undefined)
|
|
1302
|
+
break;
|
|
1303
|
+
await judge(t, expanded.canonical.get(t));
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
else
|
|
1307
|
+
await judge(p);
|
|
1279
1308
|
}
|
|
1309
|
+
if (denied !== undefined)
|
|
1310
|
+
return { escaping, denied };
|
|
1280
1311
|
return unresolvable === undefined ? { escaping } : { escaping, unverifiable: unresolvable };
|
|
1281
1312
|
},
|
|
1282
1313
|
canonicalOf: async (path, signal) => {
|
|
@@ -1300,19 +1331,33 @@ function createCanonicalReadBoundary(env, readRoots) {
|
|
|
1300
1331
|
},
|
|
1301
1332
|
};
|
|
1302
1333
|
}
|
|
1303
|
-
async function canonicalEscapeRefusal(canonical, candidates, readRoots, face, signal) {
|
|
1304
|
-
const resolved = await canonical.escapesAfterResolution(candidates, signal);
|
|
1334
|
+
async function canonicalEscapeRefusal(canonical, candidates, readRoots, face, ask, signal) {
|
|
1335
|
+
const resolved = await canonical.escapesAfterResolution(candidates, ask, signal);
|
|
1336
|
+
if (resolved.denied !== undefined) {
|
|
1337
|
+
return readDenyRefusal(resolved.denied.path, resolved.denied.pattern);
|
|
1338
|
+
}
|
|
1305
1339
|
if (resolved.unverifiable !== undefined) {
|
|
1306
|
-
return
|
|
1307
|
-
face.note
|
|
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);
|
|
1308
1342
|
}
|
|
1309
1343
|
if (resolved.escaping.length > 0) {
|
|
1310
1344
|
const quoted = resolved.escaping.map((p) => `"${p}"`).join(", ");
|
|
1311
|
-
return
|
|
1312
|
-
face.note,
|
|
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);
|
|
1313
1347
|
}
|
|
1314
1348
|
return undefined;
|
|
1315
1349
|
}
|
|
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
|
+
}
|
|
1316
1361
|
const READONLY_FACE_ESCAPE_PROSE = {
|
|
1317
1362
|
subject: "a path this command reads",
|
|
1318
1363
|
verdictPhrase: ` ${NOT_AUTO_ALLOWED}`,
|
|
@@ -1328,6 +1373,8 @@ export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
|
|
|
1328
1373
|
const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
|
|
1329
1374
|
const sample = [...allow].slice(0, 6).join(", ");
|
|
1330
1375
|
const readRoots = [rootCanonical, ...(opts?.additionalRoots ?? [])];
|
|
1376
|
+
const denySeat = readDenySeat(opts?.readDeny);
|
|
1377
|
+
const readBoundary = { roots: readRoots, cwd: rootCanonical, ...denySeat };
|
|
1331
1378
|
const overflowSpoolFence = createShellOverflowSpoolFence(env);
|
|
1332
1379
|
const readsOnlyEngineOverflowSpool = async (verdict) => {
|
|
1333
1380
|
if (verdict.outOfRootRead !== true)
|
|
@@ -1339,6 +1386,9 @@ export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
|
|
|
1339
1386
|
return false;
|
|
1340
1387
|
for (const p of [...paths, ...(verdict.outOfRootSpellings ?? [])]) {
|
|
1341
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;
|
|
1342
1392
|
if (!(await overflowSpoolFence(real ?? p)))
|
|
1343
1393
|
return false;
|
|
1344
1394
|
}
|
|
@@ -1366,17 +1416,30 @@ export function createBashReadonlyTool(env, rootCanonical, allow, opts) {
|
|
|
1366
1416
|
const reason = coarseReadonlyCheck(command, allow);
|
|
1367
1417
|
if (reason)
|
|
1368
1418
|
return errorResult(`Error (Bash): ${reason}`);
|
|
1369
|
-
const boundary = classifySimpleCommandReadBoundary(command,
|
|
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
|
+
}
|
|
1370
1431
|
if (boundary.reason !== undefined) {
|
|
1371
1432
|
const rescueCandidates = [...(boundary.outOfRootPaths ?? []), ...(boundary.outOfRootSpellings ?? [])];
|
|
1372
1433
|
const rescued = boundary.outOfRootRead === true &&
|
|
1373
1434
|
(boundary.unresolvedOperands ?? []).length === 0 &&
|
|
1374
1435
|
(await canonicalBoundary.allResolveInside(rescueCandidates, ctx.signal));
|
|
1436
|
+
if (rescued)
|
|
1437
|
+
releasedByGrant = rescueCandidates;
|
|
1375
1438
|
if (!rescued && !(await readsOnlyEngineOverflowSpool(boundary))) {
|
|
1376
|
-
return
|
|
1439
|
+
return readContainmentRefusal(`Error (Bash): ${boundary.reason}. ${BASH_READONLY_CONFINEMENT_NOTE}`, boundary.outOfRootPaths ?? []);
|
|
1377
1440
|
}
|
|
1378
1441
|
}
|
|
1379
|
-
const escape = await canonicalEscapeRefusal(canonicalBoundary, { literal: [...(boundary.checkedPaths ?? []), ...(boundary.checkedSpellings ?? [])], patterns: [...(boundary.undecidedPaths ?? []), ...(boundary.undecidedSpellings ?? [])] }, readRoots, READONLY_FACE_ESCAPE_PROSE, 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);
|
|
1380
1443
|
if (escape !== undefined)
|
|
1381
1444
|
return escape;
|
|
1382
1445
|
const readOnlyTick = opts?.onToolProgress === undefined ? undefined : (t) => describeSafely(opts.onToolProgress, { toolCallId: ctx.toolCallId, toolName: "Bash", ...t });
|
package/dist/tools/fs/index.d.ts
CHANGED
|
@@ -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
|
package/dist/tools/fs/index.js
CHANGED
|
@@ -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
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"repo": "core",
|
|
3
|
+
"version": "7.23.3",
|
|
4
|
+
"from": "b48d37a658cfaa46695e76339021dc1226a9ba57",
|
|
5
|
+
"to": "v7.23.3",
|
|
6
|
+
"added": [],
|
|
7
|
+
"removed": [],
|
|
8
|
+
"changed": [],
|
|
9
|
+
"narrowed": [],
|
|
10
|
+
"closedSetMembers": [],
|
|
11
|
+
"closedSetsUnreadable": [],
|
|
12
|
+
"typeKeys": [],
|
|
13
|
+
"declarationMoves": [],
|
|
14
|
+
"mintMoves": [],
|
|
15
|
+
"criteriaObserves": [],
|
|
16
|
+
"consumersHint": {
|
|
17
|
+
"cli": [],
|
|
18
|
+
"server": [],
|
|
19
|
+
"sdk": [],
|
|
20
|
+
"note": "scanned cli@bef85633 server@1da4e102 sdk@0a6aeb7 — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
|
|
21
|
+
},
|
|
22
|
+
"breakingCount": 0,
|
|
23
|
+
"breakingItems": [],
|
|
24
|
+
"toResolvedAt": "912d7792e95edd36fb9b6bb68866661ac36f7f6a"
|
|
25
|
+
}
|
|
@@ -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
|
+
}
|