@sigrun/warden 0.0.0-stage → 0.1.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.
package/dist/route.js ADDED
@@ -0,0 +1,513 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { redact } from "pi-warden";
3
+ import { cardCheck } from "./card-check.js";
4
+ import { buildCard } from "./cards.js";
5
+ import { EVIDENCE_BUDGET_BYTES, evidenceFor } from "./evidence.js";
6
+ import { holdForPerson } from "./interaction.js";
7
+ import { raiseEscalation, resolveGrant, takeGrant } from "./escalate.js";
8
+ import { skillsInRun } from "./skills.js";
9
+ import { callFingerprint, grantKey } from "./state.js";
10
+ import { targetsOf } from "./targets.js";
11
+ import { Cooldown } from "./judge.js";
12
+ import { evaluateLive } from "./live.js";
13
+ /** Collision-impossible live-hold nonce (6 random bytes as hex). */
14
+ const newNonce = () => randomBytes(6).toString("hex");
15
+ function callLog(input, base) {
16
+ const presence = input.presence;
17
+ return {
18
+ ...base,
19
+ ...(presence.kind === "job" ? { jobId: presence.jobId } : {}),
20
+ ...("owner" in presence && presence.owner !== undefined
21
+ ? { owner: presence.owner }
22
+ : {}),
23
+ call: redact(JSON.stringify(input.call.arguments)),
24
+ };
25
+ }
26
+ /**
27
+ * Route one classified call. Returns `{block}` to block, undefined to
28
+ * allow. Nested calls (`call.parent` set — codemode/executeTool) route
29
+ * EXACTLY like model-issued calls: no special-casing.
30
+ */
31
+ export async function routeOffline(input) {
32
+ const { classification, presence, call } = input;
33
+ const fp = callFingerprint(call);
34
+ const key = grantKey(call.name, call.arguments);
35
+ const logBase = {
36
+ conversationId: input.conversationId,
37
+ tool: call.name,
38
+ };
39
+ // A deny classification blocks in every presence.
40
+ if (classification.kind === "deny") {
41
+ await input.log.append({
42
+ ...callLog(input, logBase),
43
+ at: new Date().toISOString(),
44
+ action: "block",
45
+ outcome: "declined",
46
+ });
47
+ await input.store.update(input.conversationId, (state) => {
48
+ state.tainted = true;
49
+ });
50
+ return { block: classification.reason };
51
+ }
52
+ if (classification.kind !== "destructive" && classification.kind !== "bash-unknown") {
53
+ // Allowed calls still record their warn rules (classification.warns).
54
+ if (classification.warns !== undefined && classification.warns.length > 0) {
55
+ await input.log.append({
56
+ ...callLog(input, logBase),
57
+ at: new Date().toISOString(),
58
+ action: "warn",
59
+ });
60
+ }
61
+ return undefined;
62
+ }
63
+ const reasons = classification.kind === "bash-unknown"
64
+ ? [`unrecognized shell command: ${classification.command}`]
65
+ : classification.reasons;
66
+ // Internal (curator) presence: block outright, no escalation.
67
+ if (presence.kind === "internal") {
68
+ const reason = reasons.join("; ");
69
+ await input.log.append({
70
+ ...callLog(input, logBase),
71
+ at: new Date().toISOString(),
72
+ action: "block",
73
+ outcome: "declined",
74
+ });
75
+ return { block: `This call can't run in a curator review: ${reason}` };
76
+ }
77
+ // Live presence: the Task 12 flow (clear ask, steer, approval on
78
+ // reply, bash-unknown). Destructive calls go through it only with a
79
+ // judge; bash-unknown always does (judge-absent is pi-warden's
80
+ // offline allow). An "ask the person" outcome — or a judge-free
81
+ // destructive call — takes the Task 11 interaction hold.
82
+ if (presence.kind === "live" &&
83
+ (classification.kind === "bash-unknown" || input.judge !== undefined)) {
84
+ const outcome = await evaluateLive({
85
+ ...input,
86
+ judge: input.judge,
87
+ cooldown: input.cooldown ?? new Cooldown(),
88
+ });
89
+ if (outcome.action === "run")
90
+ return undefined;
91
+ if (outcome.action === "steer")
92
+ return { block: outcome.text };
93
+ // "ask-person" falls through to the interaction hold below.
94
+ }
95
+ // Live presence: an interaction question per fingerprint.
96
+ //
97
+ // Consumption design (fail-closed, see the task report): interaction
98
+ // records are seeded first-write-wins and nothing re-pends an ANSWERED
99
+ // record, so ONE answered record must never release more than one call.
100
+ // The store therefore pins a per-hold NONCE in the blocked record
101
+ // (`afterEntry`): the interaction callId is `warden-<fp>-<nonce>`. A
102
+ // re-entered beforeTool (same fingerprint, nonce unchanged) waits on
103
+ // the SAME record; once an answer is consumed (allow released one call,
104
+ // or deny blocked it), the next beforeTool for the fingerprint rotates
105
+ // the nonce, seeding a FRESH card. One answered card can never release
106
+ // two calls, and a denied retry can't be silently allowed by the stale
107
+ // answer.
108
+ if (presence.kind === "live") {
109
+ const nowIso = new Date().toISOString();
110
+ // One store.update registers the hold (or rotates it once an
111
+ // answer is consumed) so re-entries and new-callId retries join the
112
+ // same question.
113
+ const decision = await input.store.update(input.conversationId, (state) => {
114
+ if (state.runStart !== input.run.runStart) {
115
+ state.runStart = input.run.runStart;
116
+ state.steers = 0;
117
+ state.tainted = undefined;
118
+ }
119
+ const record = state.blocked.find((entry) => entry.fingerprint === fp);
120
+ if (record === undefined) {
121
+ const next = newNonce();
122
+ state.blocked.push({ fingerprint: fp, at: nowIso, afterEntry: next, status: "pending" });
123
+ return { nonce: next, newHold: true };
124
+ }
125
+ if (record.status === "answered" || record.status === "released") {
126
+ // The previous question was consumed by an answer: rotate for a
127
+ // FRESH card. An already-released one-shot allow never frees a
128
+ // second call.
129
+ const next = newNonce();
130
+ record.at = nowIso;
131
+ record.afterEntry = next;
132
+ record.status = "pending";
133
+ state.released = state.released.filter((entry) => entry.fingerprint !== fp);
134
+ return { nonce: next, newHold: true };
135
+ }
136
+ return { nonce: record.afterEntry ?? (record.afterEntry = newNonce()), newHold: false };
137
+ });
138
+ const outcome = await holdForPerson(input.host, call, input.conversationId, `${fp}-${decision.nonce}`, reasons, input.context);
139
+ return finishAnswer(input, fp, decision.nonce, logBase, outcome);
140
+ }
141
+ // Job presence: block + escalate with one-shot grants.
142
+ return jobRoute(input, fp, key, reasons, logBase);
143
+ }
144
+ async function finishAnswer(input, fp, nonce, logBase, outcome) {
145
+ const { call } = input;
146
+ if (outcome.answer === "allow") {
147
+ // The FIRST waiter to claim the answer releases; every other
148
+ // waiter on the same card re-waits on the CURRENT hold.
149
+ const released = await input.store.update(input.conversationId, (state) => {
150
+ const record = state.blocked.find((entry) => entry.fingerprint === fp);
151
+ // Another waiter already consumed this answer (or the hold was
152
+ // rotated while we were polling): this call is not released by it.
153
+ if (record === undefined || record.status !== "pending")
154
+ return false;
155
+ record.status = "released";
156
+ state.released.push({ fingerprint: fp, replyEntry: `${call.id}:${nonce}` });
157
+ return true;
158
+ });
159
+ if (!released) {
160
+ // Lost the claim race: the sibling already consumed this card.
161
+ // BLOCK rather than re-poll the answered record (that hot-spins
162
+ // two disk-writing store.updates per iteration until cancel —
163
+ // the I-1 livelock). Exactly-one-release is preserved: the model
164
+ // retries after the sibling settles and gets a fresh card.
165
+ return { block: "an identical call is already awaiting approval" };
166
+ }
167
+ await input.log.append({
168
+ ...callLog(input, logBase),
169
+ at: new Date().toISOString(),
170
+ action: "release",
171
+ outcome: "approved",
172
+ });
173
+ return undefined;
174
+ }
175
+ const reason = outcome.reason === "" ? "denied by the answering person" : outcome.reason;
176
+ const first = await input.store.update(input.conversationId, (state) => {
177
+ const record = state.blocked.find((entry) => entry.fingerprint === fp);
178
+ if (record !== undefined && record.status === "pending") {
179
+ record.status = "answered";
180
+ return true;
181
+ }
182
+ return false;
183
+ });
184
+ if (!first) {
185
+ // A co-waiter already consumed this deny: same lost-claim block,
186
+ // never a spin.
187
+ return { block: "an identical call is already awaiting approval" };
188
+ }
189
+ await input.log.append({
190
+ ...callLog(input, logBase),
191
+ at: new Date().toISOString(),
192
+ action: "block",
193
+ outcome: "declined",
194
+ });
195
+ return { block: `Held ${call.name} denied: ${reason}` };
196
+ }
197
+ /**
198
+ * The job-presence slice. Everything that decides allow/block runs
199
+ * inside ONE store.update so parallel identical calls serialize.
200
+ */
201
+ async function jobRoute(input, fp, key, reasons, logBase) {
202
+ const { presence, call, run } = input;
203
+ if (presence.kind !== "job")
204
+ throw new Error("jobRoute in a non-job presence");
205
+ // A reservation whose call never ran is dropped by the escalations
206
+ // run-end sweep; here warden only drops reservations from older runs.
207
+ const decision = await input.store.update(input.conversationId, (state) => {
208
+ if (state.runStart !== run.runStart) {
209
+ const startingNewRun = state.runStart !== undefined && run.runStart !== undefined;
210
+ state.runStart = run.runStart;
211
+ state.steers = 0;
212
+ state.tainted = undefined;
213
+ if (startingNewRun) {
214
+ // Reservations from an EARLIER run are stale (the sweep owns
215
+ // their escalation item; keeping them would confuse a
216
+ // restarted run). Within one run (same runStart), or before
217
+ // the first user entry, nothing is dropped.
218
+ state.reservations = state.reservations.filter(() => false);
219
+ }
220
+ }
221
+ const reserved = state.reservations.find((entry) => entry.grantKey === key);
222
+ // Only real (escalation-backed) reservations make a sibling call
223
+ // 'dup'; an EMPTY slot (take/takeGrant still in flight) must wait
224
+ // its turn, not block as a duplicate.
225
+ if (reserved !== undefined && reserved.escalationId !== "" && reserved.callId !== call.id) {
226
+ return { kind: "dup" };
227
+ }
228
+ if (reserved !== undefined && reserved.escalationId === "" && reserved.callId !== call.id) {
229
+ // A sibling is being decided; ask it to yield to this caller so
230
+ // this caller does not ALSO take/raise (double grant).
231
+ return { kind: "contended" };
232
+ }
233
+ if (reserved !== undefined) {
234
+ // 'own' requires a REAL grant: an empty placeholder slot (pushed
235
+ // before the take ran) is NOT a reservation — a re-entered call
236
+ // whose take never landed must decide/take again, never run on
237
+ // the placeholder alone (the C-1 fail-open hole).
238
+ if (reserved.escalationId !== "") {
239
+ return { kind: "own", escalationId: reserved.escalationId };
240
+ }
241
+ return { kind: "decide", hasSlot: true };
242
+ }
243
+ return { kind: "decide", hasSlot: false };
244
+ });
245
+ if (decision.kind === "dup") {
246
+ return { block: "an identical call is already running under this approval" };
247
+ }
248
+ if (decision.kind === "own") {
249
+ return undefined; // re-entered with the same call id — still allowed
250
+ }
251
+ if (decision.kind === "contended") {
252
+ // A sibling's take/raise is in flight; wait for it to settle.
253
+ // Poll the store until the sibling's slot either resolves to a
254
+ // real reservation (this caller is then 'dup') or disappears
255
+ // (its raise failed; this caller decides). Bounded so a stuck
256
+ // sibling cannot spin this waiter forever.
257
+ for (let spins = 0; spins < 200; spins += 1) {
258
+ await new Promise((resolve) => setTimeout(resolve, 5));
259
+ const sibling = await input.store.update(input.conversationId, (state) => state.reservations.find((entry) => entry.grantKey === key && entry.callId !== call.id));
260
+ if (sibling === undefined)
261
+ return jobRoute(input, fp, key, reasons, logBase);
262
+ if (sibling.escalationId !== "") {
263
+ return { block: "an identical call is already running under this approval" };
264
+ }
265
+ }
266
+ return { block: "warden error: a sibling hold never settled" };
267
+ }
268
+ // Only ONE unreserved call per grant key proceeds past this line;
269
+ // claim a slot IN THE STORE before hitting the escalation hooks so
270
+ // parallel identical calls serialize (the brief's rule). A slot whose
271
+ // escalation id never lands (take didn't yield a grant) is removed
272
+ // below; one whose call never runs is left for the sweep like a real
273
+ // reservation. A re-entry onto its OWN empty placeholder keeps that
274
+ // slot (it serializes against siblings) but takes fresh.
275
+ if (!decision.hasSlot) {
276
+ await input.store.update(input.conversationId, (state) => {
277
+ state.reservations.push({ grantKey: key, callId: call.id, escalationId: "" });
278
+ });
279
+ }
280
+ let taken;
281
+ try {
282
+ taken = await takeGrant(input.host, key, presence.jobId, input.conversationId);
283
+ }
284
+ catch (error) {
285
+ // A thrown take must not leak the placeholder: clear the slot so
286
+ // the key isn't wedged as 'contended' for the rest of the run.
287
+ await input.store.update(input.conversationId, (state) => {
288
+ state.reservations = state.reservations.filter((entry) => !(entry.grantKey === key && entry.callId === call.id));
289
+ });
290
+ throw error;
291
+ }
292
+ if (taken.status === "claimed" && taken.option === "approve" && taken.id !== undefined) {
293
+ // Swap the slot's placeholder for the real item id.
294
+ await input.store.update(input.conversationId, (state) => {
295
+ const reservation = state.reservations.find((entry) => entry.grantKey === key && entry.callId === call.id);
296
+ if (reservation !== undefined)
297
+ reservation.escalationId = taken.id;
298
+ });
299
+ await input.log.append({
300
+ ...callLog(input, logBase),
301
+ at: new Date().toISOString(),
302
+ action: "release",
303
+ outcome: "approved",
304
+ });
305
+ return undefined;
306
+ }
307
+ if (taken.status !== "none") {
308
+ // A grant exists in a non-approving state (open, answered-deny):
309
+ // it decides before the clearance path (brief step 1: the grant
310
+ // check first, unchanged from Task 11). Release this call's slot
311
+ // first, then take the block + raise path (the raise dedups onto
312
+ // the EXISTING item, so no duplicate).
313
+ //
314
+ // NOTE: `absent` (no escalation provider) is NOT a grant: a
315
+ // cleared call must not be shadowed by a missing inbox. `takeGrant`
316
+ // maps the registry's `absent` to `none` (Task 16 e2e regression).
317
+ await input.store.update(input.conversationId, (state) => {
318
+ state.reservations = state.reservations.filter((entry) => !(entry.grantKey === key && entry.callId === call.id));
319
+ });
320
+ return jobRaise(input, reasons, logBase);
321
+ }
322
+ // Task 14: a call CLEARED for this job + tool may run autonomously,
323
+ // but only after the per-target card check. The clearance is a
324
+ // settings entry (`{job, tools}`) — never a skill. Everything else
325
+ // keeps the Task 11 block + escalate path below.
326
+ const cleared = input.settings.clearances.some((clearance) => clearance.job === presence.jobId && clearance.tools.includes(call.name));
327
+ if (cleared) {
328
+ // The card-check path does not use the grant reservation: release
329
+ // the placeholder slot before judging.
330
+ await input.store.update(input.conversationId, (state) => {
331
+ state.reservations = state.reservations.filter((entry) => !(entry.grantKey === key && entry.callId === call.id));
332
+ });
333
+ return clearedJobRoute(input, reasons, logBase);
334
+ }
335
+ return jobRaise(input, reasons, logBase);
336
+ }
337
+ /**
338
+ * The Task 11 tail: raise (idempotent by grant key) and block. Split
339
+ * out so jobRoute's post-grant no-grant exit can share it.
340
+ */
341
+ async function jobRaise(input, reasons, logBase) {
342
+ const { presence, call } = input;
343
+ if (presence.kind !== "job")
344
+ throw new Error("jobRaise in a non-job presence");
345
+ const key = grantKey(call.name, call.arguments);
346
+ // No usable grant: if a sibling is still deciding this key, wait for
347
+ // its outcome — a granted sibling makes this call 'dup', a raised one
348
+ // makes this call block with the same item. Bounded.
349
+ for (let spins = 0;; spins += 1) {
350
+ const sibling = await input.store.update(input.conversationId, (state) => state.reservations.find((entry) => entry.grantKey === key && entry.callId !== call.id));
351
+ if (sibling === undefined)
352
+ break; // no sibling: this call raises below
353
+ if (sibling.escalationId !== "") {
354
+ // The sibling's grant landed; drop this call's slot and block dup.
355
+ await input.store.update(input.conversationId, (state) => {
356
+ state.reservations = state.reservations.filter((entry) => !(entry.grantKey === key && entry.callId === call.id));
357
+ });
358
+ return { block: "an identical call is already running under this approval" };
359
+ }
360
+ if (spins >= 200) {
361
+ return { block: "warden error: a sibling hold never settled" };
362
+ }
363
+ await new Promise((resolve) => setTimeout(resolve, 5));
364
+ }
365
+ // No usable grant: release the slot, raise (idempotent by key), block.
366
+ await input.store.update(input.conversationId, (state) => {
367
+ state.reservations = state.reservations.filter((entry) => !(entry.grantKey === key && entry.callId === call.id));
368
+ });
369
+ // No usable grant: raise (idempotent by grant key) and block.
370
+ const raised = await raiseEscalation(input.host, call, key, presence.jobId, presence.owner, reasons, input.conversationId);
371
+ await input.log.append({
372
+ ...callLog(input, logBase),
373
+ at: new Date().toISOString(),
374
+ action: "escalate",
375
+ outcome: "escalated",
376
+ });
377
+ const id = raised.id;
378
+ if (id === undefined) {
379
+ // No escalation inbox answered: name the reasons, never "unknown".
380
+ return {
381
+ block: `Not cleared for this job without a person's approval; no escalation inbox is installed. Reasons: ${reasons.join("; ")}`,
382
+ };
383
+ }
384
+ return {
385
+ block: `Not cleared for this job without a person's approval; raised escalation ${id}.`,
386
+ };
387
+ }
388
+ const MAX_TARGETS = 16;
389
+ /**
390
+ * The cleared-job path (Task 14, brief order): a cleared destructive
391
+ * call runs autonomously only after a per-target card check against the
392
+ * decision model. Every failure mode blocks + escalates — never runs.
393
+ */
394
+ async function clearedJobRoute(input, reasons, logBase) {
395
+ const { call, presence } = input;
396
+ if (presence.kind !== "job")
397
+ throw new Error("clearedJobRoute outside a job presence");
398
+ const blockEscalate = async (block, escalateReasons) => {
399
+ const raised = await raiseEscalation(input.host, call, grantKey(call.name, call.arguments), presence.jobId, presence.owner, escalateReasons, input.conversationId);
400
+ await input.log.append({
401
+ ...callLog(input, logBase),
402
+ at: new Date().toISOString(),
403
+ action: "escalate",
404
+ outcome: "escalated",
405
+ });
406
+ if (raised.id === undefined) {
407
+ return {
408
+ block: `${block}; no escalation inbox is installed. Reasons: ${escalateReasons.join("; ")}`,
409
+ };
410
+ }
411
+ return { block: `${block}; raised escalation ${raised.id}.` };
412
+ };
413
+ // 1. No decision model (undefined judge, or the cooldown active →
414
+ // decisionsJudge returned undefined upstream; a thrown judgment →
415
+ // unjudgeable below). All no-model outcomes block + escalate.
416
+ const coolingDown = input.cooldown?.active === true;
417
+ if (input.judge === undefined || coolingDown) {
418
+ return blockEscalate(`Not running ${call.name} autonomously: no decision model available`, [...reasons, "no decision model available"]);
419
+ }
420
+ // 2. A tainted run never runs autonomously this run.
421
+ const state = await input.store.load(input.conversationId);
422
+ if (state.tainted === true) {
423
+ return blockEscalate(`Not running ${call.name} autonomously: instructions found in tool output this run`, [...reasons, "instructions found in tool output this run"]);
424
+ }
425
+ // 3. Over 16 targets: steer the split, no decision request.
426
+ const targets = targetsOf(call.arguments);
427
+ if (targets.length > MAX_TARGETS) {
428
+ return blockEscalate(`Not running ${call.name} autonomously: too many targets (${targets.length}); split this into calls of at most ${MAX_TARGETS} targets`, [...reasons, "too many targets"]);
429
+ }
430
+ // 4. The governing cards: those of skillsInRun whose text names the
431
+ // tool's mcpName or sigrun name. A missing card may be built inline.
432
+ const skills = skillsInRun(input.run.entries);
433
+ const cards = [];
434
+ for (const skill of skills) {
435
+ const card = await buildCard(input.host, skill, input.settings);
436
+ if (card === undefined)
437
+ continue;
438
+ cards.push(card);
439
+ }
440
+ const names = [input.classification.kind === "destructive" ? input.classification.mcp?.mcpName : undefined, call.name].filter((name) => name !== undefined);
441
+ const governing = cards.filter((card) => names.some((name) => card.text.includes(name)));
442
+ const displayName = input.classification.kind === "destructive" && input.classification.mcp !== undefined
443
+ ? input.classification.mcp.mcpName
444
+ : call.name;
445
+ if (governing.length === 0) {
446
+ return blockEscalate(`Not running ${call.name} autonomously: no skill card covers ${displayName}`, [...reasons, `no skill card covers ${displayName}`]);
447
+ }
448
+ // 5. The card check.
449
+ const outcome = await cardCheck({
450
+ judge: input.judge,
451
+ cards: governing,
452
+ call,
453
+ ...(input.classification.kind === "destructive" &&
454
+ input.classification.mcp !== undefined
455
+ ? { mcpName: input.classification.mcp.mcpName }
456
+ : {}),
457
+ targets,
458
+ evidence: evidenceFor(input.run, targets, EVIDENCE_BUDGET_BYTES),
459
+ threshold: input.settings.thresholds.card,
460
+ });
461
+ if (outcome.pass === true) {
462
+ await input.log.append({
463
+ ...callLog(input, logBase),
464
+ at: new Date().toISOString(),
465
+ action: "allow-judged",
466
+ scores: outcome.scores,
467
+ });
468
+ return undefined;
469
+ }
470
+ if (outcome.pass === "unjudgeable") {
471
+ return blockEscalate(`Not running ${call.name} autonomously: no decision model available (${outcome.reason})`, [...reasons, `no decision model available (${outcome.reason})`]);
472
+ }
473
+ return blockEscalate(`Not running ${call.name} autonomously: the card check failed for ${outcome.failing.join(", ")} (${outcome.reasons.join("; ")})`, [...reasons, ...outcome.reasons]);
474
+ }
475
+ /**
476
+ * The Task-11 slice of afterTool: resolve the grant this call's
477
+ * reservation names and drop the reservation. The planted-instructions /
478
+ * masking / stuck guards are Task 15 and are omitted here entirely.
479
+ */
480
+ export async function resolveReservedGrant(input) {
481
+ const key = grantKey(input.call.name, input.call.arguments);
482
+ const reservation = await input.store.update(input.conversationId, (state) => {
483
+ const index = state.reservations.findIndex((entry) => entry.grantKey === key && entry.callId === input.call.id);
484
+ if (index < 0)
485
+ return undefined;
486
+ const [found] = state.reservations.splice(index, 1);
487
+ return found;
488
+ });
489
+ if (reservation === undefined)
490
+ return;
491
+ const failed = input.result.isError === true;
492
+ try {
493
+ await resolveGrant(input.host, reservation.escalationId, failed ? "failed" : "done", failed ? "warden: the call failed" : "warden: executed", input.conversationId);
494
+ }
495
+ catch (error) {
496
+ console.warn(`warden: resolving grant ${reservation.escalationId} failed`, error);
497
+ }
498
+ }
499
+ /** One background prune per process, never awaited by the hook. */
500
+ let pruneStarted = false;
501
+ /** Test hook: reset the once-per-process prune guard. */
502
+ export function resetPruneGuardForTests() {
503
+ pruneStarted = false;
504
+ }
505
+ /** Kick `log.prune` exactly once per process; failures go to console.warn. */
506
+ export function startPruneOnce(log, retentionDays) {
507
+ if (pruneStarted)
508
+ return;
509
+ pruneStarted = true;
510
+ void log
511
+ .prune(retentionDays)
512
+ .catch((error) => console.warn("warden: hold-log prune failed", error));
513
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Warden settings: parsed from the extension's settings document
3
+ * (the extension's own scoped settings document, `settings/sigrun-warden.json`).
4
+ * Every invalid value falls back
5
+ * to its default with ONE `console.warn` naming the key, at most once
6
+ * per key per process — `parseSettings` runs per factory, so the dedup
7
+ * set is module-scoped; tests that need a fresh warn pass new keys or
8
+ * call `resetWarnedKeys()` (test hook).
9
+ */
10
+ export interface WardenThresholds {
11
+ clearAsk: number;
12
+ card: number;
13
+ irreversible: number;
14
+ }
15
+ export interface WardenClearance {
16
+ job: string;
17
+ tools: readonly string[];
18
+ }
19
+ export interface WardenRule {
20
+ match: "command" | "path" | "tool";
21
+ pattern: string;
22
+ level: "warn" | "hold" | "deny";
23
+ }
24
+ export interface WardenRetentionDays {
25
+ calls: number;
26
+ holds: number;
27
+ }
28
+ export interface WardenSettings {
29
+ judge: {
30
+ enabled: boolean;
31
+ };
32
+ decisionModel?: string;
33
+ thresholds: WardenThresholds;
34
+ clearances: readonly WardenClearance[];
35
+ rules: readonly WardenRule[];
36
+ cardModel?: string;
37
+ steerBudget: number;
38
+ retentionDays: WardenRetentionDays;
39
+ }
40
+ export declare function defaultSettings(): WardenSettings;
41
+ /** Test hook: clear the once-per-process warn dedup. */
42
+ export declare function resetWarnedKeys(): void;
43
+ /**
44
+ * Parse the raw settings document. `raw` is the settings-store value; an
45
+ * absent or non-object raw value yields all defaults. Each invalid key
46
+ * warns once per process and falls back independently.
47
+ */
48
+ export declare function parseSettings(raw: Record<string, unknown>, warn: (message: string) => void): WardenSettings;