@sema-agent/core 5.27.0 → 5.28.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/core/hooks.d.ts +22 -0
  3. package/dist/core/hooks.js +22 -3
  4. package/dist/core/memory-engine/engine.js +2 -4
  5. package/dist/core/memory-engine/file-backend.d.ts +66 -7
  6. package/dist/core/memory-engine/file-backend.js +69 -27
  7. package/dist/core/memory-engine/layout.d.ts +31 -2
  8. package/dist/core/memory-engine/layout.js +132 -8
  9. package/dist/core/memory-engine/types.d.ts +6 -5
  10. package/dist/core/permission-rule-consent.d.ts +82 -8
  11. package/dist/core/permission-rule-consent.js +92 -1
  12. package/dist/core/permission-rule-model.d.ts +17 -1
  13. package/dist/core/permission-rule-model.js +21 -0
  14. package/dist/core/permission-rule-org.d.ts +22 -3
  15. package/dist/core/permission-rule-org.js +67 -20
  16. package/dist/core/permission-rule-store.js +2 -2
  17. package/dist/core/permission-rule-sync.d.ts +15 -1
  18. package/dist/core/permission-rule-sync.js +89 -47
  19. package/dist/core/runner/prepare-task.js +8 -3
  20. package/dist/core/runner/runtask.js +8 -1
  21. package/dist/core/task-registry-agent.d.ts +9 -0
  22. package/dist/core/task-registry-agent.js +51 -21
  23. package/dist/core/task-registry-monitor.js +1 -1
  24. package/dist/core/task-registry-shared.d.ts +9 -0
  25. package/dist/core/tool-policy.d.ts +35 -2
  26. package/dist/core/tool-policy.js +37 -3
  27. package/dist/core/tool-result-store.d.ts +108 -7
  28. package/dist/core/tool-result-store.js +95 -15
  29. package/dist/core/types.d.ts +80 -10
  30. package/dist/core/types.js +30 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/stores/file/tool-result-store.d.ts +41 -1
  33. package/dist/stores/file/tool-result-store.js +107 -19
  34. package/dist/tools/fs/fs-bash.d.ts +7 -0
  35. package/dist/tools/fs/fs-shared.d.ts +5 -0
  36. package/dist/tools/fs/fs-shared.js +11 -7
  37. package/dist/tools/fs/index.d.ts +6 -0
  38. package/dist/tools/fs/index.js +2 -0
  39. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
- import { closeSync, constants as fsConstants, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
2
- const { O_WRONLY, O_CREAT, O_TRUNC, O_NOFOLLOW } = fsConstants;
1
+ import { closeSync, constants as fsConstants, copyFileSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
2
+ const { O_WRONLY, O_CREAT, O_TRUNC, O_NOFOLLOW, O_EXCL } = fsConstants;
3
3
  import { homedir } from "node:os";
4
4
  import { createHash } from "node:crypto";
5
5
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
@@ -184,6 +184,119 @@ export function scopeDirName(scope) {
184
184
  const hash = createHash("sha256").update(scope, "utf8").digest("hex").slice(0, 6);
185
185
  return `${cleaned}-${hash}`;
186
186
  }
187
+ const caseFoldByDirIdentity = new Map();
188
+ let caseFoldProbeSeq = 0;
189
+ function lstatOrAbsent(p) {
190
+ try {
191
+ return lstatSync(p);
192
+ }
193
+ catch (err) {
194
+ if (err.code === "ENOENT")
195
+ return undefined;
196
+ throw err;
197
+ }
198
+ }
199
+ function probeDirCaseFolds(dir) {
200
+ const name = `.casefold-probe-${process.pid}-${caseFoldProbeSeq++}`;
201
+ const writeLeg = (() => {
202
+ let fd;
203
+ try {
204
+ fd = openSync(join(dir, name), O_WRONLY | O_CREAT | O_EXCL, 0o600);
205
+ }
206
+ catch {
207
+ return undefined;
208
+ }
209
+ let identity;
210
+ try {
211
+ const st = fstatSync(fd);
212
+ identity = { dev: st.dev, ino: st.ino };
213
+ }
214
+ catch {
215
+ }
216
+ finally {
217
+ closeSync(fd);
218
+ }
219
+ try {
220
+ let swapped;
221
+ let orig;
222
+ try {
223
+ swapped = lstatOrAbsent(join(dir, name.toUpperCase()));
224
+ orig = swapped === undefined ? lstatOrAbsent(join(dir, name)) : undefined;
225
+ }
226
+ catch {
227
+ return undefined;
228
+ }
229
+ if (swapped !== undefined) {
230
+ return identity === undefined || (swapped.dev === identity.dev && swapped.ino === identity.ino) ? true : undefined;
231
+ }
232
+ if (orig === undefined || identity === undefined)
233
+ return undefined;
234
+ return orig.dev === identity.dev && orig.ino === identity.ino ? false : undefined;
235
+ }
236
+ finally {
237
+ try {
238
+ rmSync(join(dir, name), { force: true });
239
+ }
240
+ catch {
241
+ }
242
+ }
243
+ })();
244
+ if (writeLeg !== undefined)
245
+ return writeLeg;
246
+ try {
247
+ for (const entry of readdirSync(dir)) {
248
+ if (!/[A-Za-z]/.test(entry))
249
+ continue;
250
+ const swapped = entry.toLowerCase() !== entry ? entry.toLowerCase() : entry.toUpperCase();
251
+ try {
252
+ const orig = lstatOrAbsent(join(dir, entry));
253
+ if (orig === undefined)
254
+ continue;
255
+ const other = lstatOrAbsent(join(dir, swapped));
256
+ const again = lstatOrAbsent(join(dir, entry));
257
+ if (again === undefined || again.dev !== orig.dev || again.ino !== orig.ino)
258
+ continue;
259
+ if (other === undefined)
260
+ return false;
261
+ return other.dev === orig.dev && other.ino === orig.ino;
262
+ }
263
+ catch {
264
+ continue;
265
+ }
266
+ }
267
+ }
268
+ catch {
269
+ }
270
+ return undefined;
271
+ }
272
+ export function dirCaseFolds(dir) {
273
+ let key;
274
+ try {
275
+ ensureDirExists(dir);
276
+ const s = statSync(dir);
277
+ key = `${s.dev}:${s.ino}`;
278
+ }
279
+ catch {
280
+ }
281
+ if (key !== undefined) {
282
+ const cached = caseFoldByDirIdentity.get(key);
283
+ if (cached !== undefined)
284
+ return cached;
285
+ }
286
+ const folds = probeDirCaseFolds(dir);
287
+ if (folds === undefined)
288
+ return undefined;
289
+ try {
290
+ const after = statSync(dir);
291
+ if (key === undefined || key !== `${after.dev}:${after.ino}`)
292
+ return undefined;
293
+ }
294
+ catch {
295
+ return undefined;
296
+ }
297
+ caseFoldByDirIdentity.set(key, folds);
298
+ return folds;
299
+ }
187
300
  function readScopesRecord(controlDir) {
188
301
  const path = join(controlDir, SCOPES_FILE);
189
302
  let raw;
@@ -217,6 +330,11 @@ function readScopesRecord(controlDir) {
217
330
  for (const [k, v] of Object.entries(rec.scopes)) {
218
331
  if (typeof v !== "string")
219
332
  throw new ControlPlaneCorruptError(`scope registry entry ${JSON.stringify(k)} is not a string: ${path}`);
333
+ if ((v === "") !== (k === rec.rootScope)) {
334
+ throw new ControlPlaneCorruptError(v === ""
335
+ ? `scope registry maps ${JSON.stringify(k)} to the root home but rootScope is ${rec.rootScope === undefined ? "unclaimed" : JSON.stringify(rec.rootScope)}: ${path}`
336
+ : `scope registry maps the root scope ${JSON.stringify(k)} to subdir ${JSON.stringify(v)} instead of the root home: ${path}`);
337
+ }
220
338
  }
221
339
  scopes = rec.scopes;
222
340
  }
@@ -255,20 +373,26 @@ export function claimRootScope(controlDir, scope) {
255
373
  return { next: { rootScope: scope, scopes: { ...rec.scopes, [scope]: "" } }, result: scope };
256
374
  });
257
375
  }
258
- export function registerScope(memoryDir, controlDir, scope) {
376
+ export function registerScope(memoryDir, controlDir, scope, opts) {
259
377
  return lockedScopesUpdate(controlDir, (rec) => {
260
- const dirName = rec.rootScope === scope ? "" : scopeDirName(scope);
378
+ const dirName = rec.rootScope === scope ? "" : (rec.scopes?.[scope] ?? scopeDirName(scope));
261
379
  for (const [other, otherDir] of Object.entries(rec.scopes ?? {})) {
262
- if (other !== scope && otherDir !== "" && otherDir === dirName) {
380
+ if (other === scope || otherDir === "")
381
+ continue;
382
+ if (otherDir === dirName) {
263
383
  throw new ControlPlaneCorruptError(`scope directory collision: ${JSON.stringify(scope)} and ${JSON.stringify(other)} both map to ${JSON.stringify(dirName)} — refusing (fail-closed)`);
264
384
  }
385
+ if (dirName !== "" && otherDir.toLowerCase() === dirName.toLowerCase()) {
386
+ const folds = opts?.caseFoldingFs ?? dirCaseFolds(memoryDir);
387
+ if (folds !== false) {
388
+ throw new ControlPlaneCorruptError(`scope directory collision on a case-folding filesystem: ${JSON.stringify(scope)} → ${JSON.stringify(dirName)} and ${JSON.stringify(other)} → ${JSON.stringify(otherDir)} are one physical directory under ${memoryDir}` +
389
+ `${folds === undefined ? " (volume case semantics could not be probed — refusing the fold-equal pair rather than risking a silent cross-scope merge)" : ""} — refusing (fail-closed)`);
390
+ }
391
+ }
265
392
  }
266
393
  if (rec.scopes?.[scope] === undefined) {
267
394
  return { next: { ...rec, scopes: { ...rec.scopes, [scope]: dirName } }, result: dirName === "" ? memoryDir : join(memoryDir, dirName) };
268
395
  }
269
- if (rec.scopes[scope] !== dirName) {
270
- return { result: rec.scopes[scope] === "" ? memoryDir : join(memoryDir, rec.scopes[scope]) };
271
- }
272
396
  return { result: dirName === "" ? memoryDir : join(memoryDir, dirName) };
273
397
  });
274
398
  }
@@ -224,11 +224,12 @@ export interface MemorySessionHandle {
224
224
  * leave the injection path reading them anyway. Absent ⇒ the normal "live file wins" behavior. */
225
225
  indexOnDiskUntrusted?: boolean;
226
226
  /** True ⇔ this session materialized through the ADOPTION-RESTRICTED (committed-view) read face:
227
- * either the plane is a read-only layering (`writeScope === null`) or the caller declared the
228
- * session unable to persist (an explicit verdict never inferred down here). Restricted sessions
229
- * read committed state only (ledger + control-plane shadow); disk divergence with no transaction
230
- * backing is neither adopted into the committed account nor served, and `inject` reads the
231
- * materialize-time index text instead of the live on-disk file. */
227
+ * the caller declared the session unable to persist (an explicit session-level verdict never
228
+ * inferred down here, and never derived from the plane's shape: a read-only layering
229
+ * (`writeScope === null`) keeps its ordinary adopt-on-read semantics and does NOT set this).
230
+ * Restricted sessions read committed state only (ledger + control-plane shadow); disk divergence
231
+ * with no transaction backing is neither adopted into the committed account nor served, and
232
+ * `inject` reads the materialize-time index text instead of the live on-disk file. */
232
233
  adoptionRestricted?: boolean;
233
234
  }
234
235
  /** Stable rejection codes a harvest gate can produce (model-visible gate events — 镜头 I). */
@@ -27,7 +27,7 @@
27
27
  * this by editing a file backend's file. That is the settings-file trust model, stated rather than
28
28
  * defended against: for a file backend, host = user, no more and no less.
29
29
  */
30
- import { type RuleScope, type RuleDot } from "./permission-rule-model.js";
30
+ import { type RuleRejectCode, type RuleScope, type RuleDot } from "./permission-rule-model.js";
31
31
  import type { PermissionRuleStoreProvider, RuleOwner } from "./permission-rule-store.js";
32
32
  /** One candidate rule inside an approval record: the exact text and where it would apply. */
33
33
  export interface RuleCandidate {
@@ -52,9 +52,19 @@ export interface RuleApprovalRecord {
52
52
  state: "pending" | "approved" | "redeemed";
53
53
  candidates: RuleCandidate[];
54
54
  createdAt: string;
55
- /** The ask this record was drawn from, for reconciliation. Advisory metadata; never adjudication input. */
55
+ /** The ask this record was drawn from, for reconciliation. Advisory metadata; never adjudication input.
56
+ * `boundInputHash` is ALSO the card-edit binding anchor: an edited-candidate confirmation must echo
57
+ * it back, so a record minted without one refuses edits (there is nothing to bind the edit to). */
56
58
  toolCallId?: string;
57
59
  boundInputHash?: string;
60
+ /**
61
+ * The adjudicated command a CARD record was drawn for — the same post-rewrite bytes the ask carried
62
+ * (the form `boundInputHash` digests). Additive: absent on batch records and on every card a prior
63
+ * version minted. It is the edit gate's coverage input ("the edited rule must still admit THIS
64
+ * command"), so a record without one refuses edited candidates rather than guessing; the engine-
65
+ * candidate paths never read it.
66
+ */
67
+ command?: string;
58
68
  /** Monotonic revision of THIS record, bumped by every accepted transition. The compare-and-set key:
59
69
  * comparing state alone cannot separate two different writes that both leave the state unchanged. */
60
70
  rev: number;
@@ -68,6 +78,20 @@ export interface RuleApprovalRecord {
68
78
  * previewed list by construction.
69
79
  */
70
80
  selectedCandidate?: number;
81
+ /**
82
+ * The person-EDITED candidate this record carries, if any — full provenance for the one candidate
83
+ * whose text was authored at the card rather than derived by the engine. `index` names the appended
84
+ * row in `candidates` (whose `rule` holds the CANONICAL spelling); `text` keeps the raw input bytes
85
+ * exactly as submitted (the idempotency primary key — a client retrying a lost response resends the
86
+ * same bytes); `at` is when the edit landed. Present ⇒ `selectedCandidate === index` (the edit and
87
+ * the choice are one CAS write). Absent on every record a prior version minted and on every card
88
+ * settled through an engine candidate.
89
+ */
90
+ edited?: {
91
+ index: number;
92
+ text: string;
93
+ at: string;
94
+ };
71
95
  /** Dots already minted for this record, keyed by candidate index — the replay anchor. */
72
96
  redeemedDots?: Record<number, RuleDot>;
73
97
  }
@@ -95,6 +119,16 @@ export interface RuleConsentDeps {
95
119
  /** Injectable clock/id for deterministic tests; defaults are the real ones. */
96
120
  now?: () => Date;
97
121
  newId?: () => string;
122
+ /**
123
+ * Deployment lever for the card-edit face: whether `confirmRuleApproval` accepts a FRESH
124
+ * `editedCandidate`. Absent or `false` = OFF (the default — the edit face widens what a fabricated
125
+ * confirmation could mint, from "one of the engine's bounded candidates" to "any same-head rule
126
+ * passing the coverage gate", so it is opt-in). Any other non-boolean value is a configuration
127
+ * mistake and refuses LOUDLY at the read — never silently mapped to a default. Replaying an edit a
128
+ * record already settled is a pure record read and does not consult this switch: the minting already
129
+ * happened, and withholding the receipt helps no one.
130
+ */
131
+ cardEdits?: boolean;
98
132
  }
99
133
  /** In-memory approval records — the test backend and the reference CAS semantics. */
100
134
  export declare class InMemoryRuleApprovalRecordStore implements RuleApprovalRecordStore {
@@ -157,21 +191,61 @@ export declare function confirmRuleApproval(opts: {
157
191
  /** design/182 §4.5 (additive): the structural owner — only the local-owner path needs it. */
158
192
  owner?: RuleOwner;
159
193
  /**
160
- * REQUIRED for a card record: the index of the option the person chose. A card presents alternatives of
161
- * different breadth, so "they said yes" is not an answer on its own — "they said yes to THIS one" is.
162
- * Rejected on a batch record, whose confirmation covers the previewed list by construction.
194
+ * REQUIRED for a card record settled through an ENGINE candidate: the index of the option the person
195
+ * chose. A card presents alternatives of different breadth, so "they said yes" is not an answer on
196
+ * its own — "they said yes to THIS one" is. Rejected on a batch record, whose confirmation covers
197
+ * the previewed list by construction. Mutually exclusive with `editedCandidate`.
163
198
  */
164
199
  selectedCandidate?: number;
200
+ /**
201
+ * The person-EDITED rule text for this card, travelling on the SAME authenticated confirmation
202
+ * channel as a choice among the engine's candidates (never the un-authenticated prepare entry, which
203
+ * keeps refusing caller candidates). `text` is the rule as authored; `boundInputHash` must echo the
204
+ * record's own bound-input digest — the submitter's proof of "I am editing the card that showed THIS
205
+ * command", an in-process mis-binding fence (a caller holding only a leaked approvalId cannot spell
206
+ * it), not a cryptographic one. The engine validates the text through the one shared validator,
207
+ * requires it to still ADMIT the adjudicated command, appends it as a new candidate and binds the
208
+ * selection to it, returning the minted ticket. Mutually exclusive with `selectedCandidate`.
209
+ */
210
+ editedCandidate?: {
211
+ text: string;
212
+ boundInputHash: string;
213
+ };
165
214
  deps: RuleConsentDeps;
166
215
  }): Promise<ConfirmResult>;
167
- /** Why a confirmation did not land. A closed set so a host can branch (re-present, re-fetch, give up). */
168
- export type ConfirmRefusalReason = "record_not_found" | "selection_missing" | "selection_invalid" | "selection_mismatch" | "batch_takes_no_selection" | "not_pending" | "conflict";
169
- /** The confirmation outcome: landed, or refused with a named reason. */
216
+ /** Why a confirmation did not land. A closed set so a host can branch (re-present, re-fetch, give up).
217
+ * The three `edit_*` members are the card-edit face's own refusals:
218
+ * - `"edit_disabled"` the deployment has not opted into card edits (`RuleConsentDeps.cardEdits`);
219
+ * - `"edit_binding_mismatch"` — the confirmation does not echo the record's bound-input digest
220
+ * (missing echo, a record minted without one, or a different card). Refused BEFORE anything else,
221
+ * settled replays included, and never returns a minted ticket;
222
+ * - `"edit_rejected"` — the edited text failed a gate (validator refusal, coverage, record shape);
223
+ * `detail` carries the specifics. */
224
+ export type ConfirmRefusalReason = "record_not_found" | "selection_missing" | "selection_invalid" | "selection_mismatch" | "batch_takes_no_selection" | "not_pending" | "conflict" | "edit_disabled" | "edit_binding_mismatch" | "edit_rejected";
225
+ /** The confirmation outcome: landed, or refused with a named reason.
226
+ *
227
+ * `mintedCandidate` (additive) is present exactly when an EDITED candidate settled this confirmation —
228
+ * fresh mint and idempotent replay alike (a client retrying a lost response gets the same index, the
229
+ * same canonical rule text and the same deterministically re-minted ticket, never a push toward a
230
+ * second card). `rule` is the CANONICAL spelling, which may differ from the submitted bytes (spelling
231
+ * normalization); a surface echoes it back so the person sees the form that will actually persist.
232
+ *
233
+ * `detail` (additive, refusal arm) rides `edit_rejected`: `code` is the shared validator's refusal
234
+ * code when the validator is what refused, absent when another gate did; `message` always says why. */
170
235
  export type ConfirmResult = {
171
236
  ok: true;
237
+ mintedCandidate?: {
238
+ index: number;
239
+ rule: string;
240
+ ticket: RuleTicket;
241
+ };
172
242
  } | {
173
243
  ok: false;
174
244
  reason: ConfirmRefusalReason;
245
+ detail?: {
246
+ code?: RuleRejectCode;
247
+ message: string;
248
+ };
175
249
  };
176
250
  /** What a redemption produced. `alreadyRedeemed` marks the replay path — the same dot, no second rule. */
177
251
  export type RedeemResult = {
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { parseAllowRuleText, suggestRulesForCommand } from "./permission-rule-model.js";
2
+ import { parseAllowRuleText, ruleAdmitsCommand, suggestRulesForCommand } from "./permission-rule-model.js";
3
3
  import { errText, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
4
4
  export class InMemoryRuleApprovalRecordStore {
5
5
  rows = new Map();
@@ -109,6 +109,7 @@ export async function prepareCardApproval(opts) {
109
109
  state: "pending",
110
110
  rev: 0,
111
111
  candidates,
112
+ command: opts.command,
112
113
  createdAt: nowIso(opts.deps),
113
114
  ...(opts.toolCallId !== undefined ? { toolCallId: opts.toolCallId } : {}),
114
115
  ...(opts.boundInputHash !== undefined ? { boundInputHash: opts.boundInputHash } : {}),
@@ -120,10 +121,17 @@ const CARD_RULE_TOOL = "Bash";
120
121
  export async function confirmRuleApproval(opts) {
121
122
  const caller = resolveCallerOwner(opts.principal, opts.owner, "confirmRuleApproval");
122
123
  const no = (reason) => ({ ok: false, reason });
124
+ if (opts.editedCandidate !== undefined && opts.selectedCandidate !== undefined) {
125
+ const e = new Error("confirmRuleApproval takes selectedCandidate OR editedCandidate, never both — one confirmation carries one choice");
126
+ e.code = "config.invalid_argument";
127
+ throw e;
128
+ }
123
129
  const rec = await opts.deps.approvals.get(opts.approvalId);
124
130
  const recOwner = rec === undefined ? undefined : ownerOfRecord(rec);
125
131
  if (rec === undefined || recOwner === undefined || !sameRuleOwner(recOwner, caller))
126
132
  return no("record_not_found");
133
+ if (opts.editedCandidate !== undefined)
134
+ return await confirmEditedCandidate(rec, opts.editedCandidate, opts.deps);
127
135
  if (rec.kind === "card") {
128
136
  const chosen = opts.selectedCandidate;
129
137
  if (chosen === undefined)
@@ -145,6 +153,89 @@ export async function confirmRuleApproval(opts) {
145
153
  const won = await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved" });
146
154
  return won ? { ok: true } : no("conflict");
147
155
  }
156
+ function normalizeEditedSpelling(text) {
157
+ const m = /^([A-Za-z][A-Za-z0-9_]*)\((.+) \*\)$/.exec(text);
158
+ if (m === null)
159
+ return text;
160
+ const head = m[1];
161
+ const body = m[2];
162
+ if (head === undefined || body === undefined || body.includes("*"))
163
+ return text;
164
+ return `${head}(${body}:*)`;
165
+ }
166
+ async function confirmEditedCandidate(rec, edit, deps) {
167
+ const no = (reason, detail) => ({
168
+ ok: false,
169
+ reason,
170
+ ...(detail !== undefined ? { detail } : {}),
171
+ });
172
+ if (rec.kind !== "card") {
173
+ return no("edit_rejected", { message: "a batch record takes no edited candidate — its confirmation covers the previewed list whole" });
174
+ }
175
+ if (typeof edit.boundInputHash !== "string" || edit.boundInputHash === "")
176
+ return no("edit_binding_mismatch");
177
+ if (rec.boundInputHash === undefined) {
178
+ return no("edit_binding_mismatch");
179
+ }
180
+ if (edit.boundInputHash !== rec.boundInputHash)
181
+ return no("edit_binding_mismatch");
182
+ if (rec.state === "approved" || rec.state === "redeemed") {
183
+ if (rec.edited === undefined)
184
+ return no("selection_mismatch");
185
+ const canonical = rec.candidates[rec.edited.index]?.rule;
186
+ if (canonical === undefined)
187
+ return no("selection_mismatch");
188
+ let hit = edit.text === rec.edited.text;
189
+ if (!hit) {
190
+ const reparsed = parseAllowRuleText(normalizeEditedSpelling(edit.text));
191
+ hit = "rule" in reparsed && reparsed.rule.rule === canonical;
192
+ }
193
+ if (!hit)
194
+ return no("selection_mismatch");
195
+ return { ok: true, mintedCandidate: { index: rec.edited.index, rule: canonical, ticket: mintRuleTicket(rec.id, rec.edited.index) } };
196
+ }
197
+ if (rec.state !== "pending")
198
+ return no("not_pending");
199
+ if (deps.cardEdits !== undefined && typeof deps.cardEdits !== "boolean") {
200
+ throw new Error(`RuleConsentDeps.cardEdits must be a boolean when present (got ${typeof deps.cardEdits}) — refusing to guess whether the card-edit face is enabled`);
201
+ }
202
+ if (deps.cardEdits !== true)
203
+ return no("edit_disabled");
204
+ const scope = rec.candidates[0]?.scope;
205
+ if (scope === undefined || !rec.candidates.every((c) => sameScope(c.scope, scope))) {
206
+ return no("edit_rejected", { message: "the record's candidates carry no single common scope — an edited candidate inherits the card's scope, and a record without one is malformed" });
207
+ }
208
+ if (rec.command === undefined) {
209
+ return no("edit_rejected", { message: "the record does not carry the adjudicated command (minted before card edits existed) — coverage cannot be verified, so the edit is refused" });
210
+ }
211
+ const parsed = parseAllowRuleText(normalizeEditedSpelling(edit.text));
212
+ if ("reject" in parsed)
213
+ return no("edit_rejected", { code: parsed.reject.code, message: parsed.reject.message });
214
+ if (!ruleAdmitsCommand(parsed.rule, rec.command)) {
215
+ return no("edit_rejected", {
216
+ message: `the edited rule "${parsed.rule.rule}" does not admit the command that was decided ("${rec.command}") — a card's edit may widen how much the rule covers, never move it to a different grant`,
217
+ });
218
+ }
219
+ const index = rec.candidates.length;
220
+ const next = {
221
+ ...rec,
222
+ rev: rec.rev + 1,
223
+ state: "approved",
224
+ candidates: [...rec.candidates, { rule: parsed.rule.rule, scope }],
225
+ selectedCandidate: index,
226
+ edited: { index, text: edit.text, at: nowIso(deps) },
227
+ };
228
+ const won = await deps.approvals.cas(rec.id, rec.rev, next);
229
+ if (!won) {
230
+ const again = await deps.approvals.get(rec.id);
231
+ if (again === undefined)
232
+ return no("record_not_found");
233
+ if (again.state === "pending")
234
+ return no("conflict");
235
+ return await confirmEditedCandidate(again, edit, deps);
236
+ }
237
+ return { ok: true, mintedCandidate: { index, rule: parsed.rule.rule, ticket: mintRuleTicket(rec.id, index) } };
238
+ }
148
239
  export async function redeemRuleTicket(opts) {
149
240
  const caller = resolveCallerOwner(opts.principal, opts.owner, "redeemRuleTicket");
150
241
  const parsed = parseRuleTicket(opts.ticket);
@@ -94,7 +94,7 @@ export interface RuleTombstone {
94
94
  deletedBy: RuleDot;
95
95
  }
96
96
  /** Why a rule text was refused. Codes are stable so an import report can group by them. */
97
- export type RuleRejectCode = "invalid.grammar" | "invalid.empty_command" | "invalid.not_simple_command" | "invalid.bare_interpreter_prefix" | "invalid.unbalanced_quotes" | "invalid.too_long" | "unsupported.tool" | "unsupported.wildcard";
97
+ export type RuleRejectCode = "invalid.grammar" | "invalid.empty_command" | "invalid.not_simple_command" | "invalid.bare_interpreter_prefix" | "invalid.unbalanced_quotes" | "invalid.control_chars" | "invalid.too_long" | "unsupported.tool" | "unsupported.wildcard";
98
98
  export interface RuleReject {
99
99
  code: RuleRejectCode;
100
100
  message: string;
@@ -167,6 +167,22 @@ export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
167
167
  * loosening; the other against a person being misled.
168
168
  */
169
169
  export declare const SUGGESTION_LEXICON: readonly string[];
170
+ /**
171
+ * Render a peer- or file-controlled value for a DISCLOSURE line (a warning, a refusal message, an
172
+ * operator log). Takes `unknown` on purpose: most of these values are typed but arrive off a wire or a
173
+ * file, so the runtime value can be anything, and a signature that demanded a string would push a bare
174
+ * `String(x)` to every call site — the exact step that gets forgotten. Rule texts are the motivating case; the same treatment is owed to every untrusted
175
+ * field a line interpolates (a wire `reason`, an actor id), since the hazard is the character class,
176
+ * not which field carries it.
177
+ *
178
+ * Quoting a refused text verbatim would carry the exact sequence the refusal exists to keep off a
179
+ * display surface, and would print the two texts a reader has to tell apart — `Bash(echo hi)` and
180
+ * `Bash(echo<ZWSP>hi)` — identically, so the report could not name WHICH rule it means. Every character
181
+ * {@link CONTROL_CHARS_RE} covers is therefore printed as its `\uXXXX` escape (`\u{XXXXX}` above the
182
+ * BMP — the TAG block U+E0020–U+E007F is a `\p{Cf}` family that lives there); everything else passes
183
+ * through, so an ordinary rule text reads normally. The result is length-bounded.
184
+ */
185
+ export declare function escapeForDisclosure(value: unknown): string;
170
186
  /**
171
187
  * Parse one rule text into its canonical shape, or refuse it with a reason.
172
188
  *
@@ -91,10 +91,31 @@ function foldSpacing(s) {
91
91
  function reject(code, message) {
92
92
  return { reject: { code, message } };
93
93
  }
94
+ const CONTROL_CHARS_RE = /[\u0000-\u0008\u000A-\u001F\u007F-\u009F\p{Cf}\u2028\u2029]/u;
95
+ const CONTROL_CHARS_GLOBAL_RE = new RegExp(CONTROL_CHARS_RE.source, "gu");
96
+ const DISCLOSED_RULE_TEXT_MAX_CHARS = 120;
97
+ export function escapeForDisclosure(value) {
98
+ let text;
99
+ try {
100
+ text = typeof value === "string" ? value : String(value);
101
+ }
102
+ catch {
103
+ return "<unprintable>";
104
+ }
105
+ const escaped = text.replace(CONTROL_CHARS_GLOBAL_RE, (ch) => {
106
+ const cp = ch.codePointAt(0) ?? 0;
107
+ const hex = cp.toString(16).toUpperCase();
108
+ return cp > 0xffff ? `\\u{${hex}}` : `\\u${hex.padStart(4, "0")}`;
109
+ });
110
+ return escaped.length <= DISCLOSED_RULE_TEXT_MAX_CHARS ? escaped : `${escaped.slice(0, DISCLOSED_RULE_TEXT_MAX_CHARS)}…`;
111
+ }
94
112
  export function parseAllowRuleText(text, opts) {
95
113
  if (text.length > MAX_RULE_TEXT_CHARS) {
96
114
  return reject("invalid.too_long", `rule text exceeds ${MAX_RULE_TEXT_CHARS} characters`);
97
115
  }
116
+ if (CONTROL_CHARS_RE.test(text)) {
117
+ return reject("invalid.control_chars", "rule text contains control characters (C0/C1, tab excepted), which cannot be part of a readable command");
118
+ }
98
119
  const parsed = parsePermissionRule(text);
99
120
  if (parsed.ruleContent === undefined) {
100
121
  return reject("invalid.grammar", `"${text}" is not a Tool(content) rule — a bare tool name claims the whole tool and is not a command rule`);
@@ -34,7 +34,7 @@
34
34
  * accept revision N−1 after a restart. Signature / predecessor chaining belongs to the privilege-
35
35
  * separation ticket; the high-water mark is the floor that needs no key distribution.
36
36
  */
37
- import { type RuleScope } from "./permission-rule-model.js";
37
+ import { type RuleRejectCode, type RuleScope } from "./permission-rule-model.js";
38
38
  import { type PermissionRuleStore, type PermissionRuleStoreProvider, type RuleOwner } from "./permission-rule-store.js";
39
39
  /** One org rule. There is structurally no allow bucket (design/179 §9: the org layer only tightens). */
40
40
  export interface OrgPermissionRule {
@@ -160,13 +160,32 @@ export declare function createOrgRuleOverlay(cfg: {
160
160
  stalenessBoundMs: number;
161
161
  now?: () => number;
162
162
  }): OrgRuleOverlay;
163
+ /**
164
+ * Which entries of an org snapshot the CURRENT rule validator refuses — the published deny/ask rules
165
+ * that can match nothing on this build. Empty ⟺ every rule is enforceable.
166
+ *
167
+ * Separate from {@link validateOrgSnapshot} because BOTH gates need it: the install gate (a fetched
168
+ * snapshot) and the serving gate (backlog #177 — a snapshot persisted by an earlier build is read back
169
+ * and served without ever having met this validator). It reports every offender rather than the first,
170
+ * since a refusal an administrator cannot act on names no line to fix.
171
+ */
172
+ export declare function unenforceableOrgRules(rules: readonly OrgPermissionRule[]): Array<{
173
+ rule: string;
174
+ code: RuleRejectCode;
175
+ }>;
163
176
  /**
164
177
  * design/182 §7.2 — which org rule speaks for this command, if any. Deny outranks ask; within a
165
178
  * behavior the first textual match wins (reporting order only — all denies are the same one answer).
166
179
  * Matching uses the SAME parser and matcher the personal lane uses (tighten direction: an
167
180
  * interpreter-headed prefix deny like `Bash(node:*)` is a legitimately wide tightening and matches).
168
- * A rule text the validator refuses never gets here — snapshot validation refuses the whole snapshot,
169
- * so an unenforceable deny cannot install as policy; the skip below is a defensive floor only.
181
+ *
182
+ * The skip below is a defensive floor, and what stands behind it is worth stating precisely: rules that
183
+ * arrive from {@link OrgRuleOverlay.resolve} have passed {@link validateOrgSnapshot} on BOTH the install
184
+ * and the serving path (backlog #177), so an unenforceable entry cannot reach here through the overlay
185
+ * — the whole snapshot is refused, or the resolution reports `unavailable`, and either way it is
186
+ * disclosed. A caller that hands this function a snapshot it obtained ELSEWHERE gets no such guarantee:
187
+ * the skip is silent, and naming the dead entries is that caller's job — {@link unenforceableOrgRules}
188
+ * is the shared way to do it.
170
189
  */
171
190
  export declare function orgRuleVerdictFor(rules: readonly OrgPermissionRule[], call: {
172
191
  tool: string;