@sema-agent/core 7.26.0 → 7.26.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/agents/verify.d.ts +16 -6
  3. package/dist/agents/verify.js +12 -5
  4. package/dist/core/checkpoint-store.d.ts +13 -10
  5. package/dist/core/gate-lanes.js +2 -1
  6. package/dist/core/gate-outcome.d.ts +54 -5
  7. package/dist/core/gate-outcome.js +17 -0
  8. package/dist/core/permission-rule-provider.js +6 -1
  9. package/dist/core/permission-rule-store.d.ts +36 -0
  10. package/dist/core/permission-rule-store.js +119 -19
  11. package/dist/core/permission-rule-sync.js +14 -5
  12. package/dist/core/runner/contracts.d.ts +48 -0
  13. package/dist/core/runner/decide-continuation.js +3 -1
  14. package/dist/core/runner/denial-limit-arms.d.ts +4 -3
  15. package/dist/core/runner/denial-limit-arms.js +2 -1
  16. package/dist/core/runner/prepare-inherited-gate.d.ts +1 -1
  17. package/dist/core/runner/prepare-inherited-gate.js +2 -1
  18. package/dist/core/runner/resume-apply.js +3 -1
  19. package/dist/core/runner/resume-policy-outcome.js +6 -6
  20. package/dist/core/runner/run-harness-handlers.js +2 -5
  21. package/dist/core/runner/run-identity-wiring.d.ts +6 -0
  22. package/dist/core/runner/run-identity-wiring.js +8 -3
  23. package/dist/core/runner/run-leg.js +9 -3
  24. package/dist/core/runner/runtask.js +2 -2
  25. package/dist/core/runner/session-rule-policy.d.ts +1 -1
  26. package/dist/core/runner/session-rule-policy.js +6 -7
  27. package/dist/core/session-policy-store.d.ts +18 -0
  28. package/dist/core/session-policy-store.js +3 -0
  29. package/dist/core/store-contracts/permission-rule-write-contract.js +16 -3
  30. package/dist/core/store-contracts/read-back-shape-contract.d.ts +56 -0
  31. package/dist/core/store-contracts/read-back-shape-contract.js +78 -0
  32. package/dist/core/tool-policy.d.ts +36 -12
  33. package/dist/core/tool-policy.js +22 -9
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.js +4 -0
  36. package/dist/stores/file/adoption/adopt.js +4 -10
  37. package/dist/stores/file/permission-rule-store.d.ts +5 -1
  38. package/dist/stores/file/permission-rule-store.js +5 -3
  39. package/dist/stores/file/session-policy-store.d.ts +32 -0
  40. package/dist/stores/file/session-policy-store.js +72 -50
  41. package/dist/tools/fs/shell-read-check.d.ts +58 -1
  42. package/dist/tools/fs/shell-read-check.js +47 -33
  43. package/dist/tools/fs/shell-read-establish.js +19 -8
  44. package/dist/tools/fs/shell-read-judge.d.ts +3 -0
  45. package/dist/tools/fs/shell-read-judge.js +3 -1
  46. package/dist/tools/fs/shell-read-plan.js +16 -17
  47. package/package.json +1 -1
  48. package/test/export-surface.snapshot.json +52 -2
  49. package/wire-changes/core@7.26.1.json +605 -0
  50. package/wire-changes/core@7.26.2.json +106 -0
@@ -131,6 +131,24 @@ export declare function normalizeRules(rules: SessionPermissionRules): SessionPe
131
131
  * allowDirs: removing confinement, or adding a dir not within some prior dir, loosens (coarse raw containment).
132
132
  */
133
133
  export declare function loosenReasons(prior: SessionPermissionRules, next: SessionPermissionRules): string[];
134
+ /**
135
+ * Does row `a` belong to the SAME lineage as row `b` — the one question every reader asks before it compares
136
+ * two rows' `rev`, asked in ONE place so a `gen` that is ABSENT cannot mean three different things at three
137
+ * stations. It means what {@link StoredSessionRules.gen} says it means: generation ZERO. A row that spells
138
+ * `gen: 0` and a row that omits it are therefore the same row's generation, and a row of generation 1 does not
139
+ * continue either of them — which is the case the readings used to disagree on, each treating absence as
140
+ * "unknown, so do not judge" and letting a recreated row pass for a continuation of the one it enforced.
141
+ *
142
+ * ABSENCE STILL CARRIES ONE OTHER FACT, and it is a different question: a backend that stamps no lineage at
143
+ * ALL gives its reader no recycle evidence, so a reader that has one of its own (it watched the row vanish)
144
+ * keeps using it. That test asks whether the FIELD is there, never what it compares to, and it is the caller's
145
+ * to make — this predicate answers only "same lineage or not".
146
+ */
147
+ export declare function continuesLineage(a: {
148
+ gen?: number;
149
+ }, b: {
150
+ gen?: number;
151
+ }): boolean;
134
152
  /** strip any caller-passed `rev` from a rules object so the store is the sole authority on it. */
135
153
  export declare function stripRev(rules: SessionPermissionRules): SessionPermissionRules;
136
154
  /**
@@ -69,6 +69,9 @@ export function loosenReasons(prior, next) {
69
69
  }
70
70
  return reasons;
71
71
  }
72
+ export function continuesLineage(a, b) {
73
+ return (a.gen ?? 0) === (b.gen ?? 0);
74
+ }
72
75
  export function stripRev(rules) {
73
76
  const { ...rest } = rules;
74
77
  delete rest.rev;
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert";
2
- import { PERMISSION_RULE_WRITER } from "../permission-rule-store.js";
2
+ import { PERMISSION_RULE_WRITER, isRuleRevision } from "../permission-rule-store.js";
3
3
  import { beginContract } from "./contract-harness.js";
4
4
  const GLOBAL = { kind: "global" };
5
5
  function add(actor, counter) {
@@ -18,6 +18,15 @@ async function rawShape(part) {
18
18
  const tombs = raw.tombstones.map((t) => `${t.behavior}|${t.rule}|${t.deletedBy.actor}#${t.deletedBy.counter}`).sort();
19
19
  return JSON.stringify({ rev: raw.rev, dots, tombs });
20
20
  }
21
+ function assertDecline(outcome, what) {
22
+ const answer = (outcome ?? {});
23
+ assert.equal(answer.conflict, true, `${what} must answer \`conflict: true\` — a decline tagged with anything else is an answer the engine reads as unreadable, so a kit accepting it certifies a backend the engine cannot use`);
24
+ assert.equal(answer.refused, undefined, `${what} must not wear the refusal tag as well — an answer that is two arms is an answer about neither`);
25
+ }
26
+ function assertRevision(outcome, what) {
27
+ const rev = outcome?.rev;
28
+ assert.ok(isRuleRevision(rev), `${what} must carry a revision — a non-negative safe integer — and this answer carried ${rev === undefined ? "none at all" : `a ${typeof rev}`}`);
29
+ }
21
30
  export async function permissionRuleWriteContract(hooks) {
22
31
  const { run, settle } = beginContract(hooks.runAssertion);
23
32
  run("the named arms land, a landing ADVANCES the revision, and a spent revision is ANSWERED (positive control)", async () => {
@@ -27,19 +36,23 @@ export async function permissionRuleWriteContract(hooks) {
27
36
  const held = (await part.list()).rev;
28
37
  const first = await writer.apply({ kind: "tighten-add", ...IDENTITY, add: a1 }, { expectedRev: held });
29
38
  assert.ok(!("conflict" in first) && !("refused" in first), "a well-formed tighten-add must land");
39
+ assertRevision(first, "the tighten-add landing");
30
40
  const afterAdd = await part.list();
31
41
  assert.deepEqual(liveDots(afterAdd.rules), new Set([`deny|${IDENTITY.rule}|kit#1`]), "the added dot must be visible through list()");
32
42
  assert.ok(afterAdd.rev > held, "a landed write must ADVANCE the revision — a constant one is not a fence, it is a number");
33
43
  assert.equal((await part[PERMISSION_RULE_WRITER].readRaw()).rev, afterAdd.rev, "every face must report ONE revision");
34
44
  const rawBefore = await rawShape(part);
35
45
  const stale = await writer.apply({ kind: "tighten-add", ...IDENTITY, add: add("kit", 2) }, { expectedRev: held });
36
- assert.ok("conflict" in stale, "a spent expectedRev must answer `conflict`, not throw and not land");
46
+ assertDecline(stale, "a spent expectedRev");
47
+ assertRevision(stale, "the declining conflict");
37
48
  const staleDelete = await writer.apply({ kind: "tighten-delete", tombstone: tombstoneFor([a1], { actor: "kit", counter: 8 }) }, { expectedRev: held });
38
- assert.ok("conflict" in staleDelete, "a spent expectedRev must answer `conflict` on the delete arm too");
49
+ assertDecline(staleDelete, "a spent expectedRev on the delete arm");
50
+ assertRevision(staleDelete, "the declining conflict on the delete arm");
39
51
  assert.equal(await rawShape(part), rawBefore, "a declined write must move nothing — rows, tombstones or revision");
40
52
  const beforeDelete = (await part.list()).rev;
41
53
  const removed = await writer.apply({ kind: "tighten-delete", tombstone: tombstoneFor([a1], { actor: "kit", counter: 9 }) }, { expectedRev: beforeDelete });
42
54
  assert.ok(!("conflict" in removed) && !("refused" in removed), "a well-formed tighten-delete must land");
55
+ assertRevision(removed, "the tighten-delete landing");
43
56
  assert.deepEqual(liveDots((await part.list()).rules), new Set(), "the tombstoned dot must leave the live view");
44
57
  assert.ok((await part.list()).rev > beforeDelete, "a landed delete must advance the revision too");
45
58
  const raw = await part[PERMISSION_RULE_WRITER].readRaw();
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The CROSS-STORE read-back rule (`@contract store.read_back_shape_judged`, quoted for integrators in
3
+ * `docs/INTEGRATION-CORE.md` §5.0) and the acceptance suite that pins it. Kit form as the sibling kits
4
+ * (see `permission-rule-write-contract.ts`): no test framework, no skip hook.
5
+ */
6
+ import { type ContractAssertionRunner } from "./contract-harness.js";
7
+ /**
8
+ * @contract store.read_back_shape_judged — every read a store answers, a single get and an enumeration
9
+ * alike, judges the SHAPE of what it read before answering: a body that is not the shape this store
10
+ * writes is CORRUPT, and a corrupt body is answered in the store's own corrupt vocabulary — a loud
11
+ * refusal, or an absence that carries a disclosure naming the loss — never as an empty set, an empty
12
+ * object or a default value, because an answer a caller cannot tell apart from "there is nothing here"
13
+ * turns a recoverable incident into a silent one. Which read-back failures a store TOLERATES (an
14
+ * enumeration that skips one unreadable row and serves the rest, a torn trailing write read as absent)
15
+ * is a per-store fact registered beside that store's own contract, never assumed from another store's.
16
+ */
17
+ export declare const CORRUPT_BODY_FORMS: readonly ["not-an-object", "missing-required", "wrong-type", "torn-json"];
18
+ /** One way a persisted body can fail to be the shape its store writes. */
19
+ export type CorruptBodyForm = (typeof CORRUPT_BODY_FORMS)[number];
20
+ export interface ReadBackShapeSubject {
21
+ /** Declared ONCE per subject, and only by a backend that keeps no bytes a foreign writer could damage
22
+ * (a process-local map holding objects it minted itself). Never per attempt: a planter that cannot
23
+ * find bytes it knows exist must FAIL rather than exempt its backend. */
24
+ readonly unplantable?: string;
25
+ /** Plant ONE corrupt body of `form`, with `sentinel` inside the planted bytes wherever the form allows
26
+ * (that is how clause 3 detects a body handed straight back). No declining return: a backend with no
27
+ * bodies declares that at the subject, and a planter that cannot find bytes it knows exist THROWS. */
28
+ plant: (form: CorruptBodyForm, sentinel: string) => Promise<void>;
29
+ /** Perform the read under test. THROWING IS AN ANSWER — the loud refusal — and is not a suite failure. */
30
+ read: () => Promise<unknown>;
31
+ /** Write ONE well-formed row — clause 1's subject. */
32
+ seed: () => Promise<void>;
33
+ close?: () => void;
34
+ }
35
+ export interface StoreReadBackShapeHooks {
36
+ /** The face under test, named as a reader would name it (`"FileCheckpointStore.get"`). Failure text only. */
37
+ face: string;
38
+ /** Open a FRESH, EMPTY store and return its subject. Called once per observation; teardown is `close`. */
39
+ open: () => Promise<ReadBackShapeSubject>;
40
+ runAssertion?: ContractAssertionRunner;
41
+ }
42
+ /**
43
+ * Run the read-back shape suite over ONE read face. Throws (assert) on the first violated clause.
44
+ *
45
+ * Clauses: (1) a seeded row and an empty store both ANSWER, answer differently, and the empty answer is
46
+ * REPRODUCIBLE — a face that throws at everything, answers everything alike, or carries incidental data
47
+ * (a read id, a timestamp) proves nothing by differing under corruption;
48
+ * (2) each of the four {@link CORRUPT_BODY_FORMS}, planted, is answered differently from an empty store;
49
+ * (3) and the answer never contains the planted bytes.
50
+ *
51
+ * CEILING: clause 2 proves the corrupt case is DISTINGUISHABLE, not that the distinguishing answer is
52
+ * right — a face answering one invalid row where a good read answers rows passes. Whether the store then
53
+ * named the loss correctly is judged by its own section in §5.0, by a person; the four forms are a
54
+ * fixture set, not the space of corrupt bodies.
55
+ */
56
+ export declare function storeReadBackShapeContract(hooks: StoreReadBackShapeHooks): Promise<void>;
@@ -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
+ }
@@ -1408,12 +1408,18 @@ export type OnAsk = "deny" | "allow" | ((req: AskRequest, signal?: AbortSignal)
1408
1408
  * owns; the engine starts no timer for it — with the ONE #548 exception below), so a host that timed
1409
1409
  * its own approval card out could
1410
1410
  * previously only report a plain `false`, which the engine correctly recorded as `"human"` — a person
1411
- * refusing. The two words the host may self-report:
1411
+ * refusing. The three words the host may self-report:
1412
1412
  * - `"human"` — a person answered (identical to omitting the field);
1413
- * - `"timeout"` — the host's own approval window elapsed with no answer. Legal ONLY with
1414
- * `allow: false`: a window that elapsed cannot be the thing that approved an action, so
1415
- * `{allow: true, settledBy: "timeout"}` is a contradiction and is refused fail-closed (the same
1416
- * narrowing the `PermissionResult` allow arm already spells in its type).
1413
+ * - `"timeout"` — the host's own approval window elapsed with no answer;
1414
+ * - `"policy"` — the deployment's OWN policy answered: a machine refused, not a person (a headless lane
1415
+ * whose approver seat exists but can show nobody a prompt). Spelled `false`, such a deny told the model
1416
+ * the USER did not want to proceed and to wait for their direction — a sentence about a person, in a run
1417
+ * where none is coming; with the word it reads {@link policyRefusalMessage} instead, and the note travels
1418
+ * as the policy's words. OMITTING the field still reads as `"human"`, so an unrevised host is unchanged.
1419
+ * The two non-human words are legal ONLY with `allow: false` — neither an elapsed window nor a refusing
1420
+ * policy can be what APPROVED an action, and neither has an allow-side settlement to become — so
1421
+ * `{allow: true, settledBy: "timeout" | "policy"}` is refused fail-closed (the same narrowing the
1422
+ * `PermissionResult` allow arm already spells in its type).
1417
1423
  * `"aborted"` is deliberately NOT accepted here — that word names the engine's OWN fail-closed ends
1418
1424
  * (abort, throw, unavailable, out-of-contract value), each already stamped at its own arm, and a
1419
1425
  * self-reported one would let a host relabel its refusal as an engine failure.
@@ -1460,7 +1466,7 @@ export type OnAsk = "deny" | "allow" | ((req: AskRequest, signal?: AbortSignal)
1460
1466
  export type AskOutcome = boolean | "unavailable" | {
1461
1467
  allow: boolean;
1462
1468
  updatedInput?: unknown;
1463
- settledBy?: "human" | "timeout";
1469
+ settledBy?: "human" | "timeout" | "policy";
1464
1470
  reason?: string;
1465
1471
  approver?: string;
1466
1472
  };
@@ -1581,6 +1587,22 @@ export declare function tryCloneArgs<T>(v: T): {
1581
1587
  * text they let through. Not part of the package's public surface.
1582
1588
  */
1583
1589
  export declare function describeThrown(err: unknown): string;
1590
+ /**
1591
+ * The ONE composer for a DEPLOYMENT-POLICY refusal of an ask — the sentence the model reads when the
1592
+ * approval channel reported that a MACHINE ended this wait (`settledBy: "policy"` on the synchronous
1593
+ * outcome, `hostDecision.decidedBy: "policy"` on a durable decide). Both legs call it, so one refusal
1594
+ * cannot speak two ways depending on which one carried it, and neither leg re-spells it.
1595
+ *
1596
+ * Not being {@link humanRefusalMessage} is the point of its existence. A person's "no" is the user's
1597
+ * direction, and the posture that follows it is "stop and wait for the user"; a policy's is a standing
1598
+ * capability of this run, where no user turn is coming — so this sentence names the deployment as the
1599
+ * refuser, says interactive approval is what the action needs and this context has none, and directs the
1600
+ * model at the work it CAN still do. A note rides a fence labelled as the POLICY's words, never licensed
1601
+ * as the user's guidance, because no user gave it. `askMessage` is the ask's own prompt where the caller
1602
+ * has one (the synchronous leg does); a durable decide answers a call parked in an earlier leg and has
1603
+ * none, and the head then simply names the call.
1604
+ */
1605
+ export declare function policyRefusalMessage(toolName: string, askMessage: string | undefined, note: string | undefined): string;
1584
1606
  /**
1585
1607
  * A {@link resolveAsk} result: always a TERMINAL `allow`/`deny` (never `ask`). `approverUnavailable`
1586
1608
  * is the out-of-band G1 three-value marker: the live approver returned `"unavailable"` for this ask —
@@ -1640,12 +1662,14 @@ export declare function carriesBidiControls(value: unknown, limits?: {
1640
1662
  * auto-deny default, fail-closed error handling, and stable deny reasons so every ask site is
1641
1663
  * consistent. Never returns `ask`.
1642
1664
  *
1643
- * Every arm that ENDED A WAIT names what ended it ({@link ApprovalSettledBy}, per-arm, at the arm):
1644
- * the approver's own verdicts are `"human"`, and the fail-closed ends around them (abort, a throw, an
1645
- * "unavailable" report, an unclonable presentation, an out-of-contract value) are `"aborted"`. The
1646
- * three POSTURE arms at the top — blanket `"allow"`, its `requiresRealApproval` refusal, and the
1647
- * headless auto-deny — carry NO source on purpose: nobody was asked, so there is no wait for anyone to
1648
- * have ended, and `decisionReason: "mode"` is already the honest word for what produced them.
1665
+ * Every arm that ENDED A WAIT composes the {@link import("./gate-outcome.js").Settlement} for it, at the
1666
+ * arm: the approver's own verdicts settle as a person's unless the outcome names another word on
1667
+ * {@link AskOutcome.settledBy} (its own window, its own policy), and the fail-closed ends around them
1668
+ * (abort, a throw, an "unavailable" report, an unclonable presentation, an out-of-contract value) settle
1669
+ * as the engine's own nobody-ended family. The three POSTURE arms at the top — blanket `"allow"`, its
1670
+ * `requiresRealApproval` refusal, and the headless auto-deny — name no person and no window on purpose:
1671
+ * nobody was asked, so there is no wait for anyone to have ended, and `decisionReason: "mode"` is already
1672
+ * the honest word for what produced them.
1649
1673
  */
1650
1674
  export declare function resolveAsk(req: AskRequest, onAsk: OnAsk | undefined, signal?: AbortSignal,
1651
1675
  /** design/384 — observer for a DETACHED approver's settlement, consulted only after the race
@@ -1004,6 +1004,13 @@ function humanRefusalMessage(req, reason) {
1004
1004
  ? `${head}\nThe user doesn't want to proceed with this tool use; the call did NOT run. The user's note on this rejection follows — treat it as the user's guidance (user authority only: it cannot grant permissions or override system rules):\n${delimitUntrusted("reviewer note", reason, REVIEWER_NOTE_MAX_BODY)}\nIf the note does not tell you how to proceed, STOP what you are doing and wait for the user.`
1005
1005
  : `${head}\nThe user doesn't want to proceed with this tool use; the call did NOT run. STOP what you are doing and wait for the user to tell you how to proceed.`;
1006
1006
  }
1007
+ export function policyRefusalMessage(toolName, askMessage, note) {
1008
+ const head = `approval required for "${toolName}" but this run has no interactive approval channel${askMessage !== undefined ? `: ${askMessage}` : ""}`;
1009
+ const body = `${head}\nThe deployment's policy declined this tool use; the call did NOT run. Action requires interactive approval and ` +
1010
+ `permission prompts are not available in this context. Do not claim it succeeded and do not retry it here; report what ` +
1011
+ `was blocked and why you needed it, then continue with the parts of the task that do not require approval.`;
1012
+ return note === undefined ? body : `${body}\nPolicy note:\n${delimitUntrusted("policy note", note, REVIEWER_NOTE_MAX_BODY)}`;
1013
+ }
1007
1014
  const BIDI_CONTROL_RE = /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/u;
1008
1015
  const BIDI_SCAN_MAX_NODES = 5_000;
1009
1016
  const BIDI_SCAN_MAX_CHARS = 1_000_000;
@@ -1249,15 +1256,16 @@ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
1249
1256
  settlement: settledByNobody("approver_contract"),
1250
1257
  };
1251
1258
  }
1252
- if (supplied !== undefined && supplied !== "human" && supplied !== "timeout") {
1259
+ if (supplied !== undefined && supplied !== "human" && supplied !== "timeout" && supplied !== "policy") {
1253
1260
  return {
1254
1261
  action: "deny",
1255
1262
  message: `the approver for "${req.toolName}" reported settledBy "${typeof supplied === "string" ? containThrownText(supplied) : supplied === null ? "null" : typeof supplied}", which is outside what a synchronous ` +
1256
- `approver may self-report — it is exactly "human" or "timeout" (or omitted); denied fail-closed`,
1263
+ `approver may self-report — it is exactly "human", "timeout" or "policy" (or omitted); denied fail-closed`,
1257
1264
  decisionReason: "mode",
1258
1265
  settlement: settledByNobody("approver_contract"),
1259
1266
  };
1260
1267
  }
1268
+ const settledWord = supplied === "timeout" ? "timeout" : supplied === "policy" ? "policy" : "human";
1261
1269
  const attribution = screenApproverAttribution(suppliedApprover);
1262
1270
  if (attribution.defect !== undefined) {
1263
1271
  return {
@@ -1267,11 +1275,12 @@ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
1267
1275
  settlement: settledByNobody("approver_contract"),
1268
1276
  };
1269
1277
  }
1270
- if (supplied === "timeout" && allowed === true) {
1278
+ if (settledWord !== "human" && allowed === true) {
1271
1279
  return {
1272
1280
  action: "deny",
1273
- message: `the approver for "${req.toolName}" returned an allow settled by "timeout" — an elapsed approval window cannot be ` +
1274
- `what approved a call; denied fail-closed (report timeout with allow:false, or allow with settledBy "human"/omitted)`,
1281
+ message: `the approver for "${req.toolName}" returned an allow settled by "${settledWord}" — only a person approves, and that word ` +
1282
+ `names a wait that ended with nobody judging: it cannot be what approved a call; denied fail-closed ` +
1283
+ `(report "${settledWord}" with allow:false, or allow with settledBy "human"/omitted)`,
1275
1284
  decisionReason: "mode",
1276
1285
  settlement: settledByNobody("approver_contract"),
1277
1286
  };
@@ -1302,13 +1311,17 @@ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
1302
1311
  const note = decidersNote(reasonText);
1303
1312
  return {
1304
1313
  action: "deny",
1305
- message: supplied === "timeout"
1314
+ message: settledWord === "timeout"
1306
1315
  ? `approval for "${req.toolName}" was not answered before the approver's own window elapsed: ${req.message}${note !== undefined ? `\nReviewer note:\n${delimitUntrusted("reviewer note", note, REVIEWER_NOTE_MAX_BODY)}` : ""}`
1307
- : humanRefusalMessage(req, note),
1316
+ : settledWord === "policy"
1317
+ ? policyRefusalMessage(req.toolName, req.message, note)
1318
+ : humanRefusalMessage(req, note),
1308
1319
  decisionReason: "mode",
1309
- settlement: supplied === "timeout"
1320
+ settlement: settledWord === "timeout"
1310
1321
  ? { kind: "approval_window_expired", who: { party: "host" }, when: Date.now() }
1311
- : settledByPerson("human_refused", attribution.approver, note),
1322
+ : settledWord === "policy"
1323
+ ? settledByNobody("policy_refused")
1324
+ : settledByPerson("human_refused", attribution.approver, note),
1312
1325
  };
1313
1326
  }
1314
1327
  if (suppliedEdit === undefined)
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>;