@ian-pascoe/pi-guardian 0.0.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/LICENSE +21 -0
- package/README.md +207 -0
- package/package.json +59 -0
- package/skills/pi-guardian/SKILL.md +24 -0
- package/src/guardian-assessment.ts +118 -0
- package/src/guardian-audit.ts +169 -0
- package/src/guardian-calibration.ts +32 -0
- package/src/guardian-command.ts +104 -0
- package/src/guardian-dialog.ts +83 -0
- package/src/guardian-evidence.ts +370 -0
- package/src/guardian-extension.ts +398 -0
- package/src/guardian-gate.ts +608 -0
- package/src/guardian-menu.ts +556 -0
- package/src/guardian-pi-resources.ts +46 -0
- package/src/guardian-prompt.ts +104 -0
- package/src/guardian-rendering.ts +240 -0
- package/src/guardian-review.ts +215 -0
- package/src/guardian-root-registry.ts +77 -0
- package/src/guardian-settings.ts +232 -0
- package/src/index.ts +1 -0
- package/src/safe-command.ts +147 -0
- package/src/sensitive-paths.ts +205 -0
- package/src/tool-policy.ts +94 -0
- package/src/troubleshooting-skill.ts +9 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
+
import {
|
|
3
|
+
guardianOptionKey,
|
|
4
|
+
guardianOptionKeys,
|
|
5
|
+
parseGuardianOptions,
|
|
6
|
+
type GuardianOptions,
|
|
7
|
+
type GuardianSettingScope,
|
|
8
|
+
type ToolPolicy,
|
|
9
|
+
} from "./guardian-settings.js";
|
|
10
|
+
|
|
11
|
+
const usage =
|
|
12
|
+
"Usage: /guardian [on|off|status|policy|inherit [key]|set <key> <JSON>|tool <name> <allow|review|deny|default|inherit>] [--global|--project]; /guardian alone opens settings";
|
|
13
|
+
|
|
14
|
+
/** A tool entry change: a Tool Policy, `default` (null: built-in default), or `inherit`. */
|
|
15
|
+
export type ToolEntryValue = ToolPolicy | "default" | "inherit";
|
|
16
|
+
const toolEntryValues: readonly ToolEntryValue[] = [
|
|
17
|
+
"allow",
|
|
18
|
+
"review",
|
|
19
|
+
"deny",
|
|
20
|
+
"default",
|
|
21
|
+
"inherit",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function isToolEntryValue(value: string): value is ToolEntryValue {
|
|
25
|
+
return toolEntryValues.some((candidate) => candidate === value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One parsed `/guardian` command. */
|
|
29
|
+
export type GuardianCommand =
|
|
30
|
+
| { action: "menu" }
|
|
31
|
+
| { action: "status" }
|
|
32
|
+
| { action: "policy"; scope: GuardianSettingScope }
|
|
33
|
+
| {
|
|
34
|
+
action: "set";
|
|
35
|
+
scope: GuardianSettingScope;
|
|
36
|
+
key: keyof GuardianOptions;
|
|
37
|
+
patch: GuardianOptions;
|
|
38
|
+
}
|
|
39
|
+
| { action: "inherit"; scope: GuardianSettingScope; key: keyof GuardianOptions }
|
|
40
|
+
| { action: "tool"; scope: GuardianSettingScope; name: string; value: ToolEntryValue };
|
|
41
|
+
|
|
42
|
+
/** Parse one configuration change; validated patches cannot invent option keys. */
|
|
43
|
+
export function parseGuardianCommand(input: string): GuardianCommand {
|
|
44
|
+
const flag = /\s+--(global|project)$/.exec(input.trim());
|
|
45
|
+
const scope: GuardianSettingScope =
|
|
46
|
+
flag?.[1] === "global" ? "global" : flag?.[1] === "project" ? "project" : "session";
|
|
47
|
+
const text = flag ? input.trim().slice(0, flag.index) : input.trim();
|
|
48
|
+
if (text === "" || text === "status") {
|
|
49
|
+
if (flag) throw new Error(usage);
|
|
50
|
+
return text === "" ? { action: "menu" } : { action: "status" };
|
|
51
|
+
}
|
|
52
|
+
if (text === "policy") return { action: "policy", scope };
|
|
53
|
+
if (text === "on" || text === "off")
|
|
54
|
+
return { action: "set", scope, key: "enabled", patch: { enabled: text === "on" } };
|
|
55
|
+
const inherit = /^inherit(?:\s+(\S+))?$/.exec(text);
|
|
56
|
+
if (inherit) return { action: "inherit", scope, key: guardianOptionKey(inherit[1] ?? "enabled") };
|
|
57
|
+
const tool = /^tool\s+(\S+)\s+(\S+)$/.exec(text);
|
|
58
|
+
if (tool?.[1] && tool[2]) {
|
|
59
|
+
if (!isToolEntryValue(tool[2])) throw new Error(usage);
|
|
60
|
+
return { action: "tool", scope, name: tool[1], value: tool[2] };
|
|
61
|
+
}
|
|
62
|
+
const set = /^set\s+(\S+)\s+([\s\S]+)$/.exec(text);
|
|
63
|
+
if (!set?.[1] || !set[2]) throw new Error(usage);
|
|
64
|
+
const key = guardianOptionKey(set[1]);
|
|
65
|
+
const patch = parseGuardianOptions({ [key]: JSON.parse(set[2]) }, scope);
|
|
66
|
+
return { action: "set", scope, key, patch };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Native command completion values replace the entire argument prefix. */
|
|
70
|
+
export function completeGuardianCommandArguments(prefix: string): AutocompleteItem[] {
|
|
71
|
+
const candidates = ["on", "off", "status", "policy", "inherit", "set", "tool"];
|
|
72
|
+
const keyPrefix = /^((?:set|inherit)\s+)\S*$/.exec(prefix)?.[1];
|
|
73
|
+
if (keyPrefix) candidates.push(...guardianOptionKeys.map((key) => `${keyPrefix}${key}`));
|
|
74
|
+
const valuePrefix = /^(tool\s+\S+\s+)\S*$/.exec(prefix)?.[1];
|
|
75
|
+
if (valuePrefix) candidates.push(...toolEntryValues.map((value) => `${valuePrefix}${value}`));
|
|
76
|
+
const scopePrefix = /^(.*\s+)(--\S*)?$/s.exec(prefix)?.[1];
|
|
77
|
+
if (scopePrefix) {
|
|
78
|
+
try {
|
|
79
|
+
const command = parseGuardianCommand(scopePrefix);
|
|
80
|
+
if ("scope" in command && command.scope === "session")
|
|
81
|
+
candidates.push(`${scopePrefix}--global`, `${scopePrefix}--project`);
|
|
82
|
+
} catch {
|
|
83
|
+
// Incomplete commands and JSON values cannot accept a scope yet.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return candidates
|
|
87
|
+
.filter((value) => value.startsWith(prefix))
|
|
88
|
+
.map((value) => ({ value, label: value }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A scope's `tools` option after changing one entry; `undefined` when the scope no longer
|
|
93
|
+
* authors any entry and should inherit the whole option.
|
|
94
|
+
*/
|
|
95
|
+
export function updatedToolEntries(
|
|
96
|
+
authored: GuardianOptions["tools"],
|
|
97
|
+
name: string,
|
|
98
|
+
value: ToolEntryValue,
|
|
99
|
+
): GuardianOptions["tools"] {
|
|
100
|
+
const next = { ...authored };
|
|
101
|
+
if (value === "inherit") delete next[name];
|
|
102
|
+
else next[name] = value === "default" ? null : value;
|
|
103
|
+
return Object.keys(next).length ? next : undefined;
|
|
104
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ExtensionContext, ExtensionUIDialogOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { IssuingCall, ToolInput } from "./guardian-evidence.js";
|
|
3
|
+
|
|
4
|
+
/** A call a Rejection or Review Failure would block, offered to the user as a User Override. */
|
|
5
|
+
export interface OverrideRequest {
|
|
6
|
+
/** Why Guardian would block the call. */
|
|
7
|
+
headline: string;
|
|
8
|
+
toolName: string;
|
|
9
|
+
input: ToolInput;
|
|
10
|
+
parent: IssuingCall | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Calls whose serialized arguments fit this many characters are shown in full in the dialog. */
|
|
14
|
+
const previewLimit = 2_000;
|
|
15
|
+
const choices = { block: "Block", allow: "Allow once", view: "View full call" } as const;
|
|
16
|
+
|
|
17
|
+
/** The whole call, for the read-only viewer. */
|
|
18
|
+
function fullCall(request: OverrideRequest): string {
|
|
19
|
+
const lines = [`Tool: ${request.toolName}`, "Arguments:", JSON.stringify(request.input, null, 2)];
|
|
20
|
+
if (request.parent)
|
|
21
|
+
lines.push(
|
|
22
|
+
`Issued by tool call: ${request.parent.toolName}`,
|
|
23
|
+
"Issuing call arguments:",
|
|
24
|
+
JSON.stringify(request.parent.input, null, 2),
|
|
25
|
+
);
|
|
26
|
+
return lines.join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Ask whether to allow one call. A call short enough is shown in full; a longer one offers
|
|
31
|
+
* Allow once only after the user opened the full call, never on a cut-down preview.
|
|
32
|
+
*/
|
|
33
|
+
async function ask(ctx: ExtensionContext, request: OverrideRequest): Promise<boolean> {
|
|
34
|
+
if (!ctx.hasUI || ctx.signal?.aborted) return false;
|
|
35
|
+
const args = JSON.stringify(request.input);
|
|
36
|
+
const parentArgs = request.parent ? JSON.stringify(request.parent.input) : "";
|
|
37
|
+
const short = args.length + parentArgs.length <= previewLimit;
|
|
38
|
+
const title = short
|
|
39
|
+
? [
|
|
40
|
+
request.headline,
|
|
41
|
+
`Arguments: ${args}`,
|
|
42
|
+
...(request.parent
|
|
43
|
+
? [`Issued by ${request.parent.toolName} with arguments: ${parentArgs}`]
|
|
44
|
+
: []),
|
|
45
|
+
].join("\n")
|
|
46
|
+
: `${request.headline}\nThe call is ${args.length + parentArgs.length} characters long, too long to show here; view the full call before allowing it.`;
|
|
47
|
+
const options: ExtensionUIDialogOptions = {};
|
|
48
|
+
if (ctx.signal) options.signal = ctx.signal;
|
|
49
|
+
let viewed = short;
|
|
50
|
+
try {
|
|
51
|
+
for (;;) {
|
|
52
|
+
const offered: string[] = short
|
|
53
|
+
? [choices.block, choices.allow]
|
|
54
|
+
: [choices.block, choices.view, ...(viewed ? [choices.allow] : [])];
|
|
55
|
+
const choice = await ctx.ui.select(title, offered, options);
|
|
56
|
+
if (ctx.signal?.aborted) return false;
|
|
57
|
+
if (choice !== choices.view) return choice === choices.allow;
|
|
58
|
+
await ctx.ui.editor(
|
|
59
|
+
`Full ${request.toolName} call under review (read-only: edits are ignored)`,
|
|
60
|
+
fullCall(request),
|
|
61
|
+
);
|
|
62
|
+
viewed = true;
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* User Override dialogs, one at a time: nested calls (such as a codemode script's `Promise.all`)
|
|
71
|
+
* run Guardian's handlers concurrently, so later dialogs wait for earlier ones.
|
|
72
|
+
*/
|
|
73
|
+
export function overrideDialogs(): (
|
|
74
|
+
ctx: ExtensionContext,
|
|
75
|
+
request: OverrideRequest,
|
|
76
|
+
) => Promise<boolean> {
|
|
77
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
78
|
+
return (ctx, request) => {
|
|
79
|
+
const answer = queue.then(() => ask(ctx, request));
|
|
80
|
+
queue = answer.catch(() => undefined);
|
|
81
|
+
return answer;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
3
|
+
import {
|
|
4
|
+
convertToLlm,
|
|
5
|
+
parseSkillBlock,
|
|
6
|
+
type AgentSession,
|
|
7
|
+
type CustomToolCallEvent,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import {
|
|
10
|
+
messageOrigins,
|
|
11
|
+
projectEvidenceItem,
|
|
12
|
+
shortenEvidence,
|
|
13
|
+
type EvidenceBlock,
|
|
14
|
+
type EvidenceItem,
|
|
15
|
+
type EvidenceMessage,
|
|
16
|
+
} from "@ian-pascoe/pi-utils/evidence";
|
|
17
|
+
|
|
18
|
+
/** Marks text shortened to fit the Guardian's evidence budget. */
|
|
19
|
+
const omissionMarker = (omitted: number) =>
|
|
20
|
+
`[… ${omitted} characters omitted from Guardian evidence]`;
|
|
21
|
+
/** Per-string cap on untrusted evidence, about 2000 tokens by Pi's chars/4 estimate. */
|
|
22
|
+
const untrustedCharacterLimit = 8_000;
|
|
23
|
+
|
|
24
|
+
/** A User Override recorded in the session, replayed as Trusted Evidence. */
|
|
25
|
+
export interface RecordedOverride {
|
|
26
|
+
text: string;
|
|
27
|
+
/** Milliseconds since the epoch, to place it among the conversation's messages. */
|
|
28
|
+
timestamp: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A message the root session's user typed, as Trusted Evidence for a Child Agent's or Advisor's
|
|
33
|
+
* calls: their own task comes from another agent, but the root user's requests are the user's.
|
|
34
|
+
*/
|
|
35
|
+
export interface RootUserMessage {
|
|
36
|
+
content: string | EvidenceBlock[];
|
|
37
|
+
/** Milliseconds since the epoch, to place it among the conversation's messages. */
|
|
38
|
+
timestamp: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A context file Pi loaded into the Guarded Agent's system prompt, such as `AGENTS.md`. */
|
|
42
|
+
export interface ContextFile {
|
|
43
|
+
path: string;
|
|
44
|
+
content: string;
|
|
45
|
+
/** Global context files and those of a trusted project; others are untrusted evidence. */
|
|
46
|
+
trusted: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Everything evidence selection reads. */
|
|
50
|
+
export interface EvidenceInput {
|
|
51
|
+
/** The Guarded Agent's session messages, in order, as Pi stores them. */
|
|
52
|
+
sources: AgentSession["messages"];
|
|
53
|
+
/** False in Child Agent and Advisor sessions, whose user messages come from another agent. */
|
|
54
|
+
trustUserMessages: boolean;
|
|
55
|
+
/** Context files from Pi's resource loader, in the order Pi loaded them. */
|
|
56
|
+
contextFiles: readonly ContextFile[];
|
|
57
|
+
/** {@link userMessageKey}s of user messages an extension sent rather than the user typed. */
|
|
58
|
+
extensionMessages?: ReadonlySet<string>;
|
|
59
|
+
overrides: readonly RecordedOverride[];
|
|
60
|
+
/** The root user's typed messages, in a Child Agent or Advisor session whose root is known. */
|
|
61
|
+
rootUserMessages?: readonly RootUserMessage[];
|
|
62
|
+
budgetTokens: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface Entry {
|
|
66
|
+
item: EvidenceItem;
|
|
67
|
+
trusted: boolean;
|
|
68
|
+
origin: string;
|
|
69
|
+
/** The message's timestamp, for entries that come from a session message. */
|
|
70
|
+
timestamp?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Selected evidence, rendered as one text block per entry. */
|
|
74
|
+
export interface SelectedEvidence {
|
|
75
|
+
blocks: string[];
|
|
76
|
+
/** Untrusted entries dropped to fit the budget. */
|
|
77
|
+
omitted: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sha256(text: string): string {
|
|
81
|
+
return createHash("sha256").update(text).digest("hex");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The text a user message carries, for identifying it across Pi's message conversions. */
|
|
85
|
+
function userText(content: Message["content"] | string): string {
|
|
86
|
+
if (!Array.isArray(content)) return content;
|
|
87
|
+
return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Identifies one user message by its timestamp and text, stable across reloads. */
|
|
91
|
+
export function userMessageKey(message: {
|
|
92
|
+
content: Message["content"] | string;
|
|
93
|
+
timestamp: number;
|
|
94
|
+
}): string {
|
|
95
|
+
return `${message.timestamp}:${sha256(userText(message.content))}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function render({ item, trusted, origin }: Entry): string {
|
|
99
|
+
return `Evidence (${trusted ? "TRUSTED" : "UNTRUSTED"}, origin: ${origin}):\n${JSON.stringify(item.message)}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function userItem(content: string | EvidenceBlock[]): EvidenceItem {
|
|
103
|
+
return { message: { role: "user", content }, images: [] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function untrusted(item: EvidenceItem, origin: string): Entry {
|
|
107
|
+
return {
|
|
108
|
+
item: shortenEvidence([item], untrustedCharacterLimit, omissionMarker)[0] ?? item,
|
|
109
|
+
trusted: false,
|
|
110
|
+
origin,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Split a `/skill:` expansion: Pi wraps the Skill's file in a `<skill>` block before the user's
|
|
116
|
+
* own text. The Skill body is file content, so it is untrusted even when the user invoked it.
|
|
117
|
+
*/
|
|
118
|
+
function splitSkill(
|
|
119
|
+
message: EvidenceMessage,
|
|
120
|
+
): { skill: string; rest: EvidenceItem | undefined } | undefined {
|
|
121
|
+
if (message.role !== "user") return undefined;
|
|
122
|
+
const { content } = message;
|
|
123
|
+
const blocks = Array.isArray(content) ? content : undefined;
|
|
124
|
+
const first: EvidenceBlock | undefined = Array.isArray(content)
|
|
125
|
+
? content[0]
|
|
126
|
+
: { type: "text", text: content };
|
|
127
|
+
if (first?.type !== "text") return undefined;
|
|
128
|
+
const parsed = parseSkillBlock(first.text);
|
|
129
|
+
if (!parsed) return undefined;
|
|
130
|
+
const skill = `<skill name="${parsed.name}" location="${parsed.location}">\n${parsed.content}\n</skill>`;
|
|
131
|
+
const others = blocks?.slice(1) ?? [];
|
|
132
|
+
if (!parsed.userMessage && !others.length) return { skill, rest: undefined };
|
|
133
|
+
const text: EvidenceBlock[] = parsed.userMessage
|
|
134
|
+
? [{ type: "text", text: parsed.userMessage }]
|
|
135
|
+
: [];
|
|
136
|
+
return {
|
|
137
|
+
skill,
|
|
138
|
+
rest: userItem(blocks ? [...text, ...others] : (parsed.userMessage ?? "")),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Each conversation message with its origin; User Overrides and the root user's messages are
|
|
144
|
+
* interleaved by time, so entries recorded later land after the messages before them.
|
|
145
|
+
*/
|
|
146
|
+
function entries(input: EvidenceInput): Entry[] {
|
|
147
|
+
const messages: Message[] = convertToLlm(input.sources).filter(
|
|
148
|
+
(message) =>
|
|
149
|
+
message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
|
150
|
+
);
|
|
151
|
+
const origins = messageOrigins(messages, input.sources);
|
|
152
|
+
const result: Entry[] = [];
|
|
153
|
+
const inserts = [
|
|
154
|
+
...input.overrides.map((override) => ({
|
|
155
|
+
timestamp: override.timestamp,
|
|
156
|
+
entry: { item: userItem(override.text), trusted: true, origin: "userOverride" },
|
|
157
|
+
})),
|
|
158
|
+
...(input.rootUserMessages ?? []).map((message) => ({
|
|
159
|
+
timestamp: message.timestamp,
|
|
160
|
+
entry: { item: userItem(message.content), trusted: true, origin: "rootUser" },
|
|
161
|
+
})),
|
|
162
|
+
].toSorted((left, right) => left.timestamp - right.timestamp);
|
|
163
|
+
let next = 0;
|
|
164
|
+
for (const [index, message] of messages.entries()) {
|
|
165
|
+
while (next < inserts.length && (inserts[next]?.timestamp ?? Infinity) < message.timestamp) {
|
|
166
|
+
const insert = inserts[next++];
|
|
167
|
+
if (insert) result.push(insert.entry);
|
|
168
|
+
}
|
|
169
|
+
const projected = projectEvidenceItem(message);
|
|
170
|
+
if (!projected) continue;
|
|
171
|
+
// Guardian sends text only; image attachments stay out of its request and its budget.
|
|
172
|
+
const item: EvidenceItem = { message: projected.message, images: [] };
|
|
173
|
+
let origin = origins[index] ?? "unknown";
|
|
174
|
+
if (
|
|
175
|
+
origin === "user" &&
|
|
176
|
+
message.role === "user" &&
|
|
177
|
+
input.extensionMessages?.has(userMessageKey(message))
|
|
178
|
+
)
|
|
179
|
+
origin = "extension";
|
|
180
|
+
const { timestamp } = message;
|
|
181
|
+
if (!input.trustUserMessages || origin !== "user") {
|
|
182
|
+
result.push({ ...untrusted(item, origin), timestamp });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const skill = splitSkill(item.message);
|
|
186
|
+
if (!skill) {
|
|
187
|
+
result.push({ item, trusted: true, origin, timestamp });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
result.push({ ...untrusted(userItem(skill.skill), "skill"), timestamp });
|
|
191
|
+
if (skill.rest) result.push({ item: skill.rest, trusted: true, origin, timestamp });
|
|
192
|
+
}
|
|
193
|
+
for (const remaining of inserts.slice(next)) result.push(remaining.entry);
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The messages the user typed in a main session, as Trusted Evidence would hold them: without
|
|
199
|
+
* Skill bodies or messages an extension sent. Child Agents and Advisors of this session receive
|
|
200
|
+
* them as the root user's requests.
|
|
201
|
+
*/
|
|
202
|
+
export function typedUserMessages(
|
|
203
|
+
input: Pick<EvidenceInput, "sources" | "extensionMessages">,
|
|
204
|
+
): RootUserMessage[] {
|
|
205
|
+
return entries({
|
|
206
|
+
...input,
|
|
207
|
+
trustUserMessages: true,
|
|
208
|
+
contextFiles: [],
|
|
209
|
+
overrides: [],
|
|
210
|
+
budgetTokens: 0,
|
|
211
|
+
}).flatMap((entry) =>
|
|
212
|
+
entry.trusted &&
|
|
213
|
+
entry.origin === "user" &&
|
|
214
|
+
entry.timestamp !== undefined &&
|
|
215
|
+
entry.item.message.role === "user"
|
|
216
|
+
? [{ content: entry.item.message.content, timestamp: entry.timestamp }]
|
|
217
|
+
: [],
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Context files as evidence: trusted ones can establish User Authorization, others cannot. */
|
|
222
|
+
function contextFileEntries(files: readonly ContextFile[]): Entry[] {
|
|
223
|
+
return files.map((file) => {
|
|
224
|
+
const item = userItem(
|
|
225
|
+
`<project_instructions path="${file.path}">\n${file.content}\n</project_instructions>`,
|
|
226
|
+
);
|
|
227
|
+
return file.trusted
|
|
228
|
+
? { item, trusted: true, origin: "projectInstructions" }
|
|
229
|
+
: untrusted(item, "projectInstructions");
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Per-string caps tried for TRUSTED entries, longest first, when they alone exceed the budget. */
|
|
234
|
+
const trustedCaps = [undefined, 32_000, 8_000, 2_000, 500] as const;
|
|
235
|
+
/** Tokens reserved for the omission note, whatever its count. */
|
|
236
|
+
const noteTokens = 32;
|
|
237
|
+
|
|
238
|
+
function omissionNote(omitted: number): string {
|
|
239
|
+
return `[${omitted} older UNTRUSTED evidence ${omitted === 1 ? "entry was" : "entries were"} omitted to fit the evidence budget]`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Where the evidence window starts, or `undefined` when even an empty window exceeds the budget.
|
|
244
|
+
* TRUSTED entries before the start are kept; the window holds every entry from it. The start
|
|
245
|
+
* only moves forward, and each move drops at least half the budget of UNTRUSTED entries, so it
|
|
246
|
+
* is a pure function of the history that moves at most once per half-budget of growth.
|
|
247
|
+
*/
|
|
248
|
+
function windowStart(
|
|
249
|
+
all: readonly Entry[],
|
|
250
|
+
costs: readonly number[],
|
|
251
|
+
budget: number,
|
|
252
|
+
): number | undefined {
|
|
253
|
+
const total = costs.reduce((sum, cost) => sum + cost, 0);
|
|
254
|
+
let start = 0;
|
|
255
|
+
let before = 0;
|
|
256
|
+
let trustedBefore = 0;
|
|
257
|
+
const cost = () => trustedBefore + (total - before) + (start > 0 ? noteTokens : 0);
|
|
258
|
+
while (cost() > budget) {
|
|
259
|
+
if (start >= all.length) return undefined;
|
|
260
|
+
let dropped = 0;
|
|
261
|
+
do {
|
|
262
|
+
const entryCost = costs[start] ?? 0;
|
|
263
|
+
if (all[start]?.trusted) trustedBefore += entryCost;
|
|
264
|
+
else dropped += entryCost;
|
|
265
|
+
before += entryCost;
|
|
266
|
+
start++;
|
|
267
|
+
} while (start < all.length && dropped < budget / 2);
|
|
268
|
+
}
|
|
269
|
+
return start;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Select evidence within the token budget, prefix-stable so provider prompt caches survive
|
|
274
|
+
* across reviews. Every TRUSTED entry is always kept. The rest is a window of every entry from a
|
|
275
|
+
* start that jumps forward, dropping at least half the budget of the oldest UNTRUSTED entries,
|
|
276
|
+
* only when the evidence overflows; between jumps each review's blocks extend the previous
|
|
277
|
+
* review's. TRUSTED entries are shortened, all with one per-string cap from a fixed ladder, only
|
|
278
|
+
* when they alone exceed the budget, so they do not reshape from review to review.
|
|
279
|
+
*/
|
|
280
|
+
export function selectEvidence(input: EvidenceInput): SelectedEvidence {
|
|
281
|
+
const all = [...contextFileEntries(input.contextFiles), ...entries(input)];
|
|
282
|
+
const budget = Math.max(0, input.budgetTokens);
|
|
283
|
+
let capped = all;
|
|
284
|
+
let start = all.length;
|
|
285
|
+
for (const cap of trustedCaps) {
|
|
286
|
+
capped =
|
|
287
|
+
cap === undefined
|
|
288
|
+
? all
|
|
289
|
+
: all.map((entry) =>
|
|
290
|
+
entry.trusted
|
|
291
|
+
? {
|
|
292
|
+
...entry,
|
|
293
|
+
item: shortenEvidence([entry.item], cap, omissionMarker)[0] ?? entry.item,
|
|
294
|
+
}
|
|
295
|
+
: entry,
|
|
296
|
+
);
|
|
297
|
+
const found = windowStart(
|
|
298
|
+
capped,
|
|
299
|
+
capped.map((entry) => textTokens(render(entry))),
|
|
300
|
+
budget,
|
|
301
|
+
);
|
|
302
|
+
if (found === undefined) continue;
|
|
303
|
+
start = found;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
const blocks: string[] = [];
|
|
307
|
+
let omitted = 0;
|
|
308
|
+
for (const entry of capped.slice(0, start)) {
|
|
309
|
+
if (entry.trusted) blocks.push(render(entry));
|
|
310
|
+
else omitted++;
|
|
311
|
+
}
|
|
312
|
+
if (omitted) blocks.push(omissionNote(omitted));
|
|
313
|
+
for (const entry of capped.slice(start)) blocks.push(render(entry));
|
|
314
|
+
return { blocks, omitted };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** A tool call's arguments as Pi passes them to `tool_call` handlers. */
|
|
318
|
+
export type ToolInput = CustomToolCallEvent["input"];
|
|
319
|
+
|
|
320
|
+
/** SHA-256 of a call's serialized arguments, identifying the exact call across reviews. */
|
|
321
|
+
export function argumentsHash(input: ToolInput): string {
|
|
322
|
+
return sha256(JSON.stringify(input));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The tool call that issued a nested call. */
|
|
326
|
+
export interface IssuingCall {
|
|
327
|
+
toolName: string;
|
|
328
|
+
input: ToolInput;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The call a Guardian Review judges. */
|
|
332
|
+
export interface ReviewedCall {
|
|
333
|
+
toolName: string;
|
|
334
|
+
input: ToolInput;
|
|
335
|
+
cwd: string;
|
|
336
|
+
/** Which agent issued it: the main agent, a Child Agent, or an Advisor. */
|
|
337
|
+
agent: string;
|
|
338
|
+
/** The tool call that issued this one, such as a codemode script. */
|
|
339
|
+
parent?: IssuingCall | undefined;
|
|
340
|
+
/** Why a built-in default sends this call to review, such as its Sensitive Path. */
|
|
341
|
+
reason?: string | undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The final request block: the Reviewed Call and the issuing call, if any. Never shortened: a
|
|
346
|
+
* cut could hide the part of the call that does harm, so a call too large to review is a Review
|
|
347
|
+
* Failure instead.
|
|
348
|
+
*/
|
|
349
|
+
export function renderReviewedCall(call: ReviewedCall): string {
|
|
350
|
+
const lines = [
|
|
351
|
+
"Reviewed Call (judge this exact action):",
|
|
352
|
+
`Guarded Agent: ${call.agent}`,
|
|
353
|
+
`Working directory: ${call.cwd}`,
|
|
354
|
+
`Tool: ${call.toolName}`,
|
|
355
|
+
...(call.reason ? [`Reviewed because: ${call.reason}`] : []),
|
|
356
|
+
`Arguments SHA-256: ${argumentsHash(call.input)}`,
|
|
357
|
+
`Arguments: ${JSON.stringify(call.input)}`,
|
|
358
|
+
];
|
|
359
|
+
if (call.parent)
|
|
360
|
+
lines.push(
|
|
361
|
+
`Issued by tool call: ${call.parent.toolName}`,
|
|
362
|
+
`Issuing call arguments: ${JSON.stringify(call.parent.input)}`,
|
|
363
|
+
);
|
|
364
|
+
return lines.join("\n");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Tokens a request text block costs by Pi's chars/4 estimate. */
|
|
368
|
+
export function textTokens(text: string): number {
|
|
369
|
+
return Math.ceil(text.length / 4);
|
|
370
|
+
}
|