@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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +133 -2
- package/dist/after.d.ts +55 -0
- package/dist/after.js +189 -0
- package/dist/card-check.d.ts +44 -0
- package/dist/card-check.js +68 -0
- package/dist/cards.d.ts +34 -0
- package/dist/cards.js +141 -0
- package/dist/classify.d.ts +76 -0
- package/dist/classify.js +179 -0
- package/dist/commands.d.ts +3 -0
- package/dist/commands.js +104 -0
- package/dist/context.d.ts +42 -0
- package/dist/context.js +103 -0
- package/dist/escalate.d.ts +38 -0
- package/dist/escalate.js +72 -0
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +54 -0
- package/dist/glob.d.ts +10 -0
- package/dist/glob.js +23 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +194 -0
- package/dist/interaction.d.ts +28 -0
- package/dist/interaction.js +93 -0
- package/dist/judge.d.ts +43 -0
- package/dist/judge.js +75 -0
- package/dist/live.d.ts +31 -0
- package/dist/live.js +311 -0
- package/dist/log.d.ts +28 -0
- package/dist/log.js +109 -0
- package/dist/questions.d.ts +7 -0
- package/dist/questions.js +18 -0
- package/dist/route.d.ts +64 -0
- package/dist/route.js +513 -0
- package/dist/settings.d.ts +48 -0
- package/dist/settings.js +167 -0
- package/dist/skills.d.ts +33 -0
- package/dist/skills.js +203 -0
- package/dist/state.d.ts +59 -0
- package/dist/state.js +110 -0
- package/dist/targets.d.ts +8 -0
- package/dist/targets.js +32 -0
- package/package.json +60 -5
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;
|