@sema-agent/core 7.38.0 → 7.38.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,54 @@
1
+ ## 7.38.2 — 2026-10-02
2
+
3
+ **Hard-breaking changes:** none.
4
+
5
+ **Closed sets:** none. **Package exports:** +0 / −0.
6
+
7
+ ### A JSONL tail that parses to an object is a record, newline or not (#1278; behavior (widen: the file backends' JSONL read; narrow: what the open-time repair discards; a failed append leaves no bytes); +0 exports) (@server @test)
8
+
9
+ - **Before (v7.38.1).** `readJsonlRecords` served nothing after a file's last newline, and `AppendLog` cut that tail away when it opened and again at the append door whenever the file had moved off a record boundary. A record that was whole on disk and lacked only its final `\n` was invisible to every replay and then physically removed. Every file store on the format shared it: session, mailbox, memory, workflow journal, and the shared-ledger stores (checkpoint, background agent, workflow run).
10
+ - **Now.** One function, `jsonlTextFormOf` (`src/stores/file/fs-atomic.ts`), judges a segment, and the three stations that must answer "is the unterminated tail a record" all ask it: the read, the open-time repair, the append door's boundary repair. A file on this format holds one JSON object per line and no strict prefix of a JSON object parses, so a tail that parses to an object is a whole record that lost only its newline: the read serves it; the open-time repair and the append door end its line (the bytes stay); nothing is disclosed (it is not damage). A tail that does not parse is a crash's partial write: not served, cut back to the last newline, silent (unchanged). A tail that parses to a non-object (number, string, boolean, `null`, array) is bytes the store never wrote: skipped and cut, and now disclosed once under `not-an-object` exactly as the same bytes with a newline are (it was silent). The consuming store's own floor (`judge`) applies to the tail like to any line. `truncateTornTail` is renamed `repairJsonlTail` and cuts by byte offset. And an append whose write fails (the write throws, or makes no progress) leaves no bytes of its own: `AppendLog.append` cuts the file back to where the write began, provided the file is exactly that size plus the bytes it wrote (a peer that appended after it owns what follows; a size that cannot be read, or a cut that fails, leaves the file for the next append's tail repair), then rethrows the write's own error. A caller that retries the record writes it once; without this, a failure that left every byte but the newline would, under the rule above, be kept and then written again by the retry.
11
+ - **Who is hurt.** A consumer that relied on an unterminated last line never being a record: it now reads one. No store in this package does (the replays read what a record carries, not how its line ended). On the operator side, a host that listens on a store's corrupt-read seat (`onCorruptRead`) hears one more `not-an-object` disclosure when a file's unterminated last segment parses to a non-object (a number, string, boolean, `null` or array), which was silent before; the same bytes with a newline already disclosed. A caller that relied on a failed append's partial bytes staying on disk: none can, the next append's tail repair cut them.
12
+ - **Compensation.** None needed; there is no switch for the old drop, because a whole record on disk is a record.
13
+ - **Known limit (KL-1278-a).** A whole object followed by a half one with no newline between (`{"a":1}{"b"`), or two whole objects with no newline between, is one segment that does not parse: it is cut whole, before and after, because recovering its first object would be parsing a prefix.
14
+ - **Known limit (KL-1278-b).** The rollback of a failed append has two preconditions: nobody appended after it, and the cut itself works. A peer that appended after our partial bytes leaves its line glued behind them, one unparseable interior line (disclosed `torn-json`, both pieces lost), as before; a failed cut (EIO) that leaves a whole-but-unterminated record is kept by the next open or append door, so a retry writes it twice.
15
+ - **Known limit (KL-1278-c).** A tail whose bytes are invalid UTF-8 inside a closed object (`{"id":"\xE4"}`) is decoded with U+FFFD like an interior line always was, so it is served and its bytes kept; a crash leaves a prefix, never that shape.
16
+ - **Known limit (KL-1278-d).** A non-object tail met first by the open-time repair or the append door, with no read before it, is cut without disclosure (`AppendLog` has no disclosure seat); every file store reads before it opens.
17
+ - Pins: `test/backlog1278-jsonl-tail-newline.test.ts` — ① a whole tail without its newline is read, silently; ② open ends its line; ③ an append after that open is its own line (byte-exact, three records); ④ a half tail is still cut (incl. one cut inside a multi-byte character, and a whole multi-byte tail); ⑤ non-object tails (`123` `[1]` `"x"` `null` `true`) disclosed once and cut, read identically with and without the newline; ⑥ empty / lone-newline / clean / blank-interior files change no byte; ⑧ a 3 MiB tail judged whole or cut; ⑨ a peer's whole unterminated record met by the append door is kept, a peer's half record is cut; ⑩ `{"a":1}{"b"` and two glued objects are cut whole; ⑪ the store's floor applies to the tail; ⑫ the present form of KL-1278-c; 9c a peer's non-object tail is cut by the append door; ⑬ a failed append leaves no bytes and the retry writes the record once (13 partial write then an error, 13b every byte but the newline then an error, 13d a short write then zero progress), 13c a peer's whole record landed before the error is not cut, 13e a peer appended after our partial bytes is not cut (present form of KL-1278-b). `test/file-checkpoint-store.test.ts` PIN 7 — a real checkpoint ledger hand-stripped of its final newline replays, and the next write does not glue. `test/defectscan-truncation-invariant-surface.test.ts` — the exempt entry for the renamed function removed.
18
+
19
+ ## 7.38.1 — 2026-10-02
20
+
21
+ **Hard-breaking changes:** none.
22
+
23
+ **Closed sets:** none. **Package exports:** +0 / −0.
24
+
25
+ ### Home-anchored read-deny rows are placed under the account home as well as the child's `HOME` (#1244; behavior (tighten: the read deny table); +0 exports) (@server @cli @client-core @test)
26
+
27
+ - **Before (v7.38.0).** The home-anchored rows of the read deny table (`~/.sema/settings.json`, `~/.claude/settings.json`, `~/.mcp.json`, …; the credential rows `.ssh` / `.aws` match by basename anywhere and were never home-bound) were placed under one home: the `HOME` the shell child receives (`ExecutionEnv.homeDir`; `NodeExecutionEnv` reads it off `shellEnv` over the inherited environment). A host that handed the child a temporary `HOME` moved the whole fence with it — the user's real home directory (the account database's, `os.userInfo().homedir`) held no row, and the structured read tools and the shell read station served `~/.sema/settings.json` there.
28
+ - **Now.** `declaredReadDenyHomes` returns the declared home and, when its lexically normalized spelling differs, the account home. The home set is fixed ONCE per placement — the Runner's (per run), the library-direct toolkit's own, or a host's explicit `readDenyHomes` taken as the home set (the account database is then not asked and no home is added) — and every home of the set, looked up or listed, is anchored at its spelling and at its canonical spelling where the environment answered one — and every judgment reads that set: a judge carries the home views of the placement it was compiled at, so a path spelled through a firmlink or a bind mount of a home is respelled at the home without asking the account again. The Runner hands its tool band that placement itself — the very home list it returned carries its views — so the band never reads the homes a second time; a band handed any other list with the placement's links (a copy) holds its home-anchored rows at every location and says so on `console.warn` (an engine defect, never a silent narrower view). The DECLARED home keeps the v7.38.0 law: its probes wait the gate bound, a placement whose declared home did not answer holds the rows at every location and is asked again, and its directory chain is asked again by every judgment until it answers (a home created after the run began is read once it exists); its views stay under every answer of the account lookup, also under no answer, so a user's deny rule and the write face meet its aliases as in v7.38.0. The ACCOUNT home (and every entry of an explicit list that is not the declared home) is best effort and bounded: its canonical spelling, its identity chain and where its rows' heads lead are read once per placement under one 2000 ms bound; what answered is kept and only what did not is lost, that home's alone — a canonical spelling that answered anchors the rows there at once; a chain that did not answer leaves a path through an alias of the home judged as spelled, a head that did not answer adds no linked location, until the next placement; a canonical spelling that did not answer leaves the rows at the home's spelling alone — the placement stays whole, and one `console.warn` line says so. In the chain a directory that is a home (read off the home's own canonical directory, where its identity answered) keeps the home's own spelling over an ancestor's. A head under a spelling that is no directory — a file, a symlink to a file, a device node, a socket: the environment's path resolution answers `not_directory` — has nothing below it (an answer; before, only a plain file counted: a `HOME` that is a symlink to a file left the placement not whole, replayed on every call). The `~` expansion base is unchanged: `ExecutionEnv.homeDir` stays the directory the child's own `~` names. An environment that declares no absolute home still places nothing (a home-anchored row then holds its run anywhere); the account home is no stand-in for it.
29
+ - **Who is hurt.** A read of a file under the account home that matches a home-anchored row (`~/.sema/settings.json`, `~/.claude/settings.json`, `~/.mcp.json`) while the child's `HOME` is elsewhere: refused (`read_path_denied`) where it was served. A host that lists `readDenyHomes` itself sees the same placement, and its views now cover every listed home (v7.38.0 respelled aliases of the declared home only: an alias spelling of another listed home was served); its explicit list stays a legal channel and can be withdrawn once it carries nothing the default does not. A remote or sandboxed leg gets the engine host's account home as one more anchor: a row over a path that may not exist there. A deployment whose account lookup fails for a reason other than "no passwd row" gets the over-strict placement above (rows held at every location) until the lookup answers. A hung account home makes a placement wait up to 2 s more, once per placement (the Runner's tool band judges with the run's placement and reads no home again). A host that passes BOTH `readDenyHomes` and `readDenyHomeLinks` (a hand-built pair) is now taken for the engine's own handoff of a placement it cannot find: the home-anchored rows are held at every location and `console.warn` names it (v7.38.0 placed the rows at the listed homes); no sibling host makes that call — pass `readDenyHomes` alone and let the toolkit resolve where the heads lead. No sibling host lists one today: server's only home seat is the execution environment's `homeDir` (`boot/execution-env.ts`), so its deployments get the account home from this default with no change on its side.
30
+ - **Compensation.** None needed for the common case (`HOME` equals the account home: one home, nothing changes). A deployment that really reads a file under its own account home that a row names lists the row in `readDenyBuiltinExclude`.
31
+ - **Known limit (KL-1244-a).** The account lookup has three answers, the split the environment's `canonicalPath` already meets, and NOTHING is kept for the process: every placement asks afresh, and a placement's judgments never ask. A home places. **No account home** — `os.userInfo()` throws the libuv "no passwd row" error (`info.code === "ENOENT"`, a container that runs an arbitrary uid; also a directory service that is down and says so) or answers a non-absolute path — places the rows under the declared home alone, a whole placement; the missing row is not a fault and does not warn (a non-absolute answer warns once). A library-direct toolkit does not place again after a whole placement, so an account home that answers later is not placed for that toolkit's lifetime; the Runner's next run places it. **No answer** on the first placement — any other failure, whatever the error object looks like — says so once on `console.warn` and yields an EMPTY home list (`readDenyHomesOf`, `declaredReadDenyHomes`), the engine's rule for "no home known": the home-anchored rows hold their run at every location until the lookup answers (the project's own `.sema/settings.json` is refused too — over-strict, recoverable) and the placement is asked again at the next placement; the home views keep the declared home meanwhile. `readDenyHomesOf` returns `[]` both when the environment declares no home and when the lookup has no answer, so a host cannot tell them apart and should not cache the list: take it at every mount, or omit `readDenyHomes` and let the toolkit place itself. A toolkit whose table has no home-anchored row places nothing and its views read the declared home (or the list it is handed) without asking the account. A structured notice would be a closed-set row; a minor release carries it.
32
+ - **Known limit (KL-1244-b).** An auxiliary home loses, until the next placement, exactly the parts that did not answer within its 2000 ms bound: a chain that did not answer — paths through an alias of it are judged as spelled; a head that did not answer — no linked location for it; a canonical spelling that did not answer — the rows sit at its spelling alone (neither its canonical spelling nor its linked targets are guarded). The next placement is the Runner's next run; a library-direct toolkit does not place again for that. A directory the probes may not enter (EACCES) is no answer: as the declared home it leaves the placement not whole (replayed per call; the rows stay at its spellings). **Known limit (KL-1244-d).** The public `judgeShellRead` has no placement to read — the boundary's deny seats are the host's own compile and the input carries no home list — so its views read the declared home and the account home looked up for that call; rows the host placed at another home get no alias views there (as in v7.38.0, whose public judgment read the declared home only); an optional input seat comes with a minor. **Known limit (KL-1244-c).** A user's own `~/` persisted permission rule (`Read(~/.aws/**)` …) still resolves against the environment's declared home only (`prepare-task.ts`, `prepare-policy-chain.ts`, `permission-rule-lanes.ts`); under a temporary `HOME` such a rule does not cover the real home directory. The built-in read deny table does.
33
+ - Pins: `test/backlog1244-passwd-home.test.ts` — ① Read / `cat` / Grep / the library-direct toolkit over the account home with a temporary child `HOME` (baseline: served); ② the child's own `HOME` still guarded; ③ one home when both are the same; ④ the `~` expansion base is the child's `HOME`; ⑤ the lookup's answers — (i) ENOENT: declared home alone, whole, silent, the project's settings read; (ii) no answer on a direct toolkit: rows held anywhere, said once, placed again on the next read; (iii) the same on the Runner lane, per run; (iv) the views read the placement's homes — the declared home's chain under every answer, read once per placement, the account home's only when it answered; (v) every reader of the home list is empty under no answer, and a toolkit built from `readDenyHomesOf` holds the rows anywhere (the account home's file, the declared home's file and the project's settings refused, a plain file read), then carries both homes once the lookup answers; (vi) the `~` base is still the child's `HOME`; (vii) the failure is classified totally (an `info` / `message` getter or `toString` that throws, an unprintable value, a console that throws) and the tool call still ends with a frame; a non-absolute answer is a bad value, said once; ⑥ no declared home ⇒ no placement; ⑦ the account home reached through a mount that the declared home lies below (Read / `cat` / Grep refused; the view of a path through the mount is the account home's spelling), also when the declared home's own identity probe fails; ⑧ the placement read the account home, then the lookup fails (EMFILE): the alias spellings of both homes are still refused (toolkit and Runner), nothing is kept for the process and a placement's judgments ask the account 0 times, an account home that does not exist or is a plain file is an answer (the chain read once, the placement whole). `test/backlog1244-home-view-snapshot.test.ts` (baseline car-7381: 10 of 11 red) — T1–T3 a placement that answered (ENOENT / a bad value) then a failing lookup: the declared home's alias refused (toolkit, Runner: Read / `cat` / Grep); R3a an explicit `[H, A]` under a lookup that never answers: both aliases refused, the account asked 0 times; R3b an account home whose path probe hangs: the first judgment ends under 3 s, the declared home's alias and the account home's own file refused, the project's settings read, the Runner's run not failed; R3c an account home whose probe fails: probed by the placement only, the project's settings read; Q1 under no answer a user's Read deny rule and an Edit deny rule (the write face) meet the declared home's alias; Q2 a declared home created after the placement is read once it exists; N a declared home that is a symlink to a file, a device node or a socket: a whole placement, not replayed; a directory the probe may not enter is no answer; R2 an account home that answers after a whole placement is not placed by the toolkit (the account not asked), the Runner's next run places it; R4 the account home's probe answers the run's placement and fails afterwards: Read / Grep / Glob through its alias refused on the Runner lane, its chain read once per run; a carried copy of the list holds the rows anywhere and is named; a host's own list is read once by the toolkit itself; R6 a host's list `[H, A-link]` whose A-link answers its canonical spelling while its chain and heads are slow: Read / Grep of A-real refused, the anchors carry A-real; a symlinked declared home in a list is anchored at its canonical spelling too; a list of real directories is placed at exactly the list; R5 a symlinked account home whose canonical spelling answers at once and whose chain is slow: anchored at the real path (a Grep over the link, a Read and a `cat` of the real path refused), a canonical probe that hangs leaves the spelling alone, `readDenyHomesOf` lists the placement's anchors. `test/backlog-r2-default-tiers.test.ts` asserts the whole homes list.
34
+
35
+ ### The text-repair lane's fallback tool-call id never carries the tool's name (#1219; behavior (the id's shape); +0 exports) (@server @test)
36
+
37
+ - **Before (v7.38.0).** `repairTextToolCalls` minted `repair_<tool name>_<body>`. A roster name is only length-bounded (1–128), so a non-ASCII name or a 120-character name made an id outside the admitted shape that every native lane's id is held to, with no fold and no notice.
38
+ - **Now.** The prefix is the constant `repair_` (as `toolu_` / `call_` are): `repair_<opaque body>`, in the admitted shape, kept verbatim by `admitToolCallId`; the id no longer names the tool. Off by default (`createOpenAIBrain({ repairTextToolCalls: true })`).
39
+ - **Who is hurt.** A consumer that parsed a repaired call's tool name out of its id, or matched `^repair_<name>_`. Nothing in the engine does.
40
+ - **Compensation.** Read the call's `name`.
41
+ - Pins: `test/backlog1219-repair-fallback-id.test.ts` (non-ASCII, 120-character, fold-separator and space names; the openai lane end to end); `test/brain-protocol-coverage.test.ts` re-pinned.
42
+
43
+ ### Docs: which of a call's names a host's own by-name policy reads (#1220; docs only; +0 exports)
44
+
45
+ `docs/INTEGRATION-CORE.md` §4.6f: a tightening policy (deny, ask, require approval) reads the whole of `ruleNamesOf(req)`; a loosening one (allow, auto-approve) reads `toolName`, the wire name. No code change; `ruleNamesOf` was already exported.
46
+
47
+ ### Contract sentence: the `crashed` cause of `task.user_steer_undrained` (docs only) (@server @cli @client-core)
48
+
49
+ - `USER_STEER_UNDRAINED_CAUSES` member `crashed` now reads "accepted by the serving layer and not confirmed consumed before the process died" (was "never consumed"). A live leg gives the serving layer no evidence that a steer reached the model, so the arm cannot claim the steer was never consumed; a consumer must not tell the person to re-send without checking the run's transcript. No code, word or shape change. The consumption evidence itself is a separate change (#1273).
50
+
51
+
1
52
  ## 7.38.0 — 2026-10-01
2
53
 
3
54
  **Hard-breaking changes:** none.
@@ -42,7 +42,7 @@ function extractCall(obj, toolNames, framed) {
42
42
  }
43
43
  return {
44
44
  type: "toolCall",
45
- id: mintFallbackToolCallId(`repair_${name}_`),
45
+ id: mintFallbackToolCallId("repair_"),
46
46
  name,
47
47
  arguments: args,
48
48
  };
@@ -395,7 +395,7 @@ export interface EngineNotice {
395
395
  * `detail: { steer, cause, taskId?, sessionId?, runId? }` / `{ followUp, taskId?, sessionId?, runId? }`
396
396
  * (`cause` on the steer row only, closed `USER_STEER_UNDRAINED_CAUSES`: `"leg_end"` — this engine's arm, the
397
397
  * leg ended with it still owed; `"crashed"` — minted by the SERVING LAYER, not this engine, before the
398
- * session's next run, for steers it had accepted and the dead process never consumed: their queue was the
398
+ * session's next run, for steers it had accepted whose consumption was not confirmed before the process died: their queue was the
399
399
  * serving layer's, so only it can count them. One code for both, so a consumer de-duplicating on `code` keeps
400
400
  * one row; `sessionId` = #433's routing half for these two `"user"`-audience rows; `runId` = #499's
401
401
  * per-run identity, the key this account is actually about — see
@@ -952,10 +952,11 @@ export declare function settleOwedUserInput(account: OwedUserInputAccount, rowRe
952
952
  runId?: string;
953
953
  }, onNotice: ((notice: EngineNotice) => void) | undefined): void;
954
954
  /**
955
- * Why a person's steer was never consumed — `task.user_steer_undrained`'s `detail.cause`. Closed:
955
+ * Why a person's steer is reported as not consumed — `task.user_steer_undrained`'s `detail.cause`. Closed:
956
956
  * · `leg_end` — the leg ended with the steer still owed (this engine mints it, at the leg's settlement);
957
957
  * · `crashed` — the engine process ended before the leg settled, and a steer the serving layer had accepted for it was
958
- * lost with the process. Only the serving layer can count that loss (the queue was its own), so the serving layer
958
+ * not confirmed consumed before the process died (a live leg gives the serving layer no evidence that a steer reached
959
+ * the model, so such a steer MAY have been consumed). Only the serving layer can count these (the queue was its own), so the serving layer
959
960
  * mints this arm, before the next run of the session starts — the one member-level exception to
960
961
  * {@link import("./governance-codes.js").NOTICE_MINTER}'s row for the code, which stays `"engine"`.
961
962
  */
@@ -18,7 +18,8 @@ import type { OnAsk } from "../tool-policy.js";
18
18
  import { type ArtifactPlane } from "../artifact-host.js";
19
19
  import { RosterBuilder } from "../tool-roster.js";
20
20
  import type { AgentTool, RunnerDeps } from "../types.js";
21
- import type { ReadDenyMatcher, ContainmentFace } from "../../tools/fs/index.js";
21
+ import type { ContainmentFace } from "../../tools/fs/index.js";
22
+ import type { PlacedReadDenyMatcher } from "../../tools/fs/read-deny-home.js";
22
23
  import { type ArtifactStoreRef } from "./read-root-grant.js";
23
24
  import type { Prepared } from "./contracts.js";
24
25
  export interface PrepareArtifactInput {
@@ -46,7 +47,7 @@ export interface PrepareArtifactInput {
46
47
  * live set: an in-flight read-root grant widens it). */
47
48
  additionalRootsCanonical: () => readonly string[];
48
49
  /** borrowed-readonly — the read-deny matcher in force for the run, when any. */
49
- readDenyMatcher: ReadDenyMatcher | undefined;
50
+ readDenyMatcher: PlacedReadDenyMatcher | undefined;
50
51
  /** borrowed-readonly — the resolved read face (`"open"` lifts root containment for reads). */
51
52
  resolvedReadFace: ContainmentFace | undefined;
52
53
  /** borrowed-readonly — the tracked-cwd cell; `current` is read per call as the relative-path base. */
@@ -1,5 +1,6 @@
1
- import { type BeforeWriteHook, type CwdRef, type ReadDenyMatcher, type PathRuleDenyRow, type ContainmentFace } from "../../tools/fs/index.js";
1
+ import { type BeforeWriteHook, type CwdRef, type PathRuleDenyRow, type ContainmentFace } from "../../tools/fs/index.js";
2
2
  import { type ReadFileState } from "../../tools/fs/safety.js";
3
+ import { type PlacedReadDenyMatcher } from "../../tools/fs/read-deny-home.js";
3
4
  import { type ShellReadAdmission, type ShellReadCheck } from "../../tools/fs/shell-read-check.js";
4
5
  import type { ShellReadBoundary } from "../../tools/fs/shell-read-judge.js";
5
6
  import type { RunnerDeps, ShellGateDoctrine, TaskSpec } from "../types.js";
@@ -254,8 +255,8 @@ export interface PrepareHandsReadFaceResult {
254
255
  * reader applies the same containment the hands tools do. Undefined ⇔ hands-less. */
255
256
  attachmentRootCanonical: string | undefined;
256
257
  /** owned — the ONE compiled deny judge per prepare (built-ins ∪ deployment ∪
257
- * task additions). Undefined ⇔ hands-less (its consumers only exist with hands). */
258
- readDenyMatcher: ReadDenyMatcher | undefined;
258
+ * task additions), carrying the home views of its placement. Undefined ⇔ hands-less (its consumers only exist with hands). */
259
+ readDenyMatcher: PlacedReadDenyMatcher | undefined;
259
260
  /** owned — the shell-seat PROVENANCE set: the shell-running tools whose tier the engine's shell seat (read
260
261
  * boundary + shape tier + shellGate floor) is the SOLE POSITIVE source of — no explicit deployment mark on that name.
261
262
  * The one seat every consumer reads (risk grading, the gate's tighten attribution, the persisted-rule mandate
@@ -313,6 +313,7 @@ export async function prepareHandsMount(input) {
313
313
  tools: shellReadTools,
314
314
  seatOf: () => (handsReadOnly ? "readonly" : "full_shell"),
315
315
  boundaryOf: () => readonlyBoundary() ?? shellReadBoundary(), rootsCanonical: true,
316
+ homeViews: readDenyPlacement.matcher.homeViews,
316
317
  });
317
318
  }
318
319
  if (!handsReadOnly) {
@@ -10,7 +10,7 @@ import { type ToolPolicy } from "../tool-policy.js";
10
10
  import type { RunnerDeps, TaskSpec } from "../types.js";
11
11
  import type { CwdRef } from "../../tools/fs/fs-shared.js";
12
12
  import { type ContainmentFace } from "../../tools/fs/index.js";
13
- import type { ReadDenyMatcher } from "../../tools/fs/read-deny.js";
13
+ import type { PlacedReadDenyMatcher } from "../../tools/fs/read-deny-home.js";
14
14
  import { type ReadFileState } from "../../tools/fs/safety.js";
15
15
  import type { Prepared } from "./contracts.js";
16
16
  export interface PrepareTurnWiringInput {
@@ -70,7 +70,7 @@ export interface PrepareTurnWiringInput {
70
70
  /** borrowed-readonly — the canonical extra read roots (the fence's root set). */
71
71
  additionalReadRootsCanonical: ReadonlyArray<string>;
72
72
  /** borrowed-readonly — the compiled read-deny judge, or undefined (the reader's withheld arm). */
73
- readDenyMatcher: ReadDenyMatcher | undefined;
73
+ readDenyMatcher: PlacedReadDenyMatcher | undefined;
74
74
  /** borrowed-readonly — the resolved read face, or undefined (the fence's ninth argument). */
75
75
  resolvedReadFace: ContainmentFace | undefined;
76
76
  /** borrowed-readonly — the tracked-cwd seat, read per call by the kept-tail normalizer (never written here). */
@@ -13,6 +13,11 @@ import { type ContractAssertionRunner } from "./contract-harness.js";
13
13
  * turns a recoverable incident into a silent one. Which read-back failures a store TOLERATES (an
14
14
  * enumeration that skips one unreadable row and serves the rest, a torn trailing write read as absent)
15
15
  * is a per-store fact registered beside that store's own contract, never assumed from another store's.
16
+ * On the JSONL file format the unterminated last segment is judged by the same parse as every other line
17
+ * (`readJsonlRecords`): one that parses to an object is a whole record that lost only its newline (served,
18
+ * and its line ended at the next open or append), one that does not parse is a crash's partial write (absent,
19
+ * cut back to the last newline), so a newline is the boundary's spelling and never the condition of a record
20
+ * existing.
16
21
  */
17
22
  export declare const CORRUPT_BODY_FORMS: readonly ["not-an-object", "missing-required", "wrong-type", "torn-json"];
18
23
  /** One way a persisted body can fail to be the shape its store writes. */
@@ -36,8 +36,9 @@ export declare function ensureDir(dir: string): void;
36
36
  export declare function atomicWriteFile(tmpDir: string, target: string, bytes: string): void;
37
37
  /**
38
38
  * Read a JSONL file and return every COMPLETE, parseable record — the universal torn-tail recovery (§2.2):
39
- * - a trailing line with no terminating `\n` (a crash mid-write) is discarded,
40
- * - any line (interior or tail) that fails `JSON.parse` is skipped (never throws — `listRemoteAgentMetadata`
39
+ * - the last segment, when no `\n` ends it, is judged as every line is (`@contract store.read_back_shape_judged`):
40
+ * an object is served, an unparseable remnant is a crash's partial write and is discarded,
41
+ * - any interior line that fails `JSON.parse` is skipped (never throws — `listRemoteAgentMetadata`
41
42
  * parity), so a single corrupt interior line never poisons the whole replay.
42
43
  * A missing file → `[]`. This is why JSONL beats one big JSON blob: a torn tail can never corrupt the store.
43
44
  *
@@ -48,10 +49,11 @@ export declare function atomicWriteFile(tmpDir: string, target: string, bytes: s
48
49
  * carry my members at all?"); a record it rejects is skipped as `missing-required` on the same road, so
49
50
  * the count below covers every skip.
50
51
  *
51
- * `onCorrupt` (optional): one `record` disclosure per skipped INTERIOR record, then — iff any was skipped —
52
- * one `replay` tally. The torn TAIL stays out of both by design: dying mid-write is the crash shape this
53
- * format exists to absorb. The callback is invoked as given — a seat that must not throw swallow-guards on
54
- * ITS side.
52
+ * `onCorrupt` (optional): one `record` disclosure per skipped record, then — iff any was skipped — one
53
+ * `replay` tally. The unparseable unterminated tail stays out of both by design: dying mid-write is the
54
+ * crash shape this format exists to absorb. An unterminated tail that parses to a non-object is not that
55
+ * shape (no prefix of an object parses to one), so it is disclosed like any other foreign line. The callback
56
+ * is invoked as given — a seat that must not throw swallow-guards on ITS side.
55
57
  */
56
58
  export declare function readJsonlRecords<T>(path: string, onCorrupt?: (info: CorruptRecordRead | CorruptReplayTally) => void, judge?: JsonlRecordJudge): T[];
57
59
  /** A consuming store's floor on a parsed JSONL object: `accepts` = one of its records; `what` names a
@@ -114,7 +116,7 @@ export declare class AppendLog {
114
116
  *
115
117
  * It is the cheap arbiter for "did the file change under us since we last left it whole": equal ⇒
116
118
  * nothing to check, different ⇒ read the last byte and repair if it is not a newline. See
117
- * {@link repairTornTail}.
119
+ * {@link repairTail}.
118
120
  */
119
121
  private boundary;
120
122
  constructor(path: string);
@@ -122,7 +124,7 @@ export declare class AppendLog {
122
124
  /**
123
125
  * RB-438 (2026-07-31, hardening probe R2) — re-establish the last-complete-record boundary before a write.
124
126
  *
125
- * The constructor's {@link truncateTornTail} (and the RB-167 lazy reopen) only ever ran at OPEN time, and
127
+ * The constructor's {@link repairJsonlTail} (and the RB-167 lazy reopen) only ever ran at OPEN time, and
126
128
  * the RB-66 loop below only repairs a SHORT write — not a `writeSync` that THROWS mid-loop (ENOSPC/EDQUOT/
127
129
  * EIO after a partial `write(2)`, the classic full-volume shape) and not the `n <= 0` guard. Both leave
128
130
  * `written` bytes on disk with no terminating newline while the fd and this object stay live, and every
@@ -133,9 +135,10 @@ export declare class AppendLog {
133
135
  *
134
136
  * Cost on the healthy path is ONE `fstat` per append (the sizes match and we return); the whole-file read
135
137
  * only happens when the file actually moved off a boundary. A legitimate concurrent appender (another
136
- * process under the boot-lock handoff) leaves the file newline-terminated, so it is adopted, not truncated.
138
+ * process under the boot-lock handoff) leaves the file newline-terminated, so it is adopted, not truncated; a
139
+ * whole record it left without its newline is kept and ended, and only a partial one is cut.
137
140
  */
138
- private repairTornTail;
141
+ private repairTail;
139
142
  /**
140
143
  * Append one record (`json + "\n"`). `fsync:true` makes the record durable before return.
141
144
  *
@@ -145,8 +148,18 @@ export declare class AppendLog {
145
148
  * line with no trailing newline, so the NEXT append concatenates onto it and replay drops BOTH records.
146
149
  * Loop until every byte lands (write(2) returns how many it took) — the O_APPEND atomicity per call is
147
150
  * unchanged, and a genuine I/O error still throws.
151
+ *
152
+ * A write that fails leaves no bytes of its own: the file is cut back to where the write began, so a caller
153
+ * that retries the record writes it once — a record that failed is never also half-present.
148
154
  */
149
155
  append(record: unknown, fsync: boolean): void;
156
+ /**
157
+ * Cut the file back to `startedAt` after a write that threw, provided the bytes past it are exactly this
158
+ * write's own (`written` of them): a peer that appended after us owns what follows, and its bytes are not
159
+ * ours to cut. Best effort — the error the caller must see is the write's, so a rollback that cannot run
160
+ * answers false and the next append's tail repair judges what is left.
161
+ */
162
+ private rollBackFailedWrite;
150
163
  close(): void;
151
164
  /**
152
165
  * RB-167 — release the descriptor for a compaction SWAP. The next write reopens it lazily.
@@ -1,4 +1,4 @@
1
- import { closeSync, constants as FS, existsSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync, renameSync, truncateSync, unlinkSync, writeFileSync, writeSync, fsyncSync, } from "node:fs";
1
+ import { appendFileSync, closeSync, constants as FS, existsSync, fstatSync, ftruncateSync, linkSync, mkdirSync, openSync, readFileSync, readSync, renameSync, truncateSync, unlinkSync, writeFileSync, writeSync, fsyncSync, } from "node:fs";
2
2
  import { homedir, hostname, uptime } from "node:os";
3
3
  import { hrtime } from "node:process";
4
4
  import { basename, dirname, join, resolve as resolvePath } from "node:path";
@@ -106,6 +106,18 @@ function basenameOf(p) {
106
106
  function dirnameOf(p) {
107
107
  return dirname(p);
108
108
  }
109
+ function jsonlTextFormOf(text) {
110
+ let value;
111
+ try {
112
+ value = JSON.parse(text);
113
+ }
114
+ catch {
115
+ return { form: "unparseable" };
116
+ }
117
+ if (value === null || typeof value !== "object" || Array.isArray(value))
118
+ return { form: "not-an-object", value };
119
+ return { form: "record", record: value };
120
+ }
109
121
  export function readJsonlRecords(path, onCorrupt, judge) {
110
122
  let raw;
111
123
  try {
@@ -119,7 +131,6 @@ export function readJsonlRecords(path, onCorrupt, judge) {
119
131
  if (raw === "")
120
132
  return [];
121
133
  const parts = raw.split("\n");
122
- const endedClean = parts[parts.length - 1] === "";
123
134
  const out = [];
124
135
  let dropped = 0;
125
136
  const discloseSkip = (form, line, what) => {
@@ -127,28 +138,25 @@ export function readJsonlRecords(path, onCorrupt, judge) {
127
138
  onCorrupt?.({ kind: "record", path, reason: `${form}: ${what} at line ${line} (skipped)`, line, form });
128
139
  };
129
140
  for (let i = 0; i < parts.length; i++) {
130
- if (i === parts.length - 1 && !endedClean)
131
- break;
132
141
  const line = parts[i];
133
142
  if (line === "")
134
143
  continue;
135
- let record;
136
- try {
137
- record = JSON.parse(line);
138
- }
139
- catch {
144
+ const read = jsonlTextFormOf(line);
145
+ if (read.form === "unparseable") {
146
+ if (i === parts.length - 1)
147
+ break;
140
148
  discloseSkip("torn-json", i + 1, "unparseable jsonl record");
141
149
  continue;
142
150
  }
143
- if (record === null || typeof record !== "object" || Array.isArray(record)) {
144
- discloseSkip("not-an-object", i + 1, `jsonl record is ${jsonTypeName(record)}, not an object`);
151
+ if (read.form === "not-an-object") {
152
+ discloseSkip("not-an-object", i + 1, `jsonl record is ${jsonTypeName(read.value)}, not an object`);
145
153
  continue;
146
154
  }
147
- if (judge !== undefined && !judge.accepts(record)) {
155
+ if (judge !== undefined && !judge.accepts(read.record)) {
148
156
  discloseSkip("missing-required", i + 1, judge.what);
149
157
  continue;
150
158
  }
151
- out.push(record);
159
+ out.push(read.record);
152
160
  }
153
161
  if (dropped > 0) {
154
162
  const total = out.length + dropped;
@@ -175,7 +183,7 @@ export class AppendLog {
175
183
  constructor(path) {
176
184
  this.path = path;
177
185
  ensureDir(dirnameOf(path));
178
- truncateTornTail(path);
186
+ repairJsonlTail(path);
179
187
  this.fd = openSync(path, "a+", 0o600);
180
188
  this.boundary = this.sizeOrUnknown();
181
189
  }
@@ -187,7 +195,7 @@ export class AppendLog {
187
195
  return -1;
188
196
  }
189
197
  }
190
- repairTornTail() {
198
+ repairTail() {
191
199
  const size = this.sizeOrUnknown();
192
200
  if (size < 0) {
193
201
  this.boundary = -1;
@@ -207,14 +215,15 @@ export class AppendLog {
207
215
  }
208
216
  }
209
217
  catch {
218
+ this.boundary = -1;
210
219
  return;
211
220
  }
212
- truncateTornTail(this.path);
221
+ repairJsonlTail(this.path);
213
222
  this.boundary = this.sizeOrUnknown();
214
223
  }
215
224
  append(record, fsync) {
216
225
  if (this.fd < 0 && !this.disposed) {
217
- truncateTornTail(this.path);
226
+ repairJsonlTail(this.path);
218
227
  this.fd = openSync(this.path, "a+", 0o600);
219
228
  this.boundary = this.sizeOrUnknown();
220
229
  }
@@ -223,8 +232,9 @@ export class AppendLog {
223
232
  e.code = "log_closed";
224
233
  throw e;
225
234
  }
226
- this.repairTornTail();
235
+ this.repairTail();
227
236
  const buf = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
237
+ const startedAt = this.boundary;
228
238
  let written = 0;
229
239
  try {
230
240
  while (written < buf.length) {
@@ -235,7 +245,7 @@ export class AppendLog {
235
245
  }
236
246
  }
237
247
  catch (err) {
238
- this.boundary = -1;
248
+ this.boundary = this.rollBackFailedWrite(startedAt, written) ? startedAt : -1;
239
249
  throw err;
240
250
  }
241
251
  if (this.boundary >= 0)
@@ -243,6 +253,19 @@ export class AppendLog {
243
253
  if (fsync)
244
254
  fsyncSync(this.fd);
245
255
  }
256
+ rollBackFailedWrite(startedAt, written) {
257
+ if (startedAt < 0)
258
+ return false;
259
+ try {
260
+ if (fstatSync(this.fd).size !== startedAt + written)
261
+ return false;
262
+ ftruncateSync(this.fd, startedAt);
263
+ return true;
264
+ }
265
+ catch {
266
+ return false;
267
+ }
268
+ }
246
269
  close() {
247
270
  this.disposed = true;
248
271
  this.releaseFd();
@@ -262,20 +285,23 @@ export class AppendLog {
262
285
  }
263
286
  }
264
287
  }
265
- function truncateTornTail(path) {
266
- let raw;
288
+ function repairJsonlTail(path) {
289
+ let bytes;
267
290
  try {
268
- raw = readFileSync(path, "utf8");
291
+ bytes = readFileSync(path);
269
292
  }
270
293
  catch (err) {
271
294
  if (err.code === "ENOENT")
272
295
  return;
273
296
  throw err;
274
297
  }
275
- if (raw === "" || raw.endsWith("\n"))
298
+ if (bytes.length === 0 || bytes[bytes.length - 1] === 0x0a)
276
299
  return;
277
- const lastNl = raw.lastIndexOf("\n");
278
- truncateSync(path, lastNl === -1 ? 0 : Buffer.byteLength(raw.slice(0, lastNl + 1), "utf8"));
300
+ const segmentStart = bytes.lastIndexOf(0x0a) + 1;
301
+ if (jsonlTextFormOf(bytes.toString("utf8", segmentStart)).form === "record")
302
+ appendFileSync(path, "\n");
303
+ else
304
+ truncateSync(path, segmentStart);
279
305
  }
280
306
  export class FileStoreLockError extends Error {
281
307
  code;
@@ -2,6 +2,7 @@ import { Type } from "typebox";
2
2
  import type { ExecutionEnv } from "../../internal/harness-types.js";
3
3
  import { type FsViolation, type ReadFileState } from "./safety.js";
4
4
  import { type DecodedTextFile } from "./encoding.js";
5
+ import { type HomeViewsCarrier } from "./read-deny-home.js";
5
6
  import { type ImageDownsampler } from "../../core/mcp.js";
6
7
  import { type EngineNotice } from "../../core/types.js";
7
8
  /**
@@ -543,7 +544,7 @@ export declare function enoentMessage(env: ExecutionEnv, key: string, cwd: strin
543
544
  matchPath(path: string): {
544
545
  pattern: string;
545
546
  } | null;
546
- }): Promise<string>;
547
+ } & HomeViewsCarrier): Promise<string>;
547
548
  /** Per-task mutable working directory shared by the shell and the path-taking fs tools.
548
549
  * Holds the RAW path (never canonicalized): bash `cd` updates `current`, and the fs tools resolve relative
549
550
  * paths against it. Containment is still enforced per-op by resolveKey (canonicalize + within), so a `cd`
@@ -345,7 +345,7 @@ export function applyContextShrinkToReadFileState(state, shrink) {
345
345
  export async function enoentMessage(env, key, cwd, signal, readDeny) {
346
346
  let msg = `File does not exist. Note: your current working directory is ${cwd}.`;
347
347
  const sep = Math.max(key.lastIndexOf("/"), key.lastIndexOf("\\"));
348
- const views = readDeny !== undefined ? homeViewsOf(env, signal) : undefined;
348
+ const views = readDeny !== undefined ? homeViewsOf(env, readDeny, signal) : undefined;
349
349
  const denied = async (path, leaf) => {
350
350
  if (readDeny === undefined || views === undefined)
351
351
  return false;
@@ -186,17 +186,24 @@ export interface HandsToolkitOptions {
186
186
  * time (never silently ignored). Deployment seat ONLY, same clamp argument as the tiers key. */
187
187
  readDenyBuiltinExclude?: readonly string[];
188
188
  /** The spellings of the execution environment's home a home-anchored deny row (`~/…`) is placed under
189
- * ({@link import("./read-deny-home.js").readDenyHomesOf}: the declared home and its canonical spelling). Absolute path
190
- * strings only — any other shape throws at wiring time, naming the seat. The Runner passes the spellings its own judge
191
- * was compiled with. */
189
+ * ({@link import("./read-deny-home.js").readDenyHomesOf}: the declared home and the account home, each with its canonical
190
+ * spelling). Absolute path strings only — any other shape throws at wiring time, naming the seat. The Runner passes the
191
+ * spellings its own judge was compiled with. A list given here is the band's home set as is: the account database is
192
+ * not asked and no home is added (each listed home is anchored at its spelling and, where the environment answers one,
193
+ * its canonical spelling), and the band's home views (a path spelled through a firmlink or a bind mount of a home,
194
+ * judged at the home) read this list and the environment's declared home; an entry that is not the declared home is probed once,
195
+ * under a short bound of its own, and a probe of it that fails costs that entry alone. */
192
196
  readDenyHomes?: readonly string[];
193
197
  /** Where each home-anchored row's literal head leads beyond the home's spellings
194
198
  * ({@link import("./read-deny-home.js").readDenyHomeLinksOf}, keyed by the row's pattern) — the Runner passes the
195
199
  * locations its own judge was compiled with, so the band and the gate judge one placement.
196
200
  * Unless a mount carries both, the band resolves the placement itself the way the Runner does (the homes it carries
197
- * are used as given; {@link import("./read-deny-home.js").placeReadDenyAtHome}), and its read tools (Read / Grep /
198
- * Glob / RepoMap) wait for that placement before they judge anything. A placement that fails leaves the home-anchored
199
- * rows held anywhere. */
201
+ * are its home set — the account is not asked and no home is added;
202
+ * {@link import("./read-deny-home.js").placeReadDenyAtHome}), and its read tools (Read / Grep / Glob / RepoMap) wait
203
+ * for that placement before they judge anything. A placement that fails leaves the home-anchored rows held anywhere.
204
+ * Carrying both is the engine's own handoff of ONE placement — its home views travel with it — so
205
+ * `readDenyHomes` must then be the very list that placement returned: any other list (a copy) holds the home-anchored
206
+ * rows at every location and is named on `console.warn`. */
200
207
  readDenyHomeLinks?: ReadonlyMap<string, readonly string[]>;
201
208
  /** The permission-rule rows of the deny table ({@link import("./read-deny.js").PathRuleDenyRow}, `ToolPolicy.pathDenyRules`):
202
209
  * the read-face rows join the structured read faces' judge beside the segment rows. The Runner passes the leg's own. */
@@ -16,7 +16,7 @@ import { BASH_READONLY_DEFAULT_ALLOW } from "./bash-readonly-classifier.js";
16
16
  export { BASH_CLASSIFY_EXTRA_ALLOW, bashClassifyDefaultAllow } from "./bash-readonly-classifier.js";
17
17
  import { checkedReadDenyHomes, compileReadDeny, compileWriteDeny, readDenyBuiltinConfigOf, readDenyPatternRun } from "./read-deny.js";
18
18
  import { withGateTimeout } from "../../core/gate-bounded-await.js";
19
- import { placeReadDenyAtHome, readDenyPlacedLater } from "./read-deny-home.js";
19
+ import { carryHomeViews, homeViewsAt, placeReadDenyAtHome, placementViewsOf, readDenyPlacedLater, sayPlacementLost } from "./read-deny-home.js";
20
20
  import { deploymentFaceClampNotice, resolveContainmentFace } from "./containment-face.js";
21
21
  export * from "./containment-face.js";
22
22
  import {} from "./fs-shared.js";
@@ -48,9 +48,17 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
48
48
  const compileAt = (homes, linked) => compileReadDeny(readDenyAdditions, "HandsToolkitOptions.readDenyPatterns", readDenyBuiltinCfg, readDenyRules, homes, linked);
49
49
  const unplaced = compileAt([]);
50
50
  const carried = readDenyHomes !== undefined && opts.readDenyHomeLinks !== undefined;
51
+ const carriedViews = carried ? placementViewsOf(opts.readDenyHomes) : undefined;
52
+ if (carried && carriedViews === undefined)
53
+ sayPlacementLost();
51
54
  const placesNothing = !unplaced.entries.some((e) => readDenyPatternRun(e.pattern).homeAnchored);
52
- const late = carried || placesNothing ? undefined : readDenyPlacedLater(unplaced, () => placeReadDenyAtHome(env, compileAt, readDenyHomes));
53
- const readDeny = late?.matcher ?? (carried ? compileAt(readDenyHomes, opts.readDenyHomeLinks) : unplaced);
55
+ const late = carried || placesNothing ? undefined : readDenyPlacedLater(carryHomeViews(unplaced, homeViewsAt(env, [])), () => placeReadDenyAtHome(env, compileAt, readDenyHomes));
56
+ const readDeny = late?.matcher ??
57
+ (carried
58
+ ? carriedViews !== undefined
59
+ ? carryHomeViews(compileAt(readDenyHomes, opts.readDenyHomeLinks), carriedViews)
60
+ : carryHomeViews(unplaced, homeViewsAt(env, []))
61
+ : carryHomeViews(unplaced, homeViewsAt(env, readDenyHomes ?? [])));
54
62
  const afterPlacement = (tool) => late === undefined
55
63
  ? tool
56
64
  : {
@@ -61,7 +69,7 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
61
69
  },
62
70
  };
63
71
  const writeDenyCompiled = compileWriteDeny(readDenyRules);
64
- const writeDeny = writeDenyCompiled.ruleRows.length > 0 ? writeDenyCompiled : undefined;
72
+ const writeDeny = writeDenyCompiled.ruleRows.length > 0 ? carryHomeViews(writeDenyCompiled, readDeny.homeViews) : undefined;
65
73
  const readFace = resolveContainmentFace({
66
74
  kind: "read",
67
75
  depsFace: opts.readFace,