@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.
@@ -0,0 +1,556 @@
1
+ import {
2
+ DynamicBorder,
3
+ ExtensionEditorComponent,
4
+ getSelectListTheme,
5
+ getSettingsListTheme,
6
+ type KeybindingsManager,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ fuzzyFilter,
10
+ getKeybindings,
11
+ Input,
12
+ SelectList,
13
+ SettingsList,
14
+ type Component,
15
+ type SelectItem,
16
+ type SettingItem,
17
+ type TUI,
18
+ type TuiMouseEvent,
19
+ type TuiMouseEventResult,
20
+ } from "@earendil-works/pi-tui";
21
+ import { Value } from "typebox/value";
22
+ import { updatedToolEntries, type ToolEntryValue } from "./guardian-command.js";
23
+ import { formatGuardianOption, type GuardianRenderTheme } from "./guardian-rendering.js";
24
+ import {
25
+ guardianOptionKey,
26
+ guardianOptionKeys,
27
+ guardianSettingScopeSchema,
28
+ parseGuardianOptions,
29
+ type GuardianChange,
30
+ type GuardianOptions,
31
+ type GuardianSettingScope,
32
+ type GuardianSettingSource,
33
+ } from "./guardian-settings.js";
34
+
35
+ /** Authored options at each writable scope. */
36
+ export type GuardianScopedOptions = { [Scope in GuardianSettingScope]: GuardianOptions };
37
+
38
+ /** Everything the menu displays, read fresh after each change. */
39
+ export interface GuardianMenuView {
40
+ /** Themed live status lines shown above the settings. */
41
+ headline: readonly string[];
42
+ /** Writable scopes; project only when trusted. */
43
+ scopes: readonly GuardianSettingScope[];
44
+ /** Effective settings after scope precedence. */
45
+ settings: GuardianOptions;
46
+ sources: Readonly<Partial<Record<keyof GuardianOptions, GuardianSettingSource>>>;
47
+ authored: Readonly<Partial<GuardianScopedOptions>>;
48
+ /** `provider/id` names of selectable models. */
49
+ models: readonly string[];
50
+ /** Tool names known to the Guarded Agent's session. */
51
+ tools: readonly string[];
52
+ }
53
+
54
+ /** The extension's settings authority behind the menu. */
55
+ export interface GuardianMenuHost {
56
+ view(): GuardianMenuView;
57
+ /** Validate, persist, and apply one change; rejects with a user-facing message. */
58
+ apply(scope: GuardianSettingScope, change: GuardianChange): Promise<void>;
59
+ }
60
+
61
+ /** Native UI collaborators supplied by `ctx.ui.custom`. */
62
+ export interface GuardianMenuUi {
63
+ tui: TUI;
64
+ keybindings: KeybindingsManager;
65
+ theme: GuardianRenderTheme;
66
+ externalEditorCommand?: string;
67
+ }
68
+
69
+ const cycleValues = {
70
+ enabled: ["inherit", "on", "off"],
71
+ thinkingLevel: ["inherit", "off", "minimal", "low", "medium", "high", "xhigh", "max"],
72
+ onDeny: ["inherit", "block", "ask"],
73
+ verbose: ["inherit", "on", "off"],
74
+ } as const satisfies Record<string, readonly string[]>;
75
+ type CycleKey = keyof typeof cycleValues;
76
+
77
+ function isCycleKey(key: keyof GuardianOptions): key is CycleKey {
78
+ return key === "enabled" || key === "thinkingLevel" || key === "onDeny" || key === "verbose";
79
+ }
80
+
81
+ function cycleValue(key: CycleKey, options: GuardianOptions): string | undefined {
82
+ if (key === "enabled" || key === "verbose") {
83
+ const value = options[key];
84
+ return value === undefined ? undefined : value ? "on" : "off";
85
+ }
86
+ return options[key];
87
+ }
88
+
89
+ const descriptions = {
90
+ enabled: "Review tool calls before they run",
91
+ model: "Guardian model; inherit follows the session's current model",
92
+ thinkingLevel: "Guardian thinking level",
93
+ tools: "Tool Policies: allow, review, or deny each tool's calls",
94
+ safeCommands: "Extra Safe Command prefixes, such as npm test; merged across scopes",
95
+ policy: "Security Policy added to the built-in policy",
96
+ reviewTimeoutMs: "Deadline for each Guardian Review, in seconds; a timeout is a Review Failure",
97
+ evidenceBudgetTokens:
98
+ "Token budget for evidence; auto is a quarter of the Guardian model's context window, at most 32k",
99
+ onDeny: "On a Rejection: block, or ask the user to allow once",
100
+ maxConsecutiveRejections: "Rejection Streak that ends the agent's turn; 0 never ends it",
101
+ verbose:
102
+ "Ask for a rationale on every review and show allowed reviews in the transcript; off asks only for high-risk ones",
103
+ } satisfies Record<keyof GuardianOptions, string>;
104
+ const inputHints = {
105
+ safeCommands: "comma-separated command prefixes, none, or inherit",
106
+ reviewTimeoutMs: "seconds, or inherit",
107
+ evidenceBudgetTokens: "a token count, auto, or inherit",
108
+ maxConsecutiveRejections: "a number (0 disables), or inherit",
109
+ } as const;
110
+ const listActions = [
111
+ "tui.select.up",
112
+ "tui.select.down",
113
+ "tui.select.confirm",
114
+ "tui.select.cancel",
115
+ ] as const;
116
+ const toolCycle: readonly ToolEntryValue[] = ["inherit", "allow", "review", "deny", "default"];
117
+
118
+ /** Convert one typed or selected menu value into a validated change. */
119
+ export function parseGuardianMenuValue(
120
+ key: keyof GuardianOptions,
121
+ text: string,
122
+ scope: GuardianSettingScope,
123
+ ): GuardianChange {
124
+ const value = text.trim();
125
+ if (value === "inherit") return { action: "inherit", key };
126
+ const patch = (() => {
127
+ switch (key) {
128
+ case "enabled":
129
+ return { enabled: value === "on" ? true : value === "off" ? false : value };
130
+ case "verbose":
131
+ return { verbose: value === "on" ? true : value === "off" ? false : value };
132
+ case "safeCommands":
133
+ return {
134
+ safeCommands:
135
+ value === "none"
136
+ ? []
137
+ : value
138
+ .split(",")
139
+ .map((entry) => entry.trim())
140
+ .filter(Boolean),
141
+ };
142
+ case "reviewTimeoutMs":
143
+ return { reviewTimeoutMs: Math.round(Number(value) * 1_000) };
144
+ case "evidenceBudgetTokens":
145
+ return { evidenceBudgetTokens: value === "auto" ? value : Number(value) };
146
+ case "maxConsecutiveRejections":
147
+ return { maxConsecutiveRejections: value === "" ? Number.NaN : Number(value) };
148
+ default:
149
+ if (value === "") throw new Error(`Enter a value for ${key}, or inherit`);
150
+ return { [key]: value };
151
+ }
152
+ })();
153
+ return { action: "set", key, patch: parseGuardianOptions(patch, scope) };
154
+ }
155
+
156
+ function errorText(cause: unknown): string {
157
+ return cause instanceof Error ? cause.message : String(cause);
158
+ }
159
+
160
+ /** Single-line value entry with inline validation. */
161
+ class ValueInput implements Component {
162
+ private readonly input: Input;
163
+ private error: string | undefined;
164
+
165
+ constructor(
166
+ private readonly title: string,
167
+ private readonly hint: string,
168
+ private readonly theme: GuardianRenderTheme,
169
+ submit: (text: string) => void,
170
+ cancel: () => void,
171
+ ) {
172
+ this.input = new Input({ placeholder: hint });
173
+ this.input.focused = true;
174
+ this.input.onSubmit = (text) => {
175
+ try {
176
+ submit(text);
177
+ } catch (cause) {
178
+ this.error = errorText(cause);
179
+ }
180
+ };
181
+ this.input.onEscape = cancel;
182
+ }
183
+ handleInput(data: string): void {
184
+ this.error = undefined;
185
+ this.input.handleInput(data);
186
+ }
187
+ render(width: number): string[] {
188
+ return [
189
+ this.theme.bold(this.title),
190
+ ...this.input.render(width),
191
+ this.error ? this.theme.fg("error", `✖ ${this.error}`) : this.theme.fg("dim", this.hint),
192
+ ];
193
+ }
194
+ invalidate(): void {
195
+ this.input.invalidate();
196
+ }
197
+ }
198
+
199
+ /** Fuzzy-searchable model list with an inherit choice. */
200
+ class ModelPicker implements Component {
201
+ private readonly input = new Input({ placeholder: "type to search" });
202
+ private list: SelectList;
203
+
204
+ constructor(
205
+ private readonly models: readonly string[],
206
+ private readonly choose: (value: string) => void,
207
+ private readonly cancel: () => void,
208
+ ) {
209
+ this.input.focused = true;
210
+ this.list = this.createList("");
211
+ }
212
+ private createList(query: string): SelectList {
213
+ const items: SelectItem[] = ["inherit", ...this.models].map((value) => ({
214
+ value,
215
+ label: value,
216
+ }));
217
+ const list = new SelectList(
218
+ query ? fuzzyFilter(items, query, (item) => item.value) : items,
219
+ 10,
220
+ getSelectListTheme(),
221
+ );
222
+ list.onSelect = (item) => this.choose(item.value);
223
+ list.onCancel = this.cancel;
224
+ return list;
225
+ }
226
+ handleInput(data: string): void {
227
+ const bindings = getKeybindings();
228
+ if (listActions.some((action) => bindings.matches(data, action))) {
229
+ this.list.handleInput(data);
230
+ return;
231
+ }
232
+ this.input.handleInput(data);
233
+ this.list = this.createList(this.input.getValue());
234
+ }
235
+ render(width: number): string[] {
236
+ return [...this.input.render(width), ...this.list.render(width)];
237
+ }
238
+ invalidate(): void {
239
+ this.input.invalidate();
240
+ this.list.invalidate();
241
+ }
242
+ }
243
+
244
+ /** Choose between editing the Security Policy in Pi's editor and inheriting it. */
245
+ class PolicyChooser implements Component {
246
+ private readonly list: SelectList;
247
+ private editor: ExtensionEditorComponent | undefined;
248
+
249
+ constructor(
250
+ ui: GuardianMenuUi,
251
+ policy: string,
252
+ submit: (text: string) => void,
253
+ inherit: () => void,
254
+ cancel: () => void,
255
+ ) {
256
+ this.list = new SelectList(
257
+ [
258
+ { value: "edit", label: "Edit…" },
259
+ { value: "inherit", label: "inherit" },
260
+ ],
261
+ 2,
262
+ getSelectListTheme(),
263
+ );
264
+ this.list.onCancel = cancel;
265
+ this.list.onSelect = (item) => {
266
+ if (item.value === "inherit") {
267
+ inherit();
268
+ return;
269
+ }
270
+ this.editor = new ExtensionEditorComponent(
271
+ ui.tui,
272
+ ui.keybindings,
273
+ "Guardian Security Policy",
274
+ policy,
275
+ submit,
276
+ cancel,
277
+ undefined,
278
+ ui.externalEditorCommand,
279
+ );
280
+ this.editor.focused = true;
281
+ };
282
+ }
283
+ handleInput(data: string): void {
284
+ (this.editor ?? this.list).handleInput(data);
285
+ }
286
+ render(width: number): string[] {
287
+ return (this.editor ?? this.list).render(width);
288
+ }
289
+ invalidate(): void {
290
+ (this.editor ?? this.list).invalidate();
291
+ }
292
+ }
293
+
294
+ const scopeRow = "scope";
295
+
296
+ /** `/guardian` settings menu built from Pi's native settings list. */
297
+ export class GuardianSettingsMenu implements Component {
298
+ private scope: GuardianSettingScope = "session";
299
+ private view: GuardianMenuView;
300
+ private rows: SettingItem[] = [];
301
+ private readonly list: SettingsList;
302
+ private error: string | undefined;
303
+ /** Edits run one at a time so each reads the result of the previous one. */
304
+ private pending: Promise<void> = Promise.resolve();
305
+ private readonly border: DynamicBorder;
306
+ private toolRows: SettingItem[] = [];
307
+
308
+ constructor(
309
+ private readonly host: GuardianMenuHost,
310
+ private readonly ui: GuardianMenuUi,
311
+ private readonly done: () => void,
312
+ ) {
313
+ this.view = host.view();
314
+ this.border = new DynamicBorder((text) => ui.theme.fg("border", text));
315
+ this.rows = [scopeRow, ...guardianOptionKeys].map((id) => this.createRow(id));
316
+ this.list = new SettingsList(
317
+ this.rows,
318
+ this.rows.length,
319
+ getSettingsListTheme(),
320
+ (id, value) => this.change(id, value),
321
+ this.done,
322
+ );
323
+ }
324
+
325
+ /** Resolves once every edit started so far has been applied or rejected. */
326
+ settled(): Promise<void> {
327
+ return this.pending;
328
+ }
329
+
330
+ /** Re-read the host after external state changes, such as a review starting. */
331
+ refresh(): void {
332
+ try {
333
+ this.view = this.host.view();
334
+ } catch (cause) {
335
+ this.error = errorText(cause);
336
+ return;
337
+ }
338
+ if (!this.view.scopes.includes(this.scope)) this.scope = "session";
339
+ for (const row of this.rows) Object.assign(row, this.createRow(row.id));
340
+ for (const row of this.toolRows) row.currentValue = this.toolDisplay(row.id);
341
+ }
342
+
343
+ private createRow(id: string): SettingItem {
344
+ if (id === scopeRow)
345
+ return {
346
+ id,
347
+ label: "Scope",
348
+ currentValue: this.scope,
349
+ values: [...this.view.scopes],
350
+ description: "Where edits are written",
351
+ };
352
+ return this.optionRow(guardianOptionKey(id));
353
+ }
354
+
355
+ private optionRow(key: keyof GuardianOptions): SettingItem {
356
+ const source = this.view.sources[key] ?? "default";
357
+ const settings = this.view.settings;
358
+ const row = {
359
+ id: key,
360
+ label: source === "default" ? key : `${key} [${source}]`,
361
+ description: descriptions[key],
362
+ };
363
+ if (isCycleKey(key))
364
+ return {
365
+ ...row,
366
+ label: key,
367
+ currentValue: this.cycleDisplay(key),
368
+ values: [...cycleValues[key]],
369
+ };
370
+ switch (key) {
371
+ case "model":
372
+ return {
373
+ ...row,
374
+ currentValue: settings.model ?? "inherit",
375
+ submenu: (_value, done) =>
376
+ new ModelPicker(
377
+ this.view.models,
378
+ (value) => {
379
+ this.apply(parseGuardianMenuValue(key, value, this.scope));
380
+ done();
381
+ },
382
+ () => done(),
383
+ ),
384
+ };
385
+ case "tools":
386
+ return {
387
+ ...row,
388
+ currentValue: formatGuardianOption(settings, key),
389
+ submenu: (_value, done) => this.toolList(() => done()),
390
+ };
391
+ case "policy":
392
+ return {
393
+ ...row,
394
+ currentValue: formatGuardianOption(settings, key),
395
+ submenu: (_value, done) =>
396
+ new PolicyChooser(
397
+ this.ui,
398
+ settings.policy ?? "",
399
+ (text) => {
400
+ this.apply({
401
+ action: "set",
402
+ key,
403
+ patch: parseGuardianOptions({ policy: text }, this.scope),
404
+ });
405
+ done();
406
+ },
407
+ () => {
408
+ this.apply({ action: "inherit", key });
409
+ done();
410
+ },
411
+ () => done(),
412
+ ),
413
+ };
414
+ default:
415
+ return {
416
+ ...row,
417
+ currentValue: formatGuardianOption(settings, key),
418
+ submenu: (_value, done) =>
419
+ new ValueInput(
420
+ key,
421
+ inputHints[key],
422
+ this.ui.theme,
423
+ (text) => {
424
+ // Throws for invalid input, keeping the field open with the message.
425
+ this.apply(parseGuardianMenuValue(key, text, this.scope));
426
+ done();
427
+ },
428
+ () => done(),
429
+ ),
430
+ };
431
+ }
432
+ }
433
+
434
+ /** This scope's own entry for a tool, then the effective Tool Policy. */
435
+ private toolDisplay(name: string): string {
436
+ const own = this.view.authored[this.scope]?.tools;
437
+ const value = own && Object.hasOwn(own, name) ? (own[name] ?? "default") : "inherit";
438
+ const effective = this.view.settings.tools?.[name];
439
+ return `${value} (${effective ?? "built-in default"})`;
440
+ }
441
+
442
+ /** Each known tool's entry at the selected scope, cycled inherit → allow → review → deny → default. */
443
+ private toolList(close: () => void): Component {
444
+ const configured = Object.keys(this.view.settings.tools ?? {});
445
+ const names = [...new Set([...this.view.tools, ...configured])].toSorted();
446
+ this.toolRows = names.map((name) => ({
447
+ id: name,
448
+ label: this.view.tools.includes(name) ? name : `${name} (unavailable)`,
449
+ currentValue: this.toolDisplay(name),
450
+ values: [...toolCycle],
451
+ }));
452
+ return new SettingsList(
453
+ this.toolRows,
454
+ Math.min(this.toolRows.length, 12),
455
+ getSettingsListTheme(),
456
+ (name) => {
457
+ const scope = this.scope;
458
+ // Read the entry when this edit runs, after any earlier toggle has been applied.
459
+ this.run(() => {
460
+ const own = this.host.view().authored[scope]?.tools;
461
+ const current = own && Object.hasOwn(own, name) ? (own[name] ?? "default") : "inherit";
462
+ const next =
463
+ toolCycle[(toolCycle.findIndex((value) => value === current) + 1) % toolCycle.length] ??
464
+ "inherit";
465
+ const tools = updatedToolEntries(own, name, next);
466
+ return this.host.apply(
467
+ scope,
468
+ tools
469
+ ? { action: "set", key: "tools", patch: parseGuardianOptions({ tools }, scope) }
470
+ : { action: "inherit", key: "tools" },
471
+ );
472
+ });
473
+ },
474
+ () => {
475
+ this.toolRows = [];
476
+ close();
477
+ },
478
+ { enableSearch: this.toolRows.length > 12 },
479
+ );
480
+ }
481
+
482
+ /** This scope's own value, or what it inherits; notes when another scope overrides it. */
483
+ private cycleDisplay(key: CycleKey): string {
484
+ const own = cycleValue(key, this.view.authored[this.scope] ?? {});
485
+ const effective = cycleValue(key, this.view.settings) ?? "default";
486
+ const source = this.view.sources[key] ?? "default";
487
+ if (own === undefined) return `inherit (${effective} · ${source})`;
488
+ return source === this.scope ? own : `${own} (overridden: ${effective} · ${source})`;
489
+ }
490
+
491
+ private change(id: string, value: string): void {
492
+ if (id === scopeRow) {
493
+ if (Value.Check(guardianSettingScopeSchema, value)) this.scope = value;
494
+ this.refresh();
495
+ return;
496
+ }
497
+ const key = guardianOptionKey(id);
498
+ if (!isCycleKey(key)) return;
499
+ // Cycle from this scope's own value; the list's proposal is based on the display text.
500
+ const values = cycleValues[key];
501
+ const own = cycleValue(key, this.view.authored[this.scope] ?? {}) ?? "inherit";
502
+ const next =
503
+ values[(values.findIndex((option) => option === own) + 1) % values.length] ?? "inherit";
504
+ try {
505
+ this.apply(parseGuardianMenuValue(key, next, this.scope));
506
+ } catch (cause) {
507
+ this.error = errorText(cause);
508
+ }
509
+ const row = this.rows.find((item) => item.id === id);
510
+ if (row) row.currentValue = this.cycleDisplay(key);
511
+ }
512
+
513
+ /** Apply at the current scope; the list refreshes once it settles. */
514
+ private apply(change: GuardianChange): void {
515
+ const scope = this.scope;
516
+ this.run(() => this.host.apply(scope, change));
517
+ }
518
+
519
+ private run(task: () => Promise<void>): void {
520
+ this.error = undefined;
521
+ this.pending = this.pending
522
+ .then(task)
523
+ .catch((cause: unknown) => {
524
+ this.error = errorText(cause);
525
+ })
526
+ .finally(() => {
527
+ this.refresh();
528
+ this.ui.tui.requestRender();
529
+ });
530
+ }
531
+
532
+ handleInput(data: string): void {
533
+ this.list.handleInput(data);
534
+ }
535
+
536
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
537
+ return this.list.handleMouse(event);
538
+ }
539
+
540
+ render(width: number): string[] {
541
+ const { theme } = this.ui;
542
+ return [
543
+ ...this.border.render(width),
544
+ ` ${theme.bold("Guardian settings")}`,
545
+ ...this.view.headline.map((line) => ` ${line}`),
546
+ "",
547
+ ...this.list.render(width),
548
+ ...(this.error ? [theme.fg("error", ` ✖ ${this.error}`)] : []),
549
+ ...this.border.render(width),
550
+ ];
551
+ }
552
+
553
+ invalidate(): void {
554
+ this.list.invalidate();
555
+ }
556
+ }
@@ -0,0 +1,46 @@
1
+ import { basename, dirname, isAbsolute, resolve } from "node:path";
2
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
3
+ import type { ContextFile } from "./guardian-evidence.js";
4
+
5
+ /** The resource loader and project trust of the Guarded Agent's session. */
6
+ export type ResourceSession = Pick<AgentSession, "resourceLoader" | "settingsManager">;
7
+
8
+ /**
9
+ * Context files as Pi loaded them into the Guarded Agent's system prompt. Pi loads the global
10
+ * file from its agent directory and `AGENTS.md`/`CLAUDE.md` from the working directory and its
11
+ * ancestors whether or not the project is trusted, so only the global file and, in a trusted
12
+ * project, the others are Trusted Evidence.
13
+ */
14
+ export function contextFiles(session: ResourceSession, agentDir: string): ContextFile[] {
15
+ const projectTrusted = session.settingsManager.isProjectTrusted();
16
+ const agentDirectory = resolve(agentDir);
17
+ return session.resourceLoader.getAgentsFiles().agentsFiles.map((file) => ({
18
+ path: file.path,
19
+ content: file.content,
20
+ trusted: projectTrusted || dirname(resolve(file.path)) === agentDirectory,
21
+ }));
22
+ }
23
+
24
+ /**
25
+ * Paths of the resources Pi loaded into the Guarded Agent: context files, Skills (their whole
26
+ * directory when a Skill is a `SKILL.md` directory), prompt templates, system prompt files, and
27
+ * extensions. Changing any of them changes trusted instructions or code.
28
+ */
29
+ export function loadedResourcePaths(session: ResourceSession): string[] {
30
+ const loader = session.resourceLoader;
31
+ const paths = [
32
+ ...loader.getAgentsFiles().agentsFiles.map((file) => file.path),
33
+ ...loader
34
+ .getSkills()
35
+ .skills.map((skill) =>
36
+ basename(skill.filePath) === "SKILL.md" ? skill.baseDir : skill.filePath,
37
+ ),
38
+ ...loader.getPrompts().prompts.map((prompt) => prompt.filePath),
39
+ ...loader.getExtensions().extensions.map((extension) => extension.resolvedPath),
40
+ ...loader.getAppendSystemPromptSources().map((source) => source.path),
41
+ ];
42
+ const systemPrompt = loader.getSystemPromptSource();
43
+ if (systemPrompt) paths.push(systemPrompt.path);
44
+ // Inline and built-in extensions have no file.
45
+ return [...new Set(paths.filter((path) => isAbsolute(path)))];
46
+ }