@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/escalate.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { redact } from "pi-warden";
|
|
2
|
+
/** The human-facing body of a raised hold: tool, redacted args, reasons. */
|
|
3
|
+
export function escalationBody(call, reasons) {
|
|
4
|
+
const redactedArgs = redact(JSON.stringify(call.arguments));
|
|
5
|
+
const reasonLines = reasons.map((reason) => `- ${reason}`).join("\n");
|
|
6
|
+
return `The warden held \`${call.name}\` in a job run.\n\nArguments:\n\`\`\`json\n${redactedArgs}\n\`\`\`\n\n${reasonLines}`;
|
|
7
|
+
}
|
|
8
|
+
/** One boot-scoped warning when job holds have no inbox to land in. */
|
|
9
|
+
let warnedNoProvider = false;
|
|
10
|
+
/** Test hook: reset the once-per-process no-provider warning. */
|
|
11
|
+
export function resetNoProviderWarning() {
|
|
12
|
+
warnedNoProvider = false;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Raise (or dedup) the hold as an escalation item. With no escalation
|
|
16
|
+
* provider the hold still surfaces: a notice to the job's owner keyed
|
|
17
|
+
* by the grant key (skipped when the job has no owner), plus one
|
|
18
|
+
* console.warn per process.
|
|
19
|
+
*/
|
|
20
|
+
export async function raiseEscalation(host, call, key, jobId, owner, reasons, conversationId) {
|
|
21
|
+
const body = escalationBody(call, reasons);
|
|
22
|
+
const result = (await host.publishedHooks.call("escalation.raise", {
|
|
23
|
+
key,
|
|
24
|
+
title: `Held: ${call.name}`,
|
|
25
|
+
body,
|
|
26
|
+
options: [
|
|
27
|
+
{ id: "approve", label: "Approve and run once" },
|
|
28
|
+
{ id: "decline", label: "Decline" },
|
|
29
|
+
],
|
|
30
|
+
allowInstruction: false,
|
|
31
|
+
onAnswer: "next-run",
|
|
32
|
+
jobId,
|
|
33
|
+
}, { conversationId }));
|
|
34
|
+
if (result.status === "absent") {
|
|
35
|
+
if (!warnedNoProvider) {
|
|
36
|
+
warnedNoProvider = true;
|
|
37
|
+
console.warn("warden: no escalation provider; job holds have no inbox (surface via notices)");
|
|
38
|
+
}
|
|
39
|
+
if (owner !== undefined) {
|
|
40
|
+
await host.notices.post({
|
|
41
|
+
user: owner,
|
|
42
|
+
key,
|
|
43
|
+
title: `Held: ${call.name}`,
|
|
44
|
+
body,
|
|
45
|
+
tone: "warn",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return { status: "absent" };
|
|
49
|
+
}
|
|
50
|
+
const ok = result;
|
|
51
|
+
return { status: ok.status, id: ok.id, created: ok.created };
|
|
52
|
+
}
|
|
53
|
+
/** Claim an answered item for the calling conversation (or report none). */
|
|
54
|
+
export async function takeGrant(host, key, jobId, conversationId) {
|
|
55
|
+
const result = (await host.publishedHooks.call("escalation.take", { key, jobId }, { conversationId }));
|
|
56
|
+
// A missing provider answers `absent`; that is NOT a grant in a
|
|
57
|
+
// non-approving state (the caller's `!== "none"` gate would read it as
|
|
58
|
+
// one and never reach the clearance path — the Task 16 e2e regression).
|
|
59
|
+
// Absence maps to `none`: no inbox, no grant, keep going.
|
|
60
|
+
if (result.status === "absent")
|
|
61
|
+
return { status: "none" };
|
|
62
|
+
return {
|
|
63
|
+
status: result.status,
|
|
64
|
+
...(result.id === undefined ? {} : { id: result.id }),
|
|
65
|
+
...(result.option === undefined ? {} : { option: result.option }),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Resolve a claimed item after carrying out its answer. */
|
|
69
|
+
export async function resolveGrant(host, id, outcome, note, conversationId) {
|
|
70
|
+
const result = (await host.publishedHooks.call("escalation.resolve", { id, outcome, note }, { conversationId }));
|
|
71
|
+
return { status: result.status };
|
|
72
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RunInfo } from "./context.js";
|
|
2
|
+
/** The default card-check evidence budget (one DecisionRequest's worth). */
|
|
3
|
+
export declare const EVIDENCE_BUDGET_BYTES = 16384;
|
|
4
|
+
/**
|
|
5
|
+
* This run's tool-result texts mentioning any target, redacted, newest
|
|
6
|
+
* first, trimmed to `budgetBytes` (an item that overflows the remaining
|
|
7
|
+
* budget is dropped whole — no partial lines).
|
|
8
|
+
*/
|
|
9
|
+
export declare function evidenceFor(run: RunInfo, targets: readonly string[], budgetBytes: number): string[];
|
package/dist/evidence.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 14 evidence collection: the redacted tool-result texts of THIS
|
|
3
|
+
* run that mention a target, newest first, within a byte budget.
|
|
4
|
+
*/
|
|
5
|
+
import { redact } from "pi-warden";
|
|
6
|
+
/** The default card-check evidence budget (one DecisionRequest's worth). */
|
|
7
|
+
export const EVIDENCE_BUDGET_BYTES = 16_384;
|
|
8
|
+
/** The text blocks of one entry's toolResult messages, joined. */
|
|
9
|
+
function toolResultText(entry) {
|
|
10
|
+
const parts = [];
|
|
11
|
+
for (const message of entry.model ?? []) {
|
|
12
|
+
if (message.role !== "toolResult")
|
|
13
|
+
continue;
|
|
14
|
+
const content = message.content;
|
|
15
|
+
if (typeof content === "string") {
|
|
16
|
+
parts.push(content);
|
|
17
|
+
}
|
|
18
|
+
else if (Array.isArray(content)) {
|
|
19
|
+
for (const block of content) {
|
|
20
|
+
if (typeof block === "object" &&
|
|
21
|
+
block !== null &&
|
|
22
|
+
block.type === "text" &&
|
|
23
|
+
typeof block.text === "string") {
|
|
24
|
+
parts.push(block.text);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return parts.join("\n");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* This run's tool-result texts mentioning any target, redacted, newest
|
|
33
|
+
* first, trimmed to `budgetBytes` (an item that overflows the remaining
|
|
34
|
+
* budget is dropped whole — no partial lines).
|
|
35
|
+
*/
|
|
36
|
+
export function evidenceFor(run, targets, budgetBytes) {
|
|
37
|
+
const out = [];
|
|
38
|
+
let remaining = budgetBytes;
|
|
39
|
+
// run.entries are oldest first; evidence is newest first.
|
|
40
|
+
for (let i = run.entries.length - 1; i >= 0; i -= 1) {
|
|
41
|
+
const text = toolResultText(run.entries[i]);
|
|
42
|
+
if (text === "")
|
|
43
|
+
continue;
|
|
44
|
+
if (!targets.some((target) => text.includes(target)))
|
|
45
|
+
continue;
|
|
46
|
+
const redacted = redact(text);
|
|
47
|
+
const bytes = Buffer.byteLength(redacted, "utf8");
|
|
48
|
+
if (bytes > remaining)
|
|
49
|
+
continue;
|
|
50
|
+
remaining -= bytes;
|
|
51
|
+
out.push(redacted);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
package/dist/glob.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anchored glob matching for warden rules. Globs support `*` ONLY: any
|
|
3
|
+
* run of characters except `/`. Every other character is literal —
|
|
4
|
+
* including `?` and `**` (two adjacent `*` wildcards, no cross-segment
|
|
5
|
+
* meaning). The match is ANCHORED at both ends: `torrents_*` does not
|
|
6
|
+
* match `x/torrents_list`, and `rm*` does not match `xrm`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function globToRegExp(pattern: string): RegExp;
|
|
9
|
+
/** First pattern that matches `value`, or `undefined`. */
|
|
10
|
+
export declare function matchGlob(value: string, patterns: readonly string[]): string | undefined;
|
package/dist/glob.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anchored glob matching for warden rules. Globs support `*` ONLY: any
|
|
3
|
+
* run of characters except `/`. Every other character is literal —
|
|
4
|
+
* including `?` and `**` (two adjacent `*` wildcards, no cross-segment
|
|
5
|
+
* meaning). The match is ANCHORED at both ends: `torrents_*` does not
|
|
6
|
+
* match `x/torrents_list`, and `rm*` does not match `xrm`.
|
|
7
|
+
*/
|
|
8
|
+
export function globToRegExp(pattern) {
|
|
9
|
+
let out = "";
|
|
10
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
11
|
+
if (pattern[index] === "*") {
|
|
12
|
+
out += "[^/]*";
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
out += pattern[index].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return new RegExp(`^${out}$`);
|
|
19
|
+
}
|
|
20
|
+
/** First pattern that matches `value`, or `undefined`. */
|
|
21
|
+
export function matchGlob(value, patterns) {
|
|
22
|
+
return patterns.find((pattern) => globToRegExp(pattern).test(value));
|
|
23
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { SigrunExtensionResult, SigrunHost } from "@sigrun/sigrun/extension";
|
|
2
|
+
/**
|
|
3
|
+
* The warden factory (arity one for the loader; test deps come second).
|
|
4
|
+
* Task 11 wires the ToolTask hook's beforeTool/afterTool for the
|
|
5
|
+
* NO-decision-model path: live conversations hold a call on an
|
|
6
|
+
* interaction question; job runs block + escalate (one-shot grants).
|
|
7
|
+
* The judge (Task 12) drives live calls; the clearance + card-check
|
|
8
|
+
* path (Task 14) drives cleared job calls.
|
|
9
|
+
*/
|
|
10
|
+
export default function createWarden(host: SigrunHost, deps?: Record<string, never>): SigrunExtensionResult;
|
|
11
|
+
export { parseSettings, defaultSettings, resetWarnedKeys } from "./settings.js";
|
|
12
|
+
export type { WardenSettings } from "./settings.js";
|
|
13
|
+
export { classify, annotationFromHookResult } from "./classify.js";
|
|
14
|
+
export type { AnnotationLookup, Classification, ClassifyDeps } from "./classify.js";
|
|
15
|
+
export { HoldLog } from "./log.js";
|
|
16
|
+
export type { HoldRecord } from "./log.js";
|
|
17
|
+
export { globToRegExp, matchGlob } from "./glob.js";
|
|
18
|
+
export { presenceOf, runInfo } from "./context.js";
|
|
19
|
+
export type { Presence, RunInfo } from "./context.js";
|
|
20
|
+
export { grantKey, StateStore } from "./state.js";
|
|
21
|
+
export type { ConversationState } from "./state.js";
|
|
22
|
+
export { confirmBody, holdForPerson, WARDEN_CONFIRM_KIND } from "./interaction.js";
|
|
23
|
+
export { escalationBody, raiseEscalation, resolveGrant, takeGrant, } from "./escalate.js";
|
|
24
|
+
export { resetPruneGuardForTests, resolveReservedGrant, routeOffline, startPruneOnce, } from "./route.js";
|
|
25
|
+
export type { RouteInput } from "./route.js";
|
|
26
|
+
export { afterToolGuard, resetAttemptWindowsForTests } from "./after.js";
|
|
27
|
+
export type { AfterContext } from "./after.js";
|
|
28
|
+
export { Cooldown, decisionsJudge } from "./judge.js";
|
|
29
|
+
export { clearAskQuestion } from "./questions.js";
|
|
30
|
+
export { evaluateLive, judgeBashUnknown } from "./live.js";
|
|
31
|
+
export type { LiveInput, LiveOutcome } from "./live.js";
|
|
32
|
+
export { skillTagsInText, skillsInRun } from "./skills.js";
|
|
33
|
+
export type { SkillSeen, SkillTag } from "./skills.js";
|
|
34
|
+
export { buildCard, CARD_MAX_BYTES, CARD_SYSTEM_PROMPT, CARD_TIMEOUT_MS, CardStore, resetInflightForTests, } from "./cards.js";
|
|
35
|
+
export type { Card } from "./cards.js";
|
|
36
|
+
export { createWardenCommands } from "./commands.js";
|
|
37
|
+
export { targetsOf } from "./targets.js";
|
|
38
|
+
export { EVIDENCE_BUDGET_BYTES, evidenceFor } from "./evidence.js";
|
|
39
|
+
export { cardCheck } from "./card-check.js";
|
|
40
|
+
export type { CardCheckInput, CardCheckResult } from "./card-check.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { defineExtension, hook, ToolTask } from "@earendil-works/pi-durable";
|
|
3
|
+
import { afterToolGuard } from "./after.js";
|
|
4
|
+
import { annotationFromHookResult, classify } from "./classify.js";
|
|
5
|
+
import { buildCard } from "./cards.js";
|
|
6
|
+
import { createWardenCommands } from "./commands.js";
|
|
7
|
+
import { presenceOf, runInfo } from "./context.js";
|
|
8
|
+
import { Cooldown, decisionsJudge } from "./judge.js";
|
|
9
|
+
import { HoldLog } from "./log.js";
|
|
10
|
+
import { resolveReservedGrant, routeOffline, startPruneOnce, } from "./route.js";
|
|
11
|
+
import { parseSettings } from "./settings.js";
|
|
12
|
+
import { StateStore } from "./state.js";
|
|
13
|
+
/**
|
|
14
|
+
* The warden factory (arity one for the loader; test deps come second).
|
|
15
|
+
* Task 11 wires the ToolTask hook's beforeTool/afterTool for the
|
|
16
|
+
* NO-decision-model path: live conversations hold a call on an
|
|
17
|
+
* interaction question; job runs block + escalate (one-shot grants).
|
|
18
|
+
* The judge (Task 12) drives live calls; the clearance + card-check
|
|
19
|
+
* path (Task 14) drives cleared job calls.
|
|
20
|
+
*/
|
|
21
|
+
export default function createWarden(host, deps = {}) {
|
|
22
|
+
void deps;
|
|
23
|
+
const log = new HoldLog(`${host.stores.paths.state}/holds.jsonl`, () => new Date());
|
|
24
|
+
const store = new StateStore(host.stores.paths.state);
|
|
25
|
+
// Instance-wide judge cooldown (in memory — a restart re-trusts the judge).
|
|
26
|
+
const cooldown = new Cooldown();
|
|
27
|
+
const beforeTool = async (call, api, context) => {
|
|
28
|
+
// The hook's contract: catch EVERYTHING and block — never let a
|
|
29
|
+
// call through on an exception.
|
|
30
|
+
try {
|
|
31
|
+
const raw = await host.stores.settings.get();
|
|
32
|
+
const settings = parseSettings(raw, (message) => console.warn(message));
|
|
33
|
+
startPruneOnce(log, settings.retentionDays);
|
|
34
|
+
const annotations = async (tool) => annotationFromHookResult(tool, (await host.publishedHooks.call("mcp.tool-annotations", { tool }, { conversationId: api.conversationId })));
|
|
35
|
+
const classification = await classify({ name: call.name, arguments: call.arguments }, { annotations, settings });
|
|
36
|
+
const presence = await presenceOf(host, api.conversationId);
|
|
37
|
+
const run = await runInfo(host, api.conversationId);
|
|
38
|
+
const judge = decisionsJudge(host, settings, cooldown);
|
|
39
|
+
return await routeOffline({
|
|
40
|
+
host,
|
|
41
|
+
call,
|
|
42
|
+
conversationId: api.conversationId,
|
|
43
|
+
classification,
|
|
44
|
+
presence,
|
|
45
|
+
run,
|
|
46
|
+
settings,
|
|
47
|
+
store,
|
|
48
|
+
log,
|
|
49
|
+
context,
|
|
50
|
+
judge,
|
|
51
|
+
cooldown,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return { block: `warden error: ${errorMessage(error)}` };
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const afterTool = async (call, result, api, _context) => {
|
|
59
|
+
// Task 13: a skill arriving in the run (a `load_skill` result or a
|
|
60
|
+
// `read` of a SKILL.md path) starts a BACKGROUND card build —
|
|
61
|
+
// single-flight per body hash; failures warn and never block.
|
|
62
|
+
const skill = skillFromToolResult(call, result);
|
|
63
|
+
if (skill !== undefined) {
|
|
64
|
+
void (async () => {
|
|
65
|
+
try {
|
|
66
|
+
const raw = await host.stores.settings.get();
|
|
67
|
+
const settings = parseSettings(raw, (message) => console.warn(message));
|
|
68
|
+
const card = await buildCard(host, skill, settings);
|
|
69
|
+
if (card === undefined) {
|
|
70
|
+
console.warn(`warden: card build failed for ${skill.name}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
console.warn("warden: card build failed", error);
|
|
75
|
+
}
|
|
76
|
+
})();
|
|
77
|
+
}
|
|
78
|
+
// Task 11 slice: resolve grants.
|
|
79
|
+
try {
|
|
80
|
+
await resolveReservedGrant({
|
|
81
|
+
host,
|
|
82
|
+
call,
|
|
83
|
+
conversationId: api.conversationId,
|
|
84
|
+
result,
|
|
85
|
+
store,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
console.warn("warden: afterTool grant resolution failed", error);
|
|
90
|
+
}
|
|
91
|
+
// Task 15, LAST in the pipeline (skill collection and grant
|
|
92
|
+
// resolution above): masking → planted-instructions taint → stuck.
|
|
93
|
+
// A thrown guard must never drop the tool's result.
|
|
94
|
+
try {
|
|
95
|
+
const raw = await host.stores.settings.get();
|
|
96
|
+
const settings = parseSettings(raw, (message) => console.warn(message));
|
|
97
|
+
const run = await runInfo(host, api.conversationId);
|
|
98
|
+
return await afterToolGuard(call, result, {
|
|
99
|
+
host,
|
|
100
|
+
conversationId: api.conversationId,
|
|
101
|
+
run,
|
|
102
|
+
settings,
|
|
103
|
+
store,
|
|
104
|
+
log,
|
|
105
|
+
judge: decisionsJudge(host, settings, cooldown),
|
|
106
|
+
context: _context,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
console.warn("warden: afterTool guard failed", error);
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
extension: defineExtension({
|
|
116
|
+
name: "sigrun.warden",
|
|
117
|
+
tools: [],
|
|
118
|
+
hooks: [hook(ToolTask, { beforeTool, afterTool })],
|
|
119
|
+
}),
|
|
120
|
+
interactionKinds: ["warden.confirm"],
|
|
121
|
+
commands: createWardenCommands(host),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The skill a tool result introduces into the run, when it does: a
|
|
126
|
+
* `load_skill` result (name from the call's args) or a `read` of a path
|
|
127
|
+
* ending SKILL.md (name = the parent directory's leaf). The body is the
|
|
128
|
+
* result's text.
|
|
129
|
+
*/
|
|
130
|
+
function skillFromToolResult(call, result) {
|
|
131
|
+
const text = toolResultText(result);
|
|
132
|
+
if (text === "" || result.isError === true)
|
|
133
|
+
return undefined;
|
|
134
|
+
const args = call.arguments;
|
|
135
|
+
if (call.name === "load_skill") {
|
|
136
|
+
const name = typeof args.skill === "string"
|
|
137
|
+
? args.skill
|
|
138
|
+
: typeof args.name === "string"
|
|
139
|
+
? args.name
|
|
140
|
+
: undefined;
|
|
141
|
+
if (name === undefined)
|
|
142
|
+
return undefined;
|
|
143
|
+
return { name, body: text, hash: bodyHash(text) };
|
|
144
|
+
}
|
|
145
|
+
if (call.name === "read" && typeof args.path === "string" && args.path.endsWith("SKILL.md")) {
|
|
146
|
+
const segments = args.path.split("/").filter((part) => part.length > 0);
|
|
147
|
+
const name = segments.length >= 2 ? segments[segments.length - 2] : segments[0];
|
|
148
|
+
return { name, location: args.path, body: text, hash: bodyHash(text) };
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
/** The concatenated text blocks of a tool result's output. */
|
|
153
|
+
function toolResultText(result) {
|
|
154
|
+
const output = result.output;
|
|
155
|
+
if (typeof output === "string")
|
|
156
|
+
return output;
|
|
157
|
+
if (!Array.isArray(output))
|
|
158
|
+
return "";
|
|
159
|
+
const parts = [];
|
|
160
|
+
for (const block of output) {
|
|
161
|
+
if (typeof block === "object" &&
|
|
162
|
+
block !== null &&
|
|
163
|
+
block.type === "text" &&
|
|
164
|
+
typeof block.text === "string") {
|
|
165
|
+
parts.push(block.text);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return parts.join("\n");
|
|
169
|
+
}
|
|
170
|
+
function bodyHash(body) {
|
|
171
|
+
return createHash("sha256").update(body, "utf8").digest("hex");
|
|
172
|
+
}
|
|
173
|
+
function errorMessage(error) {
|
|
174
|
+
return error instanceof Error ? error.message : String(error);
|
|
175
|
+
}
|
|
176
|
+
export { parseSettings, defaultSettings, resetWarnedKeys } from "./settings.js";
|
|
177
|
+
export { classify, annotationFromHookResult } from "./classify.js";
|
|
178
|
+
export { HoldLog } from "./log.js";
|
|
179
|
+
export { globToRegExp, matchGlob } from "./glob.js";
|
|
180
|
+
export { presenceOf, runInfo } from "./context.js";
|
|
181
|
+
export { grantKey, StateStore } from "./state.js";
|
|
182
|
+
export { confirmBody, holdForPerson, WARDEN_CONFIRM_KIND } from "./interaction.js";
|
|
183
|
+
export { escalationBody, raiseEscalation, resolveGrant, takeGrant, } from "./escalate.js";
|
|
184
|
+
export { resetPruneGuardForTests, resolveReservedGrant, routeOffline, startPruneOnce, } from "./route.js";
|
|
185
|
+
export { afterToolGuard, resetAttemptWindowsForTests } from "./after.js";
|
|
186
|
+
export { Cooldown, decisionsJudge } from "./judge.js";
|
|
187
|
+
export { clearAskQuestion } from "./questions.js";
|
|
188
|
+
export { evaluateLive, judgeBashUnknown } from "./live.js";
|
|
189
|
+
export { skillTagsInText, skillsInRun } from "./skills.js";
|
|
190
|
+
export { buildCard, CARD_MAX_BYTES, CARD_SYSTEM_PROMPT, CARD_TIMEOUT_MS, CardStore, resetInflightForTests, } from "./cards.js";
|
|
191
|
+
export { createWardenCommands } from "./commands.js";
|
|
192
|
+
export { targetsOf } from "./targets.js";
|
|
193
|
+
export { EVIDENCE_BUDGET_BYTES, evidenceFor } from "./evidence.js";
|
|
194
|
+
export { cardCheck } from "./card-check.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Context } from "@earendil-works/chord";
|
|
2
|
+
import type { ToolHookCall } from "@earendil-works/pi-durable";
|
|
3
|
+
import type { Tree } from "@sigrun/sigrun/blocks";
|
|
4
|
+
import type { SigrunHost } from "@sigrun/sigrun/extension";
|
|
5
|
+
import type { ConversationId } from "@earendil-works/pi-durable";
|
|
6
|
+
/** The interaction kind the warden declares (loader-validated). */
|
|
7
|
+
export declare const WARDEN_CONFIRM_KIND = "warden.confirm";
|
|
8
|
+
/**
|
|
9
|
+
* The allow/deny gate body: markdown context naming the tool and its
|
|
10
|
+
* redacted arguments plus the reasons, then a Decision section with a
|
|
11
|
+
* `choice` (allow/deny) and a `longtext` reason. Mirrors the
|
|
12
|
+
* `gatingConfirmBody` example in core's docs/extending.md.
|
|
13
|
+
*/
|
|
14
|
+
export declare function confirmBody(call: Pick<ToolHookCall, "name" | "arguments">, reasons: readonly string[]): Tree;
|
|
15
|
+
/**
|
|
16
|
+
* Seed the question and long-poll for the answer. The record callId is
|
|
17
|
+
* `"warden-" + fingerprint(16 hex)`; seeding is first-write-wins, so
|
|
18
|
+
* concurrent or repeated holds of identical calls share ONE card. The
|
|
19
|
+
* poll sleeps cancellation-safely so abort/close unwinds the held call.
|
|
20
|
+
* Consumption of an answered allow (exactly one call per reply) is a
|
|
21
|
+
* state concern, handled by the caller (route.ts).
|
|
22
|
+
*
|
|
23
|
+
* @returns the decision and the person's reason text ("" when absent).
|
|
24
|
+
*/
|
|
25
|
+
export declare function holdForPerson(host: SigrunHost, call: Pick<ToolHookCall, "name" | "arguments">, conversationId: ConversationId, fingerprint: string, reasons: readonly string[], context: Context): Promise<{
|
|
26
|
+
answer: "allow" | "deny";
|
|
27
|
+
reason: string;
|
|
28
|
+
}>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live-presence hold: an interaction record (kind `warden.confirm`)
|
|
3
|
+
* seeded idempotently by CALL FINGERPRINT, so a crash re-entry or a
|
|
4
|
+
* retry with a new tool-call id waits on the same question — never a
|
|
5
|
+
* second card.
|
|
6
|
+
*/
|
|
7
|
+
import { awaitWithContext } from "@earendil-works/chord/context";
|
|
8
|
+
import { redact } from "pi-warden";
|
|
9
|
+
import { choice, form, longtext, markdown, section, tree, } from "@sigrun/sigrun/blocks";
|
|
10
|
+
/** The interaction kind the warden declares (loader-validated). */
|
|
11
|
+
export const WARDEN_CONFIRM_KIND = "warden.confirm";
|
|
12
|
+
/** Poll cadence while a person decides (the fixture-gate shape). */
|
|
13
|
+
const POLL_MS = 500;
|
|
14
|
+
/**
|
|
15
|
+
* The allow/deny gate body: markdown context naming the tool and its
|
|
16
|
+
* redacted arguments plus the reasons, then a Decision section with a
|
|
17
|
+
* `choice` (allow/deny) and a `longtext` reason. Mirrors the
|
|
18
|
+
* `gatingConfirmBody` example in core's docs/extending.md.
|
|
19
|
+
*/
|
|
20
|
+
export function confirmBody(call, reasons) {
|
|
21
|
+
const redactedArgs = redact(JSON.stringify(call.arguments));
|
|
22
|
+
const reasonLines = reasons.map((reason) => `- ${reason}`).join("\n");
|
|
23
|
+
return tree(form({
|
|
24
|
+
id: "warden-confirm",
|
|
25
|
+
bind: "/values",
|
|
26
|
+
mode: "submit",
|
|
27
|
+
submitLabel: "Send",
|
|
28
|
+
children: [
|
|
29
|
+
section({
|
|
30
|
+
title: "Confirm",
|
|
31
|
+
children: [
|
|
32
|
+
markdown({
|
|
33
|
+
text: `The warden held \`${call.name}\` with arguments:\n\n\`\`\`json\n${redactedArgs}\n\`\`\`\n\n${reasonLines}`,
|
|
34
|
+
}),
|
|
35
|
+
],
|
|
36
|
+
}),
|
|
37
|
+
section({
|
|
38
|
+
title: "Decision",
|
|
39
|
+
children: [
|
|
40
|
+
choice({
|
|
41
|
+
name: "decision",
|
|
42
|
+
label: "Allow this call?",
|
|
43
|
+
options: [
|
|
44
|
+
{ id: "allow", label: "Allow", tone: "good" },
|
|
45
|
+
{ id: "deny", label: "Deny", tone: "bad" },
|
|
46
|
+
],
|
|
47
|
+
}),
|
|
48
|
+
longtext({
|
|
49
|
+
name: "reason",
|
|
50
|
+
label: "Why (optional)",
|
|
51
|
+
placeholder: "Recorded with the hold.",
|
|
52
|
+
}),
|
|
53
|
+
],
|
|
54
|
+
}),
|
|
55
|
+
],
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Seed the question and long-poll for the answer. The record callId is
|
|
60
|
+
* `"warden-" + fingerprint(16 hex)`; seeding is first-write-wins, so
|
|
61
|
+
* concurrent or repeated holds of identical calls share ONE card. The
|
|
62
|
+
* poll sleeps cancellation-safely so abort/close unwinds the held call.
|
|
63
|
+
* Consumption of an answered allow (exactly one call per reply) is a
|
|
64
|
+
* state concern, handled by the caller (route.ts).
|
|
65
|
+
*
|
|
66
|
+
* @returns the decision and the person's reason text ("" when absent).
|
|
67
|
+
*/
|
|
68
|
+
export async function holdForPerson(host, call, conversationId, fingerprint, reasons, context) {
|
|
69
|
+
const callId = `warden-${fingerprint}`;
|
|
70
|
+
await host.seedInteraction(conversationId, {
|
|
71
|
+
kind: WARDEN_CONFIRM_KIND,
|
|
72
|
+
callId,
|
|
73
|
+
status: "pending",
|
|
74
|
+
createdAt: Date.now(),
|
|
75
|
+
body: confirmBody(call, reasons),
|
|
76
|
+
details: {
|
|
77
|
+
toolName: call.name,
|
|
78
|
+
arguments: JSON.parse(redact(JSON.stringify(call.arguments))),
|
|
79
|
+
},
|
|
80
|
+
}, context);
|
|
81
|
+
for (;;) {
|
|
82
|
+
const record = await host.snapshotInteraction(conversationId, callId, context);
|
|
83
|
+
if (record?.status === "answered") {
|
|
84
|
+
const decision = record.answer?.values.decision;
|
|
85
|
+
const reason = record.answer?.values.reason;
|
|
86
|
+
return {
|
|
87
|
+
answer: decision === "allow" ? "allow" : "deny",
|
|
88
|
+
reason: typeof reason === "string" ? reason : "",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
await awaitWithContext(new Promise((resolve) => setTimeout(resolve, POLL_MS)), context);
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/judge.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decisions judge (warden spec §3.2): a pi-warden `Judge` over
|
|
3
|
+
* core's `host.decisions.evaluate`. Core NEVER throws (failures arrive
|
|
4
|
+
* as the `ok:false` half of DecisionResult); pi-warden's ask paths
|
|
5
|
+
* expect the pi-typesafe contract — the raw SystemOneResult on success
|
|
6
|
+
* and a THROW on failure — so this adapter converts the failure half
|
|
7
|
+
* into `Error("<code>: <error>")`.
|
|
8
|
+
*
|
|
9
|
+
* The `Cooldown` pauses a failing judge instance-wide (in memory): 3
|
|
10
|
+
* consecutive failures pause judgments for 5 minutes; a success resets
|
|
11
|
+
* the streak.
|
|
12
|
+
*/
|
|
13
|
+
import type { Judge } from "pi-typesafe";
|
|
14
|
+
import type { SigrunHost } from "@sigrun/sigrun/extension";
|
|
15
|
+
import type { WardenSettings } from "./settings.js";
|
|
16
|
+
/**
|
|
17
|
+
* Session-scoped pause on a failing judge: three consecutive failures
|
|
18
|
+
* pause judgment calls for five minutes. Nothing is persisted; a new
|
|
19
|
+
* process starts with the judge trusted again.
|
|
20
|
+
*/
|
|
21
|
+
export declare class Cooldown {
|
|
22
|
+
#private;
|
|
23
|
+
/** The default reads Date.now on each call, so a test can mock the clock. */
|
|
24
|
+
constructor(now?: () => number);
|
|
25
|
+
/** Record one judge failure; the third consecutive one opens the window. */
|
|
26
|
+
failed(): void;
|
|
27
|
+
/** A successful judgment clears the streak and the window. */
|
|
28
|
+
succeeded(): void;
|
|
29
|
+
/** True while judgments are paused. */
|
|
30
|
+
get active(): boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The judge for one settings snapshot: `undefined` when the judge is
|
|
34
|
+
* disabled or the cooldown is active. The evaluate implementation:
|
|
35
|
+
*
|
|
36
|
+
* - calls `host.decisions.evaluate(request, {model: settings.decisionModel, signal})`;
|
|
37
|
+
* - on `ok:true` returns the RAW Evaluation shape `{answers, usage, model, elapsedMs}`;
|
|
38
|
+
* - on `ok:false` throws `Error("<code>: <error>")` so pi-warden's and
|
|
39
|
+
* our ask paths see a failure;
|
|
40
|
+
* - every outcome updates the cooldown (thrown transport errors count
|
|
41
|
+
* as failures too).
|
|
42
|
+
*/
|
|
43
|
+
export declare function decisionsJudge(host: SigrunHost, settings: WardenSettings, cooldown: Cooldown): Judge | undefined;
|
package/dist/judge.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const FAILURE_LIMIT = 3;
|
|
2
|
+
const COOLDOWN_MS = 5 * 60 * 1000;
|
|
3
|
+
/**
|
|
4
|
+
* Session-scoped pause on a failing judge: three consecutive failures
|
|
5
|
+
* pause judgment calls for five minutes. Nothing is persisted; a new
|
|
6
|
+
* process starts with the judge trusted again.
|
|
7
|
+
*/
|
|
8
|
+
export class Cooldown {
|
|
9
|
+
#now;
|
|
10
|
+
#failures = 0;
|
|
11
|
+
#until = 0;
|
|
12
|
+
/** The default reads Date.now on each call, so a test can mock the clock. */
|
|
13
|
+
constructor(now = () => Date.now()) {
|
|
14
|
+
this.#now = now;
|
|
15
|
+
}
|
|
16
|
+
/** Record one judge failure; the third consecutive one opens the window. */
|
|
17
|
+
failed() {
|
|
18
|
+
this.#failures += 1;
|
|
19
|
+
if (this.#failures >= FAILURE_LIMIT) {
|
|
20
|
+
this.#until = this.#now() + COOLDOWN_MS;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** A successful judgment clears the streak and the window. */
|
|
24
|
+
succeeded() {
|
|
25
|
+
this.#failures = 0;
|
|
26
|
+
this.#until = 0;
|
|
27
|
+
}
|
|
28
|
+
/** True while judgments are paused. */
|
|
29
|
+
get active() {
|
|
30
|
+
return this.#until > this.#now();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The judge for one settings snapshot: `undefined` when the judge is
|
|
35
|
+
* disabled or the cooldown is active. The evaluate implementation:
|
|
36
|
+
*
|
|
37
|
+
* - calls `host.decisions.evaluate(request, {model: settings.decisionModel, signal})`;
|
|
38
|
+
* - on `ok:true` returns the RAW Evaluation shape `{answers, usage, model, elapsedMs}`;
|
|
39
|
+
* - on `ok:false` throws `Error("<code>: <error>")` so pi-warden's and
|
|
40
|
+
* our ask paths see a failure;
|
|
41
|
+
* - every outcome updates the cooldown (thrown transport errors count
|
|
42
|
+
* as failures too).
|
|
43
|
+
*/
|
|
44
|
+
export function decisionsJudge(host, settings, cooldown) {
|
|
45
|
+
if (!settings.judge.enabled)
|
|
46
|
+
return undefined;
|
|
47
|
+
if (cooldown.active)
|
|
48
|
+
return undefined;
|
|
49
|
+
return {
|
|
50
|
+
evaluate: async (request, options) => {
|
|
51
|
+
let result;
|
|
52
|
+
try {
|
|
53
|
+
result = await host.decisions.evaluate(request, {
|
|
54
|
+
model: settings.decisionModel,
|
|
55
|
+
signal: options?.signal,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
cooldown.failed();
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
if (result.ok) {
|
|
63
|
+
cooldown.succeeded();
|
|
64
|
+
return {
|
|
65
|
+
answers: result.answers,
|
|
66
|
+
usage: result.usage,
|
|
67
|
+
model: result.model,
|
|
68
|
+
elapsedMs: result.elapsedMs,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
cooldown.failed();
|
|
72
|
+
throw new Error(`${result.code}: ${result.error}`);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|