@sema-agent/core 7.26.0 → 7.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/core/permission-rule-provider.js +6 -1
  3. package/dist/core/permission-rule-store.d.ts +36 -0
  4. package/dist/core/permission-rule-store.js +119 -19
  5. package/dist/core/permission-rule-sync.js +14 -5
  6. package/dist/core/runner/prepare-inherited-gate.d.ts +1 -1
  7. package/dist/core/runner/prepare-inherited-gate.js +2 -1
  8. package/dist/core/runner/session-rule-policy.d.ts +1 -1
  9. package/dist/core/runner/session-rule-policy.js +6 -7
  10. package/dist/core/session-policy-store.d.ts +18 -0
  11. package/dist/core/session-policy-store.js +3 -0
  12. package/dist/core/store-contracts/permission-rule-write-contract.js +16 -3
  13. package/dist/core/store-contracts/read-back-shape-contract.d.ts +56 -0
  14. package/dist/core/store-contracts/read-back-shape-contract.js +78 -0
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.js +4 -0
  17. package/dist/stores/file/adoption/adopt.js +4 -10
  18. package/dist/stores/file/permission-rule-store.d.ts +5 -1
  19. package/dist/stores/file/permission-rule-store.js +5 -3
  20. package/dist/stores/file/session-policy-store.d.ts +32 -0
  21. package/dist/stores/file/session-policy-store.js +72 -50
  22. package/dist/tools/fs/shell-read-check.d.ts +58 -1
  23. package/dist/tools/fs/shell-read-check.js +47 -33
  24. package/dist/tools/fs/shell-read-establish.js +19 -8
  25. package/dist/tools/fs/shell-read-judge.d.ts +3 -0
  26. package/dist/tools/fs/shell-read-judge.js +3 -1
  27. package/package.json +1 -1
  28. package/test/export-surface.snapshot.json +51 -1
  29. package/wire-changes/core@7.26.1.json +605 -0
@@ -0,0 +1,78 @@
1
+ import assert from "node:assert";
2
+ import { thrownText } from "../thrown-value.js";
3
+ import { beginContract } from "./contract-harness.js";
4
+ export const CORRUPT_BODY_FORMS = ["not-an-object", "missing-required", "wrong-type", "torn-json"];
5
+ async function observe(subject) {
6
+ try {
7
+ const value = await subject.read();
8
+ return { threw: false, body: safeStringify(value) };
9
+ }
10
+ catch (err) {
11
+ return { threw: true, message: thrownText(err) };
12
+ }
13
+ }
14
+ function safeStringify(value) {
15
+ const seen = new WeakSet();
16
+ try {
17
+ return (JSON.stringify(value, (_k, v) => {
18
+ if (typeof v === "object" && v !== null) {
19
+ if (seen.has(v))
20
+ return "[circular]";
21
+ seen.add(v);
22
+ }
23
+ return v;
24
+ }) ?? String(value));
25
+ }
26
+ catch {
27
+ return String(value);
28
+ }
29
+ }
30
+ function render(o) {
31
+ return o.threw ? `threw(${o.message})` : `answered ${o.body}`;
32
+ }
33
+ async function withSubject(hooks, fn) {
34
+ const subject = await hooks.open();
35
+ try {
36
+ return await fn(subject);
37
+ }
38
+ finally {
39
+ subject.close?.();
40
+ }
41
+ }
42
+ export async function storeReadBackShapeContract(hooks) {
43
+ const { run, settle } = beginContract(hooks.runAssertion);
44
+ const where = hooks.face;
45
+ run(`${where}: the read face tells a present row from an absent one (positive control)`, async () => {
46
+ const empty = await withSubject(hooks, observe);
47
+ const seeded = await withSubject(hooks, async (s) => {
48
+ await s.seed();
49
+ return observe(s);
50
+ });
51
+ assert.equal(empty.threw, false, `${where}: reading an EMPTY store must answer, not throw (${render(empty)}) — absence is not damage`);
52
+ assert.equal(seeded.threw, false, `${where}: reading a well-formed row must answer, not throw (${render(seeded)})`);
53
+ const again = await withSubject(hooks, observe);
54
+ assert.deepEqual(again, empty, `${where}: two reads of two EMPTY stores answered differently (${render(empty)} then ${render(again)}) — this suite's oracle is "the corrupt answer differs from the empty one", which is free for a face carrying incidental data; project it away in the binding`);
55
+ assert.notDeepEqual(seeded, empty, `${where}: a seeded store and an empty one answered identically (${render(empty)}) — this face reports nothing a corrupt body could differ from, so every clause below would pass without meaning anything`);
56
+ });
57
+ for (const form of CORRUPT_BODY_FORMS) {
58
+ run(`${where}: a ${form} body is not read as an absent row`, async () => {
59
+ const sentinel = `sema-corrupt-sentinel-${form}`;
60
+ const empty = await withSubject(hooks, observe);
61
+ const corrupt = await withSubject(hooks, async (s) => {
62
+ if (s.unplantable !== undefined) {
63
+ assert.ok(s.unplantable.length > 0, `${where}: a subject declaring no plantable body must say WHY`);
64
+ return undefined;
65
+ }
66
+ await s.plant(form, sentinel);
67
+ return observe(s);
68
+ });
69
+ if (corrupt === undefined)
70
+ return;
71
+ assert.notDeepEqual(corrupt, empty, `${where}: a ${form} body was answered exactly as an absent row is (${render(empty)}) — a corrupt body must reach the caller as a refusal or as an absence that names the loss, never as the store's empty answer`);
72
+ if (!corrupt.threw) {
73
+ assert.ok(!corrupt.body.includes(sentinel), `${where}: a ${form} body was handed back through the read face (the planted marker is in the answer) — the read judged nothing`);
74
+ }
75
+ });
76
+ }
77
+ await settle();
78
+ }
package/dist/index.d.ts CHANGED
@@ -142,6 +142,9 @@ export { ToolRosterEntry, ToolRoster, ToolRosterDelta, ToolRosterRenderHints, RO
142
142
  export { type ToolCallFace } from "./core/tool-policy.js";
143
143
  export { effectivePathTargetOf, pathTargetBaseOf, type ToolPathBases, type PathTargetReading } from "./core/effective-path-target.js";
144
144
  export { pathFamilyOf, isAbsoluteForFamily, pathIdentityOf, win32NamespaceScreen, WIN32_NAMESPACE_REFUSALS, type PathFamily, type PathIdentity, type PathIdentityReading, type Win32NamespaceRefusal } from "./tools/fs/safety.js";
145
+ export { judgeShellRead, type ShellReadJudgeInput, type ShellReadJudgment, type ShellReadVerdict, type ShellReadStationSeat, type ShellReadAdmission, } from "./tools/fs/shell-read-check.js";
146
+ export { SHELL_READ_FINDINGS, SHELL_READ_SEATS, SHELL_READ_DECISION, SHELL_READ_FINDING_RANK, type ShellReadFinding, type ShellReadSeat, type ShellReadDisposition, type ShellReadBoundary, type ReadEstablishment, type EstablishedRead, type ReadFinding, } from "./tools/fs/shell-read-judge.js";
147
+ export { type ReadCandidate, type ShellReadPlan, type ShellReadSegment } from "./tools/fs/shell-read-plan.js";
145
148
  export { TOOL_CONFORMANCE_VECTORS, type ToolConformanceVector } from "./core/tool-conformance.js";
146
149
  export { applyMcpToolFaces, toolFaceProblem, APPROVAL_CARD_REQUIRED_KEYS, type McpToolFace } from "./core/runner/tool-face-overlay.js";
147
150
  export { createStructuredProjector } from "./core/runner/tool-output-projection.js";
@@ -271,6 +274,7 @@ export { type WorkflowRunStore, type WorkflowRunSummary, summarizeWorkflowRun, i
271
274
  export { FileWorkflowRunStore, type FileWorkflowRunStoreOptions } from "./stores/file/workflow-run-store.js";
272
275
  export { workflowRunStoreContract } from "./core/workflow-run-store-contract.js";
273
276
  export { CONTRACT_KIT_ENGINE_VERSION } from "./core/store-contracts/contract-kit-version.js";
277
+ export { CORRUPT_BODY_FORMS, storeReadBackShapeContract, type CorruptBodyForm, type ReadBackShapeSubject, type StoreReadBackShapeHooks, } from "./core/store-contracts/read-back-shape-contract.js";
274
278
  export { type ContractAssertionRunner } from "./core/store-contracts/contract-harness.js";
275
279
  export { checkpointStoreContract } from "./core/store-contracts/checkpoint-store-contract.js";
276
280
  export { sessionRepoContract } from "./core/store-contracts/session-repo-contract.js";
package/dist/index.js CHANGED
@@ -115,6 +115,9 @@ export { ToolRosterEntry, ToolRoster, ToolRosterDelta, ToolRosterRenderHints, RO
115
115
  export {} from "./core/tool-policy.js";
116
116
  export { effectivePathTargetOf, pathTargetBaseOf } from "./core/effective-path-target.js";
117
117
  export { pathFamilyOf, isAbsoluteForFamily, pathIdentityOf, win32NamespaceScreen, WIN32_NAMESPACE_REFUSALS } from "./tools/fs/safety.js";
118
+ export { judgeShellRead, } from "./tools/fs/shell-read-check.js";
119
+ export { SHELL_READ_FINDINGS, SHELL_READ_SEATS, SHELL_READ_DECISION, SHELL_READ_FINDING_RANK, } from "./tools/fs/shell-read-judge.js";
120
+ export {} from "./tools/fs/shell-read-plan.js";
118
121
  export { TOOL_CONFORMANCE_VECTORS } from "./core/tool-conformance.js";
119
122
  export { applyMcpToolFaces, toolFaceProblem, APPROVAL_CARD_REQUIRED_KEYS } from "./core/runner/tool-face-overlay.js";
120
123
  export { createStructuredProjector } from "./core/runner/tool-output-projection.js";
@@ -225,6 +228,7 @@ export { summarizeWorkflowRun, isTerminalWorkflowStatus, WorkflowRunStoreError,
225
228
  export { FileWorkflowRunStore } from "./stores/file/workflow-run-store.js";
226
229
  export { workflowRunStoreContract } from "./core/workflow-run-store-contract.js";
227
230
  export { CONTRACT_KIT_ENGINE_VERSION } from "./core/store-contracts/contract-kit-version.js";
231
+ export { CORRUPT_BODY_FORMS, storeReadBackShapeContract, } from "./core/store-contracts/read-back-shape-contract.js";
228
232
  export {} from "./core/store-contracts/contract-harness.js";
229
233
  export { checkpointStoreContract } from "./core/store-contracts/checkpoint-store-contract.js";
230
234
  export { sessionRepoContract } from "./core/store-contracts/session-repo-contract.js";
@@ -3,7 +3,7 @@ import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { adoptFilePermissionRuleStore } from "../permission-rule-adopt.js";
5
5
  import { BootLock, atomicWriteFile, ensureDir, sanitizeScope } from "../fs-atomic.js";
6
- import { SESSION_POLICY_MARK_SUFFIX, readSessionPolicyMark } from "../session-policy-store.js";
6
+ import { SESSION_POLICY_MARK_SUFFIX, judgeSessionPolicyRowObject, readSessionPolicyMark } from "../session-policy-store.js";
7
7
  import { thrownText } from "../../../core/thrown-value.js";
8
8
  import { AdoptionError, NOT_MIGRATED_BY_DESIGN, readRootAdoptionFile, writeRootAdoptionFile, } from "./marker.js";
9
9
  const CONFIG_ACCOUNT_FILE = "adoption-configs.json";
@@ -209,16 +209,10 @@ function rewriteSessionPolicyRows(root, fromPrincipal, toPrincipal) {
209
209
  continue;
210
210
  throw err;
211
211
  }
212
- let parsed;
213
- try {
214
- parsed = JSON.parse(raw);
215
- }
216
- catch {
217
- continue;
218
- }
219
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
212
+ const judged = judgeSessionPolicyRowObject(raw);
213
+ if ("corrupt" in judged)
220
214
  continue;
221
- const row = parsed;
215
+ const row = judged.row;
222
216
  if (row.__principal !== fromPrincipal)
223
217
  continue;
224
218
  if (typeof row.__sid !== "string")
@@ -139,7 +139,11 @@ declare class FileDurableRulePartition implements WritableDurableRulePartition {
139
139
  private screenDurableRows;
140
140
  private write;
141
141
  list(): Promise<StoredRules>;
142
- /** The quarantine area (design/182 §5.2/§8.3): introspection only — never part of `list()`. */
142
+ /** The quarantine area (design/182 §5.2/§8.3): introspection only — never part of `list()`. Its posture
143
+ * on an unreadable bucket is `list()`'s, for the reason `list()` has it: "the quarantine is empty" and
144
+ * "the bucket cannot be read" are different facts about rows a person is meant to go and look at, and a
145
+ * face that answers the first about the second is the one shape this store never renders. An ABSENT
146
+ * bucket is still an empty quarantine. */
143
147
  quarantined(): Promise<QuarantinedRuleAdd[]>;
144
148
  /**
145
149
  * The current file, for a WRITE.
@@ -220,6 +220,8 @@ class FileDurableRulePartition {
220
220
  }
221
221
  async quarantined() {
222
222
  const r = this.read();
223
+ if ("unreadable" in r)
224
+ throw new Error(r.unreadable);
223
225
  return "file" in r ? this.screenDurableRows(r.file).quarantined : [];
224
226
  }
225
227
  current() {
@@ -307,7 +309,7 @@ class FileDurableRulePartition {
307
309
  ...(joined.quarantined.length > 0 ? { quarantined: joined.quarantined } : {}),
308
310
  };
309
311
  if (!this.writeAndVerify(landed)) {
310
- throw new Error("the permission-rule store did not survive its own write; the change was not committed");
312
+ throw new Error("the permission-rule store did not read back as the version just written; whether the change reached the bytes on disk is unobservable from here — reconcile by reading the store");
311
313
  }
312
314
  return { rev: cur.rev + 1, sync: report };
313
315
  }
@@ -334,7 +336,7 @@ class FileDurableRulePartition {
334
336
  }
335
337
  }
336
338
  if (!this.writeAndVerify(next)) {
337
- throw new Error("the permission-rule store did not survive its own write; the change was not committed");
339
+ throw new Error("the permission-rule store did not read back as the version just written; whether the change reached the bytes on disk is unobservable from here — reconcile by reading the store");
338
340
  }
339
341
  return { rev: cur.rev + 1 };
340
342
  }),
@@ -452,7 +454,7 @@ class FileOrgRuleStatePersistence {
452
454
  atomicPublish(this.dir, this.file, JSON.stringify({ ...body, checksum: checksumOf(body) }, null, 2));
453
455
  const after = this.readSync();
454
456
  if (after === undefined || canonicalize(after) !== canonicalize(state)) {
455
- const why = "the org state file did not survive its own write; the org snapshot was not installed";
457
+ const why = "the org state file did not read back as the version just written; whether the snapshot reached the bytes on disk is unobservable from here — reconcile by reading the file";
456
458
  this.onError?.(why);
457
459
  throw new Error(why);
458
460
  }
@@ -31,6 +31,8 @@ export interface StoredHighWaterMark {
31
31
  __gen?: number;
32
32
  __sid?: string;
33
33
  __principal?: string | null;
34
+ /** The erase's tombstone (a torn row purged with no mark): writes are refused until an operator deletes this file. */
35
+ __lineage?: "lost";
34
36
  }
35
37
  /**
36
38
  * The ONE reader of a mark file — the number it holds, or `null` when there is no file (the ordinary state of
@@ -41,6 +43,34 @@ export interface StoredHighWaterMark {
41
43
  * these files too, and a second, more tolerant reader over there is how the guarantee goes quietly missing.
42
44
  */
43
45
  export declare function readSessionPolicyMark(path: string): StoredHighWaterMark | null;
46
+ /** Why one row file's bytes are not a row. `unparseable` is the ONE grade saying they are not JSON at all — the
47
+ * only evidence the erase sweep's crash-artifact ruling rests on. `reason` is the disclosure text, so two faces
48
+ * judging the SAME file name the same fault in the same words. */
49
+ export interface SessionPolicyRowFault {
50
+ grade: "unparseable" | "malformed";
51
+ reason: string;
52
+ }
53
+ /** One row file's bytes → the JSON object every face of this directory requires before reading a field off it.
54
+ * Split from the rules-shape judgment below because the erase sweep and the ownership rebind treat a row as
55
+ * bytes-with-attribution: a malformed-rules row is still erasable and still travels with its key. */
56
+ export declare function judgeSessionPolicyRowObject(raw: string): {
57
+ row: Record<string, unknown>;
58
+ } | {
59
+ corrupt: SessionPolicyRowFault;
60
+ };
61
+ /**
62
+ * The SHAPE of a stored row, judged ONCE for every face that serves its rules to a reader — the per-key read
63
+ * and the session-wide enumeration alike, so one store cannot answer two things about one file.
64
+ *
65
+ * Required: `rev` >= 1 (a rev-0 row would read as the live reader's SYNTHETIC empty seed); `gen`, when present, a
66
+ * whole number; every rule field a string array (a STRING `rev` poisons the CAS arithmetic). The `__sid`/`__principal`
67
+ * metadata is STRIPPED so the value is byte-identical to the in-memory twin's.
68
+ */
69
+ export declare function judgeSessionPolicyRowShape(row: Record<string, unknown>): {
70
+ rules: StoredSessionRules;
71
+ } | {
72
+ corrupt: SessionPolicyRowFault;
73
+ };
44
74
  export declare class FileSessionPolicyStore implements SessionPolicyStore {
45
75
  private readonly dir;
46
76
  private readonly onCorruptRead;
@@ -71,6 +101,8 @@ export declare class FileSessionPolicyStore implements SessionPolicyStore {
71
101
  private basenameFor;
72
102
  private markPathFor;
73
103
  private writeMarkFile;
104
+ /** The mark as given — the erase's lineage transition rewrites a mark keeping whatever attribution it had. */
105
+ private writeMark;
74
106
  private read;
75
107
  getRules(sessionId: string, principal?: string): Promise<StoredSessionRules | null>;
76
108
  putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
@@ -40,6 +40,34 @@ export function readSessionPolicyMark(path) {
40
40
  return refuse("carries a lineage that is not a whole number");
41
41
  return mark;
42
42
  }
43
+ export function judgeSessionPolicyRowObject(raw) {
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(raw);
47
+ }
48
+ catch {
49
+ return { corrupt: { grade: "unparseable", reason: "unparseable JSON (torn/zero-byte write?)" } };
50
+ }
51
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
52
+ return { corrupt: { grade: "malformed", reason: "not a JSON object" } };
53
+ }
54
+ return { row: parsed };
55
+ }
56
+ export function judgeSessionPolicyRowShape(row) {
57
+ const bad = (reason) => ({ corrupt: { grade: "malformed", reason } });
58
+ if (typeof row.rev !== "number" || !Number.isFinite(row.rev) || row.rev < 1)
59
+ return bad("rev is not a positive number");
60
+ if (row.gen !== undefined && (typeof row.gen !== "number" || !Number.isInteger(row.gen) || row.gen < 0))
61
+ return bad("gen is not a whole number");
62
+ const strArr = (x) => x === undefined || (Array.isArray(x) && x.every((e) => typeof e === "string"));
63
+ if (!strArr(row.toolAllow) || !strArr(row.toolDeny) || !strArr(row.commandAllow) || !strArr(row.commandDeny) || !strArr(row.allowDirs)) {
64
+ return bad("malformed rule field");
65
+ }
66
+ const { __sid: _sid, __principal: _principal, ...clean } = row;
67
+ void _sid;
68
+ void _principal;
69
+ return { rules: clean };
70
+ }
43
71
  export class FileSessionPolicyStore {
44
72
  dir;
45
73
  onCorruptRead;
@@ -75,7 +103,9 @@ export class FileSessionPolicyStore {
75
103
  return join(this.dir, `${this.basenameFor(sessionId, principal)}${MARK_SUFFIX}`);
76
104
  }
77
105
  writeMarkFile(path, rev, sessionId, principal, gen) {
78
- const mark = { __hwm: rev, __gen: gen, __sid: sessionId, __principal: principal };
106
+ this.writeMark(path, { __hwm: rev, __gen: gen, __sid: sessionId, __principal: principal });
107
+ }
108
+ writeMark(path, mark) {
79
109
  atomicWriteFile(join(this.dir, "tmp"), path, JSON.stringify(mark));
80
110
  }
81
111
  read(sessionId, principal) {
@@ -88,35 +118,17 @@ export class FileSessionPolicyStore {
88
118
  return null;
89
119
  throw err;
90
120
  }
91
- try {
92
- const parsed = JSON.parse(raw);
93
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
94
- this.discloseCorrupt(sessionId, principal, "not a JSON object");
95
- return null;
96
- }
97
- const r = parsed;
98
- if (typeof r.rev !== "number" || !Number.isFinite(r.rev) || r.rev < 1) {
99
- this.discloseCorrupt(sessionId, principal, "rev is not a positive number");
100
- return null;
101
- }
102
- if (r.gen !== undefined && (typeof r.gen !== "number" || !Number.isInteger(r.gen) || r.gen < 0)) {
103
- this.discloseCorrupt(sessionId, principal, "gen is not a whole number");
104
- return null;
105
- }
106
- const strArr = (x) => x === undefined || (Array.isArray(x) && x.every((e) => typeof e === "string"));
107
- if (!strArr(r.toolAllow) || !strArr(r.toolDeny) || !strArr(r.commandAllow) || !strArr(r.commandDeny) || !strArr(r.allowDirs)) {
108
- this.discloseCorrupt(sessionId, principal, "malformed rule field");
109
- return null;
110
- }
111
- const { __sid: _sid, __principal: _principal, ...clean } = r;
112
- void _sid;
113
- void _principal;
114
- return clean;
121
+ const judged = judgeSessionPolicyRowObject(raw);
122
+ if ("corrupt" in judged) {
123
+ this.discloseCorrupt(sessionId, principal, judged.corrupt.reason);
124
+ return null;
115
125
  }
116
- catch {
117
- this.discloseCorrupt(sessionId, principal, "unparseable JSON (torn/zero-byte write?)");
126
+ const shaped = judgeSessionPolicyRowShape(judged.row);
127
+ if ("corrupt" in shaped) {
128
+ this.discloseCorrupt(sessionId, principal, shaped.corrupt.reason);
118
129
  return null;
119
130
  }
131
+ return shaped.rules;
120
132
  }
121
133
  async getRules(sessionId, principal) {
122
134
  return this.read(sessionId, principal);
@@ -134,8 +146,12 @@ export class FileSessionPolicyStore {
134
146
  }
135
147
  }
136
148
  const mark = readSessionPolicyMark(this.markPathFor(sessionId, principal));
149
+ const rowFilePresent = prior === null && existsSync(this.pathFor(sessionId, principal));
150
+ if (mark?.__lineage === "lost" || (rowFilePresent && mark === null)) {
151
+ throw new SessionPolicyError("corrupt", `session rules for ${JSON.stringify(sessionId)} have no lineage evidence (${mark?.__lineage === "lost" ? "the erase left a lost-lineage mark" : "an unreadable row and no high-water mark"}) — refusing to restart the key's lineage by guessing; delete the mark${rowFilePresent ? " and the row file" : ""} to restart it`);
152
+ }
137
153
  const floor = Math.max(prior?.rev ?? 0, mark?.__hwm ?? 0);
138
- const gen = prior?.gen ?? (existsSync(this.pathFor(sessionId, principal)) ? (mark?.__gen ?? 0) + 1 : (mark?.__gen ?? 0));
154
+ const gen = prior !== null ? (prior.gen ?? 0) : rowFilePresent ? (mark?.__gen ?? 0) + 1 : (mark?.__gen ?? 0);
139
155
  const next = { ...clean, rev: floor + 1, gen };
140
156
  const attributedPrincipal = (principal ?? this.anonymousAlias) ?? null;
141
157
  this.writeMarkFile(this.markPathFor(sessionId, principal), next.rev, sessionId, attributedPrincipal, gen);
@@ -169,24 +185,28 @@ export class FileSessionPolicyStore {
169
185
  }
170
186
  continue;
171
187
  }
172
- let parsed;
173
- try {
174
- parsed = JSON.parse(raw);
188
+ const judged = judgeSessionPolicyRowObject(raw);
189
+ if ("corrupt" in judged) {
190
+ this.disclose({ sessionId, path: full, reason: judged.corrupt.reason });
191
+ continue;
175
192
  }
176
- catch {
177
- this.disclose({ sessionId, path: full, reason: "unparseable JSON (torn/zero-byte write?)" });
193
+ if (typeof judged.row.__sid !== "string") {
194
+ this.disclose({ sessionId, path: full, reason: "session metadata is not a string" });
178
195
  continue;
179
196
  }
180
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
181
- this.disclose({ sessionId, path: full, reason: "not a JSON object" });
197
+ if (judged.row.__sid !== sessionId)
198
+ continue;
199
+ const shaped = judgeSessionPolicyRowShape(judged.row);
200
+ if ("corrupt" in shaped) {
201
+ this.disclose({ sessionId, path: full, reason: shaped.corrupt.reason });
182
202
  continue;
183
203
  }
184
- const r = parsed;
185
- if (r.__sid !== sessionId)
204
+ const attributed = judged.row.__principal;
205
+ if (attributed !== undefined && attributed !== null && typeof attributed !== "string") {
206
+ this.disclose({ sessionId, path: full, reason: "principal metadata is not a string" });
186
207
  continue;
187
- const { __sid: _sid, __principal, ...rules } = r;
188
- void _sid;
189
- out.push({ principal: __principal ?? undefined, rules: rules });
208
+ }
209
+ out.push({ principal: attributed ?? undefined, rules: shaped.rules });
190
210
  }
191
211
  return out;
192
212
  }
@@ -212,21 +232,25 @@ export class FileSessionPolicyStore {
212
232
  catch {
213
233
  continue;
214
234
  }
215
- let parsed;
216
- try {
217
- parsed = JSON.parse(raw);
218
- }
219
- catch {
235
+ const judged = judgeSessionPolicyRowObject(raw);
236
+ if ("corrupt" in judged) {
237
+ if (judged.corrupt.grade !== "unparseable")
238
+ continue;
220
239
  const tornMarkPath = join(this.dir, `${file.slice(0, -".json".length)}${MARK_SUFFIX}`);
221
240
  try {
222
241
  const tornMark = readSessionPolicyMark(tornMarkPath);
223
- if (tornMark !== null && tornMark.__sid === sessionId) {
224
- this.writeMarkFile(tornMarkPath, tornMark.__hwm, sessionId, tornMark.__principal ?? null, (tornMark.__gen ?? 0) + 1);
242
+ if (tornMark === null) {
243
+ this.writeMark(tornMarkPath, { __hwm: 0, __gen: 0, __lineage: "lost" });
244
+ this.disclose({ sessionId, path: join(this.dir, file), reason: "erase purged a torn row that had no high-water mark: the key's lineage is lost, writes are refused until the mark is deleted" });
245
+ }
246
+ else {
247
+ this.writeMark(tornMarkPath, { ...tornMark, __gen: (tornMark.__gen ?? 0) + 1 });
225
248
  }
226
249
  }
227
250
  catch (err) {
228
251
  if (!(err instanceof SessionPolicyError))
229
252
  throw err;
253
+ this.disclose({ sessionId, path: join(this.dir, file), reason: `erase purged a torn row whose high-water mark is corrupt (${err.message}); the mark is left for the operator` });
230
254
  }
231
255
  try {
232
256
  unlinkSync(join(this.dir, file));
@@ -237,9 +261,7 @@ export class FileSessionPolicyStore {
237
261
  }
238
262
  continue;
239
263
  }
240
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
241
- continue;
242
- const row = parsed;
264
+ const row = judged.row;
243
265
  if (row.__sid !== sessionId)
244
266
  continue;
245
267
  const markPath = join(this.dir, `${file.slice(0, -".json".length)}${MARK_SUFFIX}`);
@@ -12,10 +12,15 @@
12
12
  * tools' execute legs judge nothing: what they run is what this station admitted, and the admission only
13
13
  * re-establishes the SAME candidates (a symlink re-pointed while the approver was thinking) — it never
14
14
  * parses text or judges a word the station did not.
15
+ *
16
+ * That pipeline is written ONCE (`runShellReadStation`) and has two entries: the gate input above, and
17
+ * {@link judgeShellRead}, the offline face. The steps between stay private — the station's working, not
18
+ * its answer, and a caller composing them would be a second judgment of the same question.
15
19
  */
16
20
  import type { ExecutionEnv } from "../../internal/harness-types.js";
17
21
  import { violationDetails } from "./safety.js";
18
- import { type ShellReadBoundary, type ShellReadDisposition, type ShellReadFinding, type ShellReadSeat } from "./shell-read-judge.js";
22
+ import { type ShellReadPlan } from "./shell-read-plan.js";
23
+ import { type EstablishedRead, type ShellReadBoundary, type ShellReadDisposition, type ShellReadFinding, type ShellReadSeat } from "./shell-read-judge.js";
19
24
  import type { ReadRootGrantCandidate } from "../../core/tool-spec.js";
20
25
  /** The ONE mint of the containment card — every arm that refuses or asks on a containment finding (a refusal, a
21
26
  * mandated card's own card, a station that could not answer, the pre-spawn re-establishment) spells it here. */
@@ -85,6 +90,58 @@ export interface ShellReadStationOptions {
85
90
  /** The listed read verbs (decides whether a segment is read by verb slots or word by word). */
86
91
  readonly readers?: ReadonlySet<string>;
87
92
  }
93
+ /** What a consumer hands {@link judgeShellRead}: the command as written, the environment whose filesystem
94
+ * answers where its words land (symlinks resolved, patterns expanded — a `NodeExecutionEnv` over a real
95
+ * directory is what the gate itself runs on), the boundary it is judged against, and the trust-policy seat
96
+ * it is judged on. The working directory a relative operand starts from is the boundary's own `cwd`. */
97
+ export interface ShellReadJudgeInput {
98
+ readonly command: string;
99
+ readonly env: ExecutionEnv;
100
+ readonly boundary: ShellReadBoundary;
101
+ readonly seat: ShellReadSeat;
102
+ /** The listed read verbs — which words of a segment are read operands. Defaults to the classify face's. */
103
+ readonly readers?: ReadonlySet<string>;
104
+ /** The face named in a refusal sentence. Defaults to `Bash`. */
105
+ readonly tool?: string;
106
+ /** Handed to the ENVIRONMENT's own primitives (`canonicalPath`, `listDir`) while the reads are
107
+ * established; whether an abort stops one is the adapter's answer, and a walk short enough to finish
108
+ * first finishes. It is NOT a deadline and it does not reach `boundary.alsoInside`, whose signature
109
+ * carries no signal — see {@link judgeShellRead} on which layer owns the bound. */
110
+ readonly signal?: AbortSignal;
111
+ }
112
+ /** What {@link judgeShellRead} answers about ONE command. */
113
+ export interface ShellReadJudgment {
114
+ /** What the station read out of the command's text before any filesystem was consulted. */
115
+ readonly plan: ShellReadPlan;
116
+ /** Every read candidate the station established, in the plan's own order, nested plans included — each
117
+ * carrying how far it got (`EstablishedRead.established`) and the target it was judged on. */
118
+ readonly establishment: readonly EstablishedRead[];
119
+ readonly verdict: ShellReadVerdict;
120
+ /** The directory-scoped grant offer THIS STATION is willing to make — the verdict's candidate through the
121
+ * same validator the gate applies, so a candidate that validator drops is absent here as it is there. It
122
+ * is the station's offer, not the composed card's: over an ask another layer already raised, the fold
123
+ * adds the mandate and nothing else, so the offer does not land there. */
124
+ readonly offer?: ReadRootGrantCandidate;
125
+ }
126
+ /**
127
+ * The station's judgment of ONE command, OUTSIDE a run — the consumer face of everything above. Same plan,
128
+ * same establishment, same judge, same table, same verdict the gate fold reads: this is
129
+ * {@link createShellReadStation}'s own derivation with the admission bookkeeping left out, so a caller
130
+ * holding a command, an environment and a boundary gets the decision the gate would reach for them.
131
+ *
132
+ * An ENVIRONMENT question, not a text question: a symlink that leaves the roots, a pattern that expands
133
+ * onto a deny row, a traversal root that cannot be enumerated are answered by `env`, not guessed from the
134
+ * spelling. {@link judgeShellReadLexically} is the no-environment reading and not a substitute — it is
135
+ * LAXER where only a filesystem sees the escape (an in-root symlink pointing out vouches) and answers a
136
+ * pattern on its spelling alone, so the two diverge in both directions.
137
+ *
138
+ * It carries NO deadline. Bounding an environment-facing wait and substituting a fail-closed verdict is the
139
+ * gate FOLD's policy on the gate's one deadline seat; the station has never held one on either entry, and a
140
+ * second default here would decide the same number twice and turn a slow-but-correct filesystem's true
141
+ * answer into a fail-closed one unasked. A consumer wanting that posture races its own deadline and reads
142
+ * the row the fold reads: `SHELL_READ_DECISION.unestablishable[seat]`.
143
+ */
144
+ export declare function judgeShellRead(input: ShellReadJudgeInput): Promise<ShellReadJudgment>;
88
145
  /**
89
146
  * The station's verdict with NO environment — the lexical reading (what an isolated sandbox gets, and what a
90
147
  * pin or a lexical pre-read can compute): the same plan, the same judge, the same table, over the folded
@@ -7,6 +7,7 @@ import { bashClassifyDefaultAllow } from "./bash-readonly-classifier.js";
7
7
  import { planShellReads } from "./shell-read-plan.js";
8
8
  import { createShellReadEstablisher, establishShellReadsLexically } from "./shell-read-establish.js";
9
9
  import { allFindings, decideShellRead, foldShellReadFindings, judgeEstablishedPlan, readRootGrantCandidateOf, } from "./shell-read-judge.js";
10
+ import { normalizeReadRootCandidate } from "../../core/read-root-candidate.js";
10
11
  import { wordsOf, lexShellText } from "./bash-lexer.js";
11
12
  function containmentDetails(paths) {
12
13
  return { type: "readonly_out_of_root", code: "readonly_out_of_root", paths };
@@ -113,6 +114,48 @@ function judgePlan(plan, established, boundary) {
113
114
  return nested === undefined ? { plan: n, segments: [] } : judgePlan(n, nested, boundary);
114
115
  });
115
116
  }
117
+ async function applyAlsoInside(judged, boundary) {
118
+ const alsoInside = boundary.alsoInside;
119
+ if (alsoInside === undefined)
120
+ return judged;
121
+ const rescued = new Set();
122
+ for (const f of allFindings(judged)) {
123
+ if (f.kind !== "outside_roots" || f.read.target === undefined || rescued.has(f.read.target))
124
+ continue;
125
+ if (await alsoInside(f.read.target))
126
+ rescued.add(f.read.target);
127
+ }
128
+ if (rescued.size === 0)
129
+ return judged;
130
+ const rewrite = (j) => ({
131
+ plan: j.plan,
132
+ segments: j.segments.map((s) => ({
133
+ segment: s.segment,
134
+ findings: s.findings.map((f) => (f.kind === "outside_roots" && f.read.target !== undefined && rescued.has(f.read.target) ? { ...f, kind: "none", containment: "inside" } : f)),
135
+ nested: s.nested.map(rewrite),
136
+ })),
137
+ });
138
+ return rewrite(judged);
139
+ }
140
+ async function runShellReadStation(tool, command, declared, seat, readers, establisher, signal) {
141
+ const plan = planShellReads(command, declarationsOf(declared, readers));
142
+ const established = await establisher.establish(plan, declared, signal);
143
+ const boundary = { ...declared, roots: established.roots };
144
+ const judged = await applyAlsoInside(judgePlan(plan, established, boundary), boundary);
145
+ const verdict = verdictOf(tool, seat, foldShellReadFindings(judged), judged, boundary);
146
+ return { plan, established, judged, boundary, verdict };
147
+ }
148
+ export async function judgeShellRead(input) {
149
+ const readers = input.readers ?? new Set(bashClassifyDefaultAllow());
150
+ const run = await runShellReadStation(input.tool ?? "Bash", input.command, input.boundary, input.seat, readers, createShellReadEstablisher(input.env, input.boundary.roots), input.signal);
151
+ const offer = normalizeReadRootCandidate(run.verdict.readRootCandidate);
152
+ return {
153
+ plan: run.plan,
154
+ establishment: allFindings(run.judged).map((f) => f.read),
155
+ verdict: run.verdict,
156
+ ...(offer !== undefined ? { offer } : {}),
157
+ };
158
+ }
116
159
  export function judgeShellReadLexically(command, boundary, seat, opts) {
117
160
  const readers = opts?.readers ?? new Set(bashClassifyDefaultAllow());
118
161
  const plan = planShellReads(command, declarationsOf(boundary, readers));
@@ -141,29 +184,6 @@ export function createShellReadStation(opts) {
141
184
  }
142
185
  admissions.set(toolCallId, a);
143
186
  };
144
- const applyAlsoInside = async (judged, boundary) => {
145
- const alsoInside = boundary.alsoInside;
146
- if (alsoInside === undefined)
147
- return judged;
148
- const rescued = new Set();
149
- for (const f of allFindings(judged)) {
150
- if (f.kind !== "outside_roots" || f.read.target === undefined || rescued.has(f.read.target))
151
- continue;
152
- if (await alsoInside(f.read.target))
153
- rescued.add(f.read.target);
154
- }
155
- if (rescued.size === 0)
156
- return judged;
157
- const rewrite = (j) => ({
158
- plan: j.plan,
159
- segments: j.segments.map((s) => ({
160
- segment: s.segment,
161
- findings: s.findings.map((f) => (f.kind === "outside_roots" && f.read.target !== undefined && rescued.has(f.read.target) ? { ...f, kind: "none", containment: "inside" } : f)),
162
- nested: s.nested.map(rewrite),
163
- })),
164
- });
165
- return rewrite(judged);
166
- };
167
187
  const check = {
168
188
  judge(toolName, args, cwd, signal, callOpts) {
169
189
  if (!opts.tools.has(toolName))
@@ -176,16 +196,10 @@ export function createShellReadStation(opts) {
176
196
  return null;
177
197
  const seat = callOpts.seat ?? opts.seatOf(toolName);
178
198
  return (async () => {
179
- const plan = planShellReads(command, declarationsOf(boundary, readers));
180
- const establisher = establisherFor(boundary.roots);
181
- const established = await establisher.establish(plan, boundary, signal);
182
- const judgedBoundary = { ...boundary, roots: established.roots };
183
- const judged = await applyAlsoInside(judgePlan(plan, established, judgedBoundary), judgedBoundary);
184
- const fold = foldShellReadFindings(judged);
185
- const verdict = verdictOf(toolName, seat, fold, judged, judgedBoundary);
186
- if (verdict.disposition !== "refuse" && callOpts.toolCallId !== undefined)
187
- remember(callOpts.toolCallId, { toolName, boundary: judgedBoundary, established, command });
188
- return verdict;
199
+ const run = await runShellReadStation(toolName, command, boundary, seat, readers, establisherFor(boundary.roots), signal);
200
+ if (run.verdict.disposition !== "refuse" && callOpts.toolCallId !== undefined)
201
+ remember(callOpts.toolCallId, { toolName, boundary: run.boundary, established: run.established, command });
202
+ return run.verdict;
189
203
  })();
190
204
  },
191
205
  unanswered(toolName, cause) {