@sema-agent/core 5.38.0 → 5.39.0

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.
@@ -1,4 +1,5 @@
1
1
  import { strict as assert } from "node:assert";
2
+ import { MAILBOX_TOMBSTONED_RECIPIENT_CODE } from "../mailbox-store.js";
2
3
  import { beginContract } from "./contract-harness.js";
3
4
  export const MAILBOX_CONTRACT_SCOPE = "default";
4
5
  const msg = (content, sentAt = 1000) => ({ content, sentAt });
@@ -191,3 +192,80 @@ export async function mailboxBundledOnlyContract(mk, runAssertion) {
191
192
  });
192
193
  await settle();
193
194
  }
195
+ export async function mailboxTombstonedRecipientContract(mk, hooks) {
196
+ const { run: runRaw, settle } = beginContract(hooks.runAssertion);
197
+ const run = (name, fn) => runRaw(name, () => withStores(mk, fn));
198
+ const S = MAILBOX_CONTRACT_SCOPE;
199
+ const codeOf = (e) => e?.code;
200
+ run("预删除态的收件人:append 响亮拒且带码(既不静默收下级联即将删掉的消息,也不裸抛)", async (make) => {
201
+ const s = make();
202
+ await hooks.tombstone(s, S, "a1");
203
+ let thrown;
204
+ try {
205
+ await s.append(S, "a1", msg("m1"));
206
+ }
207
+ catch (e) {
208
+ thrown = e;
209
+ }
210
+ assert.notEqual(thrown, undefined, "静默收下 = 给发件人一张级联马上撕掉的回执");
211
+ assert.equal(codeOf(thrown), MAILBOX_TOMBSTONED_RECIPIENT_CODE, "裸抛与拒同形:消费方要能按码分诊");
212
+ let again;
213
+ try {
214
+ await s.append(S, "a1", msg("m2"));
215
+ }
216
+ catch (e) {
217
+ again = e;
218
+ }
219
+ assert.equal(codeOf(again), MAILBOX_TOMBSTONED_RECIPIENT_CODE);
220
+ });
221
+ run("拒的粒度是收件人,不是开关:同 scope 下另一个活收件人照常收信", async (make) => {
222
+ const s = make();
223
+ await hooks.tombstone(s, S, "a1");
224
+ assert.equal(await s.append(S, "a2", msg("still-live")), 1, "活收件人不受邻居的删除级联牵连");
225
+ assert.equal(await s.peekCount(S, "a2"), 1);
226
+ });
227
+ run("收件人身份是 (scope, handle) 整体:A 租户的删除级联不得拒掉 B 租户的同名收件人", async (make) => {
228
+ const s = make();
229
+ await hooks.tombstone(s, "tenant-a", "a1");
230
+ assert.equal(await s.append("tenant-b", "a1", msg("other-tenant")), 1, "同名 handle 在另一个租户下是另一个收件人");
231
+ assert.equal(await s.peekCount("tenant-b", "a1"), 1);
232
+ });
233
+ run("两件套还得是单射的:把 (scope, handle) 拼成一个字符串记态,不得让两个不同收件人撞成一个", async (make) => {
234
+ const s = make();
235
+ await hooks.tombstone(s, "tenant a", "b");
236
+ assert.equal(await s.append("tenant", "a b", msg("distinct-recipient")), 1, "拼键歧义不得把另一个收件人一起拒掉");
237
+ assert.equal(await s.peekCount("tenant", "a b"), 1);
238
+ });
239
+ run("副作用轴 · 活租约:被拒的 append 不得动别人手上的租约(单消费者围栏不因删除窗口松开)", async (make) => {
240
+ const s = make();
241
+ await s.append(S, "a1", msg("in-flight"));
242
+ const held = await s.claimLease(S, "a1", "consumer-A", 60_000, 10_000);
243
+ assert.equal(held?.messages.length, 1);
244
+ await hooks.tombstone(s, S, "a1");
245
+ await assert.rejects(s.append(S, "a1", msg("after-tombstone", 2_000)), "预删除态下的 append 必须拒");
246
+ assert.equal(await s.claimLease(S, "a1", "rival", 60_000, 11_000), null, "A 的活租约必须仍然 fence 住别人");
247
+ await s.ack(S, "a1", "consumer-A", held.maxSeq);
248
+ assert.equal(await s.peekCount(S, "a1"), 0);
249
+ });
250
+ run("拒的是入口,不是清仓:被拒的 append 零副作用,已入箱的消息按原 seq/内容原封不动", async (make) => {
251
+ const s = make();
252
+ const seq = await s.append(S, "a1", msg("parked-before"));
253
+ await hooks.tombstone(s, S, "a1");
254
+ let thrown;
255
+ let refused = false;
256
+ try {
257
+ await s.append(S, "a1", msg("after-tombstone", 2_000));
258
+ }
259
+ catch (e) {
260
+ refused = true;
261
+ thrown = e;
262
+ }
263
+ assert.equal(refused, true, "预删除态下的 append 必须拒");
264
+ assert.equal(codeOf(thrown), MAILBOX_TOMBSTONED_RECIPIENT_CODE, "箱非空时的拒同样要带码");
265
+ assert.equal(await s.peekCount(S, "a1"), 1, "被拒的 append 不得入箱(幽灵投递),也不得清箱");
266
+ const lease = await s.claimLease(S, "a1", "w1", 60_000, 10_000);
267
+ assert.deepEqual(lease?.messages.map((m) => m.seq), [seq]);
268
+ assert.deepEqual(lease?.messages.map((m) => m.content), ["parked-before"], "取到的仍是拒之前那一条");
269
+ });
270
+ await settle();
271
+ }
@@ -4785,6 +4785,27 @@ export interface RunnerDeps {
4785
4785
  * ignored). Same deployment-only seat, clamp argument and checkpoint posture as the tiers key.
4786
4786
  */
4787
4787
  readDenyBuiltinExclude?: readonly string[];
4788
+ /**
4789
+ * backlog #286 (#279 — CC 2.1.233 `DANGEROUS_FILES`/`DANGEROUS_DIRECTORIES`/
4790
+ * `DANGEROUS_DIRECTORY_PATHS` parity): the WRITE-protection table. DEFAULT-ON: a path-confinable
4791
+ * write (Write/Edit/NotebookEdit) whose target lands on a table row has a surviving `allow`
4792
+ * demoted to `ask` at the tool gate (`decisionReason: "safety"`; a deny/ask verdict is untouched;
4793
+ * approval flows the ordinary ask-resolution chain — classifier, blanket `onAsk`, durable park —
4794
+ * with no `requiresRealApproval` mandate). Absent =
4795
+ * {@link import("./write-protect.js").WRITE_PROTECTED_DEFAULT_TABLE} (the CC triple verbatim +
4796
+ * the two argued sema rows). This key IS the whole-table escape hatch, deployment seat ONLY (no
4797
+ * TaskSpec twin, no governed-workflow channel): `[]` = no table (explicit and legal); a non-empty
4798
+ * list REPLACES the built-in table whole (compose additions as
4799
+ * `[...WRITE_PROTECTED_DEFAULT_TABLE, …]`; drop rows by filtering the exported table — the
4800
+ * visible/deletable admin face). Bad values refuse loudly at prepare (#123): garbage shapes,
4801
+ * glob metacharacters (the table speaks LITERAL names — glob semantics live in
4802
+ * `createSensitivePathPolicy`), unknown kinds, impossible kind/name combinations. Matching is
4803
+ * lexical over the spelled target with one case fold (ı/ſ included) — a symlink alias evades it
4804
+ * by construction; the canonicalizing opt-in deny policy remains the hard layer. Not frozen into
4805
+ * checkpoints: a resumed task follows the CURRENT deployment table (an approved parked call
4806
+ * bypasses the gate as always — the human already adjudicated it).
4807
+ */
4808
+ writeProtectedPaths?: readonly import("./write-protect.js").WriteProtectedEntry[];
4788
4809
  /**
4789
4810
  * design/199 件A — the DEPLOYMENT's read-face declaration
4790
4811
  * ({@link import("../tools/fs/read-face.js").ReadFace}; see {@link TaskSpec.readFace} for the
@@ -0,0 +1,93 @@
1
+ /**
2
+ * How one table row matches the judged path:
3
+ * · `"basename"` — the target's last segment equals the row name (CC `DANGEROUS_FILES` form);
4
+ * · `"segment"` — ANY path segment equals the row name (CC `DANGEROUS_DIRECTORIES` form);
5
+ * · `"segment-run"` — a CONSECUTIVE run of segments equals the row's `/`-separated segments
6
+ * (CC `DANGEROUS_DIRECTORY_PATHS` form, e.g. `.config/git`).
7
+ */
8
+ export type WriteProtectedKind = "basename" | "segment" | "segment-run";
9
+ /** One row of the write-protection table: a LITERAL name and how it matches. The `name` is also the
10
+ * row's stable identity — the string a refusal/ask message cites. */
11
+ export interface WriteProtectedRow {
12
+ readonly name: string;
13
+ readonly kind: WriteProtectedKind;
14
+ }
15
+ /**
16
+ * One deployment-authored entry for the replacement seat (`RunnerDeps.writeProtectedPaths`).
17
+ * String shorthand: a bare name ≡ `{ name, kind: "segment" }` (the WIDER single-segment kind —
18
+ * over-matching is the fail-safe direction for a tighten); a name containing `/` ≡
19
+ * `{ name, kind: "segment-run" }`. Spell `kind: "basename"` explicitly when last-segment-only
20
+ * matching is the intent.
21
+ */
22
+ export type WriteProtectedEntry = string | WriteProtectedRow;
23
+ /** A table hit: which row matched (the row's canonical name + kind). */
24
+ export interface WriteProtectedHit {
25
+ readonly name: string;
26
+ readonly kind: WriteProtectedKind;
27
+ }
28
+ /**
29
+ * WRITE_PROTECTED_DEFAULT_TABLE — the default-active table (the material basis of the deployment
30
+ * admin face: what an unconfigured deployment demotes to `ask`; visible here, deletable by
31
+ * replacing the seat with a filtered copy). CC 2.1.233 triple VERBATIM first, then the sema rows,
32
+ * each with its argument.
33
+ *
34
+ * NOT listed, deliberately (each a ruled-out candidate, recorded so the next reader does not
35
+ * re-litigate silently):
36
+ * · `.env` / `.env.*` — CC's own table excludes them too (only `.envrc`, the direnv AUTO-EXECUTION
37
+ * vector, is in): a plain `.env` is application config and routine workspace material for the
38
+ * tasks this engine runs (the read deny set rules it out on the same grounds); the opt-in write
39
+ * DENY (`RECOMMENDED_SENSITIVE_PATTERNS`) covers deployments that want it guarded.
40
+ * · key-material FILE patterns (`id_rsa*`, `*.pem`, …) — they are glob-shaped, and this table
41
+ * speaks literals; the opt-in deny policy owns that vocabulary.
42
+ * · cloud credential dirs (`.aws`, `.kube`, `.azure`, `.config/gcloud`) — writing cloud config IS
43
+ * the routine "configure this environment" action tasks are asked to perform, so a default ask
44
+ * on every such write is recurring friction without CC precedent; the opt-in deny policy covers
45
+ * them, and the READ side already default-refuses them (reading credentials exfiltrates; writing
46
+ * a fresh config file does not).
47
+ */
48
+ export declare const WRITE_PROTECTED_DEFAULT_TABLE: readonly WriteProtectedRow[];
49
+ /**
50
+ * The ONE case fold of this module, applied to BOTH sides of every comparison (table names at
51
+ * compile, path segments at match) — `toLowerCase` plus the two confusable letters CC's fold maps
52
+ * (U+0131 dotless ı → i, U+017F long ſ → s). Deliberately NOT full Unicode confusable folding:
53
+ * that would be a different, wider claim than the one made (the read deny set's ASCII-contract doc
54
+ * states the same boundary for its own fold).
55
+ */
56
+ export declare function foldWriteProtectCase(s: string): string;
57
+ /**
58
+ * Resolve the ACTIVE table under the deployment seat (validated, loud — #123's bad-value states all
59
+ * throw and name the knob). `undefined` = the default table; `[]` = NO table (an explicit, legal
60
+ * posture — the whole-table escape hatch's empty end); a non-empty list REPLACES the default table
61
+ * whole (compose additions as `[...WRITE_PROTECTED_DEFAULT_TABLE, …]`, drop rows by filtering the
62
+ * exported table — the visible/deletable admin face). Exact duplicates fold (idempotent, not one of
63
+ * #123's bad-value states). Exported so an admin face can preview the effective table under a
64
+ * candidate configuration with the engine's own rules.
65
+ */
66
+ export declare function resolveWriteProtectedTable(entries?: readonly WriteProtectedEntry[]): readonly WriteProtectedRow[];
67
+ /** The compiled judge over one resolved table. Pure and synchronous — literal folded comparisons,
68
+ * no filesystem access (see the module header for the declared lexical scope). */
69
+ export interface WriteProtectionMatcher {
70
+ /** The resolved rows this judge was compiled from (canonical names — the disclosure face). */
71
+ readonly rows: readonly WriteProtectedRow[];
72
+ /** Judge ONE path spelling. Returns the first matching row (basename rows first, then segment,
73
+ * then segment-run — deterministic), or null. */
74
+ matchPath(path: string): WriteProtectedHit | null;
75
+ }
76
+ /**
77
+ * Compile the write-protection judge for a deployment configuration. Returns `undefined` when the
78
+ * resolved table is EMPTY (`[]` replacement) — the caller then mounts no tighten at all, keeping an
79
+ * opted-out deployment's decision path byte-identical to a build without this layer.
80
+ */
81
+ export declare function compileWriteProtection(entries?: readonly WriteProtectedEntry[]): WriteProtectionMatcher | undefined;
82
+ /**
83
+ * Build the ENGINE-FILLED gate input (`ToolGateInput.writeProtectionCheck`): covered tools are the
84
+ * path-confinable write set (Write/Edit/NotebookEdit — the shared spelling), the judged target is
85
+ * the SAME string the tool itself will resolve (`writeTargetPath`, the shared single source with
86
+ * the sensitive-path guard and the fs-write gate), and the verdict is the table judge's. A covered
87
+ * write with NO resolvable target returns null here — this layer is ADDITIVE friction on a named
88
+ * set of targets, not a containment boundary (a target-less call cannot land on a named row, and
89
+ * the tool's own schema validation refuses it before any write); the fail-closed treatment of the
90
+ * unresolvable case belongs to the containment gates (fs-write-gate-policy documents that split).
91
+ * Returns `undefined` when the resolved table is empty — nothing to judge, mount nothing.
92
+ */
93
+ export declare function createWriteProtectionCheck(entries?: readonly WriteProtectedEntry[]): ((toolName: string, args: unknown) => WriteProtectedHit | null) | undefined;
@@ -0,0 +1,194 @@
1
+ import { writeTargetPath } from "../tools/fs/safety.js";
2
+ import { PATH_CONFINABLE_WRITE_TOOLS } from "./runner/session-rule-policy.js";
3
+ export const WRITE_PROTECTED_DEFAULT_TABLE = [
4
+ { name: ".gitconfig", kind: "basename" },
5
+ { name: ".gitmodules", kind: "basename" },
6
+ { name: ".bashrc", kind: "basename" },
7
+ { name: ".bash_profile", kind: "basename" },
8
+ { name: ".zshrc", kind: "basename" },
9
+ { name: ".zprofile", kind: "basename" },
10
+ { name: ".profile", kind: "basename" },
11
+ { name: ".zshenv", kind: "basename" },
12
+ { name: ".zlogin", kind: "basename" },
13
+ { name: ".zlogout", kind: "basename" },
14
+ { name: ".bash_login", kind: "basename" },
15
+ { name: ".bash_aliases", kind: "basename" },
16
+ { name: ".bash_logout", kind: "basename" },
17
+ { name: ".envrc", kind: "basename" },
18
+ { name: ".ripgreprc", kind: "basename" },
19
+ { name: ".mcp.json", kind: "basename" },
20
+ { name: ".claude.json", kind: "basename" },
21
+ { name: ".npmrc", kind: "basename" },
22
+ { name: ".yarnrc", kind: "basename" },
23
+ { name: ".yarnrc.yml", kind: "basename" },
24
+ { name: ".pnp.cjs", kind: "basename" },
25
+ { name: ".pnp.loader.mjs", kind: "basename" },
26
+ { name: ".pnpmfile.cjs", kind: "basename" },
27
+ { name: "bunfig.toml", kind: "basename" },
28
+ { name: ".bunfig.toml", kind: "basename" },
29
+ { name: ".bazelrc", kind: "basename" },
30
+ { name: ".bazelversion", kind: "basename" },
31
+ { name: ".bazeliskrc", kind: "basename" },
32
+ { name: ".pre-commit-config.yaml", kind: "basename" },
33
+ { name: "lefthook.yml", kind: "basename" },
34
+ { name: ".lefthook.yml", kind: "basename" },
35
+ { name: "lefthook.yaml", kind: "basename" },
36
+ { name: ".lefthook.yaml", kind: "basename" },
37
+ { name: "gradle-wrapper.properties", kind: "basename" },
38
+ { name: "maven-wrapper.properties", kind: "basename" },
39
+ { name: ".devcontainer.json", kind: "basename" },
40
+ { name: "pyrightconfig.json", kind: "basename" },
41
+ { name: ".git", kind: "segment" },
42
+ { name: ".vscode", kind: "segment" },
43
+ { name: ".idea", kind: "segment" },
44
+ { name: ".claude", kind: "segment" },
45
+ { name: ".husky", kind: "segment" },
46
+ { name: ".cargo", kind: "segment" },
47
+ { name: ".devcontainer", kind: "segment" },
48
+ { name: ".yarn", kind: "segment" },
49
+ { name: ".mvn", kind: "segment" },
50
+ { name: ".config/git", kind: "segment-run" },
51
+ { name: ".ssh", kind: "segment" },
52
+ { name: ".gnupg", kind: "segment" },
53
+ ];
54
+ export function foldWriteProtectCase(s) {
55
+ return s.toLowerCase().replace(/ı/g, "i").replace(/ſ/g, "s");
56
+ }
57
+ const KINDS = ["basename", "segment", "segment-run"];
58
+ function describeEntryValue(v) {
59
+ try {
60
+ return JSON.stringify(v) ?? String(v);
61
+ }
62
+ catch {
63
+ return String(v);
64
+ }
65
+ }
66
+ function normalizeWriteProtectedEntry(entry) {
67
+ const shape = typeof entry === "string" ? { name: entry, kind: entry.includes("/") ? "segment-run" : "segment" } : entry;
68
+ if (typeof shape !== "object" || shape === null || typeof shape.name !== "string") {
69
+ throw new Error(`writeProtectedPaths: entry ${describeEntryValue(entry)} is not a name string or { name, kind } row.`);
70
+ }
71
+ const name = shape.name;
72
+ if (!KINDS.includes(shape.kind)) {
73
+ throw new Error(`writeProtectedPaths: entry ${describeEntryValue(entry)} has kind ${describeEntryValue(shape.kind)} — known kinds: ${KINDS.join(", ")}.`);
74
+ }
75
+ const kind = shape.kind;
76
+ if (name.includes("*") || name.includes("?")) {
77
+ throw new Error(`writeProtectedPaths: entry ${describeEntryValue(name)} contains a wildcard metacharacter — this table speaks LITERAL names only (a silently-literal "*" would guard less than it reads); pattern semantics live in createSensitivePathPolicy.`);
78
+ }
79
+ if (name.includes("\\")) {
80
+ throw new Error(`writeProtectedPaths: entry ${describeEntryValue(name)} contains a backslash — names are "/"-separated (both path families are matched); spell the segments with "/".`);
81
+ }
82
+ const segments = name.split("/").filter((s) => s.length > 0);
83
+ if (segments.length === 0) {
84
+ throw new Error(`writeProtectedPaths: entry ${describeEntryValue(name)} contains no path segments and would protect nothing — remove the entry or spell the name.`);
85
+ }
86
+ if (segments.some((s) => s === "." || s === "..")) {
87
+ throw new Error(`writeProtectedPaths: entry ${describeEntryValue(name)} contains a "." or ".." segment — the judge folds dot segments lexically before matching, so such a row could never match anything; spell the real name.`);
88
+ }
89
+ if (segments.length > 1 && kind !== "segment-run") {
90
+ throw new Error(`writeProtectedPaths: entry ${describeEntryValue(name)} spans ${segments.length} segments but declares kind "${kind}" — multi-segment names match as kind "segment-run".`);
91
+ }
92
+ return { name: segments.join("/"), kind };
93
+ }
94
+ export function resolveWriteProtectedTable(entries) {
95
+ if (entries === undefined)
96
+ return WRITE_PROTECTED_DEFAULT_TABLE;
97
+ if (!Array.isArray(entries)) {
98
+ throw new Error(`writeProtectedPaths: expected an array of entries (whole-table replacement; [] = no write-protection table), got ${describeEntryValue(entries)}.`);
99
+ }
100
+ const out = [];
101
+ const seen = new Set();
102
+ for (const entry of entries) {
103
+ const row = normalizeWriteProtectedEntry(entry);
104
+ const key = `${row.kind}:${foldWriteProtectCase(row.name)}`;
105
+ if (seen.has(key))
106
+ continue;
107
+ seen.add(key);
108
+ out.push(row);
109
+ }
110
+ return out;
111
+ }
112
+ export function compileWriteProtection(entries) {
113
+ const rows = resolveWriteProtectedTable(entries);
114
+ if (rows.length === 0)
115
+ return undefined;
116
+ const basenames = new Map();
117
+ const segments = new Map();
118
+ const runs = [];
119
+ for (const row of rows) {
120
+ const folded = foldWriteProtectCase(row.name);
121
+ if (row.kind === "basename") {
122
+ if (!basenames.has(folded))
123
+ basenames.set(folded, row.name);
124
+ }
125
+ else if (row.kind === "segment") {
126
+ if (!segments.has(folded))
127
+ segments.set(folded, row.name);
128
+ }
129
+ else {
130
+ runs.push({ name: row.name, parts: folded.split("/").filter((s) => s.length > 0) });
131
+ }
132
+ }
133
+ return {
134
+ rows,
135
+ matchPath(path) {
136
+ const viewOf = (win32) => {
137
+ const segs = [];
138
+ for (const raw of path.split(/[\\/]/)) {
139
+ if (raw.length === 0)
140
+ continue;
141
+ const dot = !win32 || /^\.{1,2}$/.test(raw) ? raw : raw.replace(/ +$/, "");
142
+ if (dot === ".")
143
+ continue;
144
+ if (dot === "..") {
145
+ const last = segs[segs.length - 1];
146
+ if (segs.length > 0 && last !== "..")
147
+ segs.pop();
148
+ else
149
+ segs.push("..");
150
+ continue;
151
+ }
152
+ const stripped = win32 ? raw.replace(/[. ]+$/, "") : raw;
153
+ segs.push(foldWriteProtectCase(stripped.length > 0 ? stripped : raw));
154
+ }
155
+ return segs;
156
+ };
157
+ const judge = (segs) => {
158
+ if (segs.length === 0)
159
+ return null;
160
+ const base = basenames.get(segs[segs.length - 1] ?? "");
161
+ if (base !== undefined)
162
+ return { name: base, kind: "basename" };
163
+ for (const s of segs) {
164
+ const hit = segments.get(s);
165
+ if (hit !== undefined)
166
+ return { name: hit, kind: "segment" };
167
+ }
168
+ for (const run of runs) {
169
+ const n = run.parts.length;
170
+ for (let i = 0; i + n <= segs.length; i++) {
171
+ if (run.parts.every((p, j) => segs[i + j] === p))
172
+ return { name: run.name, kind: "segment-run" };
173
+ }
174
+ }
175
+ return null;
176
+ };
177
+ const posix = viewOf(false);
178
+ return judge(posix) ?? judge(viewOf(true));
179
+ },
180
+ };
181
+ }
182
+ export function createWriteProtectionCheck(entries) {
183
+ const matcher = compileWriteProtection(entries);
184
+ if (matcher === undefined)
185
+ return undefined;
186
+ return (toolName, args) => {
187
+ if (!PATH_CONFINABLE_WRITE_TOOLS.has(toolName))
188
+ return null;
189
+ const path = writeTargetPath(toolName, args);
190
+ if (typeof path !== "string" || path.length === 0)
191
+ return null;
192
+ return matcher.matchPath(path);
193
+ };
194
+ }
package/dist/index.d.ts CHANGED
@@ -91,6 +91,7 @@ export { classifyCompoundReadonlyDetailed, formatOutOfRootReadApprovalOption, ty
91
91
  export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
92
92
  export { READ_FACE_DEFAULT_DENY_ENTRIES, READ_FACE_BUILTIN_DENY_TABLE, READ_DENY_BUILTIN_TIERS, READ_DENY_DEFAULT_TIERS, resolveReadDenyBuiltins, compileReadDeny, type ReadDenyEntry, type ReadDenyMatcher, type NormalizedReadDenyEntry, type ReadDenyBuiltinTier, type ReadDenyBuiltinRow, type ReadDenyBuiltinConfig, } from "./tools/fs/index.js";
93
93
  export { deploymentReadFaceClampNotice, resolveReadFace, type ReadFace, type ReadFaceInputs } from "./tools/fs/index.js";
94
+ export { WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable, compileWriteProtection, type WriteProtectedEntry, type WriteProtectedRow, type WriteProtectedKind, type WriteProtectedHit, type WriteProtectionMatcher, } from "./core/write-protect.js";
94
95
  export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, buildToolResultRef, toolResultContentSegment, MAX_MINTED_TOOL_RESULT_REF_CHARS, type ToolResultProvenance, assertToolResultProvenanceMatch, normalizeToolResultProvenance, toolResultProvenanceOf, ToolResultRefConflictError, TOOL_RESULT_REF_CONFLICT_CODE, type ToolResultStore, type ToolResultSlice, type ToolResultDeletionReport, } from "./core/tool-result-store.js";
95
96
  export { InMemoryCheckpointStore, CheckpointError, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, LEGACY_PENDING_STEER_INPUT_ID, type ActorAssertion, type PendingSteerEntry, type PendingSteerInput, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, type RiskDescriptor, type ProbeCause, type ProbeCauseOperands, type CheckpointStore, type CheckpointSummary, type Checkpoint, type CheckpointToken, type CheckpointGate, type CheckpointState, type SerializedCheckpointState, type CheckpointFaultMode, type PendingAction, type ResumeOutcome, type ResolvedOutcome, type ReopenReason, type ResolveExpectation, type SafetyAxis, type RealApprovalGateBit, type ResourceLedger, type ResourceLimitReason, type PlatformLimitReason, } from "./core/checkpoint-store.js";
96
97
  export { InMemoryUsageWindowStore, GLOBAL_USAGE_KEY, EMPTY_USAGE_WINDOW_RECORD, chargeUsageRecord, readUsageRecord, usageRetryAfterMs, resolveUsageWindows, type UsageWindow, type UsageWindowStore, type UsageWindowReading, type UsageWindowRecord, type UsageSlot, type UsageBucketRow, } from "./core/usage-window-store.js";
@@ -114,7 +115,7 @@ export { type StoreDurability } from "./core/checkpoint-store.js";
114
115
  export { type StoreFidelity } from "./core/checkpoint-store.js";
115
116
  export { projectHumanInput, buildHumanInputEvent, type HumanInputSource, type HumanInputFrame, type HumanInputEvent, type HumanInputDelivery, } from "./core/human-input-projection.js";
116
117
  export { TaskRegistry, defaultTaskRegistry, createTaskOutputTool, createTaskStopTool, type SemaTaskType, type SemaTaskStatus, type SemaTaskHandle, type ParkedClaimTicket, type TaskAccess, type UnifiedTaskOutput, type TaskRetrievalStatus, type StopSource, type RegisterMonitorInput, type MonitorTimers, MONITOR_BATCH_WINDOW_MS, MONITOR_DEFAULT_TIMEOUT_MS, MONITOR_MAX_TIMEOUT_MS, MONITOR_MAX_BATCHES_PER_MINUTE, canAccessWorkflowRun, DURABLE_AGENT_HANDLE_RE, } from "./core/task-registry.js";
117
- export { InMemoryMailboxStore, type MailboxStore, type MailboxMessage, type MailboxLease } from "./core/mailbox-store.js";
118
+ export { InMemoryMailboxStore, type MailboxStore, type MailboxMessage, type MailboxLease, MailboxStoreError, MAILBOX_TOMBSTONED_RECIPIENT_CODE, } from "./core/mailbox-store.js";
118
119
  export { FileMailboxStore, type FileMailboxStoreOptions } from "./stores/file/mailbox-store.js";
119
120
  export { createFileTaskListStore } from "./stores/file/task-list-store.js";
120
121
  export { createCcFileTaskListStore } from "./stores/cc/task-list-store.js";
@@ -217,7 +218,7 @@ export { sessionRepoContract } from "./core/store-contracts/session-repo-contrac
217
218
  export { toolResultStoreContract } from "./core/store-contracts/tool-result-store-contract.js";
218
219
  export { fileSnapshotStoreContract } from "./core/store-contracts/file-snapshot-store-contract.js";
219
220
  export { permissionRuleSyncContract, type PermissionRuleSyncContractHooks } from "./core/store-contracts/permission-rule-sync-contract.js";
220
- export { MAILBOX_CONTRACT_SCOPE, mailboxStoreContract, mailboxAckOwnershipContract, mailboxBundledOnlyContract, } from "./core/store-contracts/mailbox-store-contract.js";
221
+ export { MAILBOX_CONTRACT_SCOPE, mailboxStoreContract, mailboxAckOwnershipContract, mailboxBundledOnlyContract, mailboxTombstonedRecipientContract, type MailboxTombstonedRecipientContractHooks, } from "./core/store-contracts/mailbox-store-contract.js";
221
222
  export { BACKGROUND_AGENT_CONTRACT_SCOPE, backgroundAgentStoreContract, backgroundAgentStoreScopesContract, } from "./core/store-contracts/background-agent-store-contract.js";
222
223
  export { type BackgroundAgentStore, type BackgroundAgentRecord, type BackgroundAgentRowSummary, type BackgroundAgentUsage, canAccessAgentRecord, STALE_RUNNING_REAP_ATTRIBUTION, BackgroundAgentStoreError, InMemoryBackgroundAgentStore, reconcileParkedAgents, queryBackgroundAgents, type BackgroundAgentQuery, } from "./core/background-agent-store.js";
223
224
  export { serveDurableAgentRowLane, buildAgentPollDetails, type AgentPollDetailsInput, } from "./core/task-registry-agent.js";
package/dist/index.js CHANGED
@@ -71,6 +71,7 @@ export { classifyCompoundReadonlyDetailed, formatOutOfRootReadApprovalOption, }
71
71
  export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
72
72
  export { READ_FACE_DEFAULT_DENY_ENTRIES, READ_FACE_BUILTIN_DENY_TABLE, READ_DENY_BUILTIN_TIERS, READ_DENY_DEFAULT_TIERS, resolveReadDenyBuiltins, compileReadDeny, } from "./tools/fs/index.js";
73
73
  export { deploymentReadFaceClampNotice, resolveReadFace } from "./tools/fs/index.js";
74
+ export { WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable, compileWriteProtection, } from "./core/write-protect.js";
74
75
  export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, buildToolResultRef, toolResultContentSegment, MAX_MINTED_TOOL_RESULT_REF_CHARS, assertToolResultProvenanceMatch, normalizeToolResultProvenance, toolResultProvenanceOf, ToolResultRefConflictError, TOOL_RESULT_REF_CONFLICT_CODE, } from "./core/tool-result-store.js";
75
76
  export { InMemoryCheckpointStore, CheckpointError, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, LEGACY_PENDING_STEER_INPUT_ID, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, } from "./core/checkpoint-store.js";
76
77
  export { InMemoryUsageWindowStore, GLOBAL_USAGE_KEY, EMPTY_USAGE_WINDOW_RECORD, chargeUsageRecord, readUsageRecord, usageRetryAfterMs, resolveUsageWindows, } from "./core/usage-window-store.js";
@@ -93,7 +94,7 @@ export {} from "./core/checkpoint-store.js";
93
94
  export {} from "./core/checkpoint-store.js";
94
95
  export { projectHumanInput, buildHumanInputEvent, } from "./core/human-input-projection.js";
95
96
  export { TaskRegistry, defaultTaskRegistry, createTaskOutputTool, createTaskStopTool, MONITOR_BATCH_WINDOW_MS, MONITOR_DEFAULT_TIMEOUT_MS, MONITOR_MAX_TIMEOUT_MS, MONITOR_MAX_BATCHES_PER_MINUTE, canAccessWorkflowRun, DURABLE_AGENT_HANDLE_RE, } from "./core/task-registry.js";
96
- export { InMemoryMailboxStore } from "./core/mailbox-store.js";
97
+ export { InMemoryMailboxStore, MailboxStoreError, MAILBOX_TOMBSTONED_RECIPIENT_CODE, } from "./core/mailbox-store.js";
97
98
  export { FileMailboxStore } from "./stores/file/mailbox-store.js";
98
99
  export { createFileTaskListStore } from "./stores/file/task-list-store.js";
99
100
  export { createCcFileTaskListStore } from "./stores/cc/task-list-store.js";
@@ -178,7 +179,7 @@ export { sessionRepoContract } from "./core/store-contracts/session-repo-contrac
178
179
  export { toolResultStoreContract } from "./core/store-contracts/tool-result-store-contract.js";
179
180
  export { fileSnapshotStoreContract } from "./core/store-contracts/file-snapshot-store-contract.js";
180
181
  export { permissionRuleSyncContract } from "./core/store-contracts/permission-rule-sync-contract.js";
181
- export { MAILBOX_CONTRACT_SCOPE, mailboxStoreContract, mailboxAckOwnershipContract, mailboxBundledOnlyContract, } from "./core/store-contracts/mailbox-store-contract.js";
182
+ export { MAILBOX_CONTRACT_SCOPE, mailboxStoreContract, mailboxAckOwnershipContract, mailboxBundledOnlyContract, mailboxTombstonedRecipientContract, } from "./core/store-contracts/mailbox-store-contract.js";
182
183
  export { BACKGROUND_AGENT_CONTRACT_SCOPE, backgroundAgentStoreContract, backgroundAgentStoreScopesContract, } from "./core/store-contracts/background-agent-store-contract.js";
183
184
  export { canAccessAgentRecord, STALE_RUNNING_REAP_ATTRIBUTION, BackgroundAgentStoreError, InMemoryBackgroundAgentStore, reconcileParkedAgents, queryBackgroundAgents, } from "./core/background-agent-store.js";
184
185
  export { serveDurableAgentRowLane, buildAgentPollDetails, } from "./core/task-registry-agent.js";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * design/98 §6.3 — the ONE reading of "is this governance baseline usable", shared by the deployment
3
+ * CAPABILITY predicate (`workflowsCapability`, which drives the surfaced `/workflows` affordance AND
4
+ * the orchestration-prompt injection) and the MOUNT-time validator (`createRunWorkflowTool`, which
5
+ * refuses loudly with a code).
6
+ *
7
+ * Why one function rather than two agreeing predicates: this seam split three times inside one
8
+ * release window, each time one level deeper. First the capability tested `!== undefined` while the
9
+ * mount tested truthiness (a JSON `null` announced workflows the tool never mounted). Then both
10
+ * tested the container while the mount refused an unusable `base` SLOT (`{ base: null }` announced
11
+ * workflows that hard-failed every opted-in task). Then the slot readings agreed but the mount
12
+ * additionally refused a malformed `worktreeBase` and malformed face lists — the same split again,
13
+ * one field further in. Two predicates that must "stay in agreement" are a standing invitation to
14
+ * drift; the only shape that cannot drift is a single function both call.
15
+ *
16
+ * The contract: {@link governanceBaselineProblem} returns `null` when the mount would SUCCEED, and
17
+ * a describable problem when the mount would REFUSE. Every mount-time refusal must be reachable
18
+ * from here, so a deployment is never told it has workflows it cannot actually run.
19
+ */
20
+ export interface GovernanceBaselineProblem {
21
+ /** Which slot/field is wrong, in the spelling a deployment configures (`base.excludeTools`). */
22
+ readonly where: string;
23
+ /** What was found there, for the operator-facing message (`null`, `a string`, `an array`). */
24
+ readonly found: string;
25
+ /** The coded refusal the mount raises for this problem. */
26
+ readonly code: "config.invalid_governance_baseline" | "config.invalid_tool_name_set";
27
+ }
28
+ /** Name a bad value WITHOUT throwing on it (a bigint / cyclic object / poisoned `toJSON` must not
29
+ * crash the describer and cost the refusal its name). */
30
+ export declare function describeBaselineValue(v: unknown): string;
31
+ /**
32
+ * The single validity reading. `null` ⇒ the mount would succeed ⇒ the capability may be announced.
33
+ *
34
+ * `base` is the control plane every script-spawned child inherits, so a non-object there refuses:
35
+ * ungoverned is spelled by not configuring `workflowGovernanceBaseline` at all, never by an
36
+ * unusable anchor. `worktreeBase` is the optional overlay whose documented absence means "base
37
+ * governs every child" — `null`/absent is that absence, any other non-object is garbage and refuses
38
+ * rather than impersonating either absence or an overlay.
39
+ */
40
+ export declare function governanceBaselineProblem(baseline: unknown): GovernanceBaselineProblem | null;
41
+ /** The mount's refusal, minted from the shared reading so the message and the code have one home. */
42
+ export declare function governanceBaselineError(p: GovernanceBaselineProblem): Error & {
43
+ code: string;
44
+ };
@@ -0,0 +1,55 @@
1
+ const FACE_LIST_FIELDS = ["excludeTools", "deferTools", "alwaysLoadTools"];
2
+ export function describeBaselineValue(v) {
3
+ return v === null ? "null" : Array.isArray(v) ? "an array" : `a ${typeof v}`;
4
+ }
5
+ function isSpecObject(v) {
6
+ return v !== null && v !== undefined && typeof v === "object" && !Array.isArray(v);
7
+ }
8
+ function faceListProblem(slot, where) {
9
+ for (const field of FACE_LIST_FIELDS) {
10
+ const v = slot[field];
11
+ if (v === undefined || v === null)
12
+ continue;
13
+ if (!Array.isArray(v)) {
14
+ return { where: `${where}.${field}`, found: describeBaselineValue(v), code: "config.invalid_tool_name_set" };
15
+ }
16
+ for (const [i, entry] of v.entries()) {
17
+ if (typeof entry !== "string") {
18
+ return {
19
+ where: `${where}.${field}`,
20
+ found: `entry ${i} is ${describeBaselineValue(entry)}`,
21
+ code: "config.invalid_tool_name_set",
22
+ };
23
+ }
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+ export function governanceBaselineProblem(baseline) {
29
+ if (!isSpecObject(baseline)) {
30
+ return { where: "workflowGovernanceBaseline", found: describeBaselineValue(baseline), code: "config.invalid_governance_baseline" };
31
+ }
32
+ const base = baseline.base;
33
+ if (!isSpecObject(base)) {
34
+ return { where: "base", found: describeBaselineValue(base), code: "config.invalid_governance_baseline" };
35
+ }
36
+ const baseFaces = faceListProblem(base, "base");
37
+ if (baseFaces !== null)
38
+ return baseFaces;
39
+ const wt = baseline.worktreeBase;
40
+ if (wt === null || wt === undefined)
41
+ return null;
42
+ if (!isSpecObject(wt)) {
43
+ return { where: "worktreeBase", found: describeBaselineValue(wt), code: "config.invalid_governance_baseline" };
44
+ }
45
+ return faceListProblem(wt, "worktreeBase");
46
+ }
47
+ export function governanceBaselineError(p) {
48
+ const detail = p.code === "config.invalid_tool_name_set"
49
+ ? `must be an array of tool names (got ${p.found}) — refusing to mount rather than govern children with a roster nobody can read.`
50
+ : `is ${p.found}, not a spec object — refusing to mount rather than launch children under no governance ` +
51
+ `(to run ungoverned, leave workflowGovernanceBaseline unconfigured).`;
52
+ const e = new Error(`createRunWorkflowTool: the workflow governance baseline's ${p.where} ${detail}`);
53
+ e.code = p.code;
54
+ return e;
55
+ }
@@ -1,5 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../core/tools.js";
3
+ import { governanceBaselineError, governanceBaselineProblem } from "./governance-baseline-validity.js";
3
4
  import { redactSecrets, redactHostLeaks, boundedRedactedSummary } from "../core/untrusted-egress.js";
4
5
  import { startWorkflow } from "./workflow.js";
5
6
  import { buildWorkflowPrimitives } from "./workflow-primitives.js";
@@ -167,16 +168,40 @@ export async function createRunWorkflowTool(d) {
167
168
  childMaxTokens: lim.childMaxTokens,
168
169
  childMaxTurns: lim.childMaxTurns,
169
170
  };
171
+ const problem = governanceBaselineProblem(d.governanceBaseline);
172
+ if (problem !== null)
173
+ throw governanceBaselineError(problem);
174
+ const FACE_LIST_FIELDS = ["excludeTools", "deferTools", "alwaysLoadTools"];
175
+ const dropNullFaces = (slot) => {
176
+ let out = slot;
177
+ for (const field of FACE_LIST_FIELDS) {
178
+ if (slot[field] !== null)
179
+ continue;
180
+ out = { ...out };
181
+ delete out[field];
182
+ }
183
+ return out;
184
+ };
185
+ const sanitizedBaseline = (() => {
186
+ const wt = d.governanceBaseline.worktreeBase;
187
+ const { worktreeBase: _absentOverlay, ...rest } = d.governanceBaseline;
188
+ return {
189
+ ...rest,
190
+ base: dropNullFaces(d.governanceBaseline.base),
191
+ ...(wt === null || wt === undefined ? {} : { worktreeBase: dropNullFaces(wt) }),
192
+ };
193
+ })();
194
+ const unionFaceList = (own, parent) => own === undefined ? [...new Set(parent)] : [...new Set([...own, ...parent])];
170
195
  const withParentFace = (base, inherit) => ({
171
196
  ...base,
172
197
  ...(d.parentExcludeTools?.length
173
- ? { excludeTools: [...new Set([...(base.excludeTools ?? inherit?.excludeTools ?? []), ...d.parentExcludeTools])] }
198
+ ? { excludeTools: unionFaceList(base.excludeTools ?? inherit?.excludeTools, d.parentExcludeTools) }
174
199
  : {}),
175
200
  ...(d.parentDeferTools?.length
176
- ? { deferTools: [...new Set([...(base.deferTools ?? inherit?.deferTools ?? []), ...d.parentDeferTools])] }
201
+ ? { deferTools: unionFaceList(base.deferTools ?? inherit?.deferTools, d.parentDeferTools) }
177
202
  : {}),
178
203
  ...(d.parentAlwaysLoadTools?.length
179
- ? { alwaysLoadTools: [...new Set([...(base.alwaysLoadTools ?? inherit?.alwaysLoadTools ?? []), ...d.parentAlwaysLoadTools])] }
204
+ ? { alwaysLoadTools: unionFaceList(base.alwaysLoadTools ?? inherit?.alwaysLoadTools, d.parentAlwaysLoadTools) }
180
205
  : {}),
181
206
  ...(() => {
182
207
  const parent = d.parentRestoreGatedTools;
@@ -197,13 +222,13 @@ export async function createRunWorkflowTool(d) {
197
222
  const withParentProfile = (base) => d.parentPromptProfile !== undefined && base.promptProfile === undefined ? { ...base, promptProfile: d.parentPromptProfile } : base;
198
223
  const baselineWithParentFace = (d.parentExcludeTools?.length ?? 0) > 0 || (d.parentDeferTools?.length ?? 0) > 0 || (d.parentAlwaysLoadTools?.length ?? 0) > 0 || d.parentPromptProfile !== undefined || d.parentRestoreGatedTools !== undefined
199
224
  ? {
200
- ...d.governanceBaseline,
201
- base: withParentProfile(withParentFace(d.governanceBaseline.base)),
202
- ...(d.governanceBaseline.worktreeBase !== undefined
203
- ? { worktreeBase: withParentFace(d.governanceBaseline.worktreeBase, d.governanceBaseline.base) }
225
+ ...sanitizedBaseline,
226
+ base: withParentProfile(withParentFace(sanitizedBaseline.base)),
227
+ ...(sanitizedBaseline.worktreeBase !== undefined
228
+ ? { worktreeBase: withParentFace(sanitizedBaseline.worktreeBase, sanitizedBaseline.base) }
204
229
  : {}),
205
230
  }
206
- : d.governanceBaseline;
231
+ : sanitizedBaseline;
207
232
  const governance = { baseline: baselineWithParentFace, models: d.models, caps: childCaps, onNotice: d.onNotice };
208
233
  const builtinsEnabled = d.builtinWorkflows !== false;
209
234
  const namedWorkflowSection = renderNamedWorkflowListing(await collectNamedWorkflowListings(d.scriptStore, builtinsEnabled));