[READ-ONLY] Mirror of https://github.com/andrioid/pi-fence. Fence wrapper for Pi coding agent. Wraps bash calls and intercepts reads/writes according to fence config
1/**
2 * Interactive grant prompt (PLAN §8 — "Grant flow on block").
3 *
4 * Shown on a tool_call block when:
5 * - `ctx.hasUI === true` (no prompts in headless mode), and
6 * - the deny is not from the mandatory list (those are non-overridable).
7 *
8 * Mandatory safety properties from PLAN §3 + §8, all enforced here:
9 *
10 * 1. Default-focused option is `[d] deny`. A user who reflexively
11 * hits Enter denies the access. Intentional friction.
12 * 2. Resolved (realpath) path is shown first; the requested path is
13 * shown as secondary. Defends against an agent that does
14 * `ln -s ~/.ssh ./innocent` then `read("./innocent/id_rsa")`.
15 * 3. 500 ms anti-fatfinger delay before any keypress is accepted.
16 * We achieve this by registering an `onTerminalInput` consumer
17 * that swallows all input for 500ms, *then* opening the dialog.
18 * Anything the user typed during that window is dropped instead
19 * of being delivered to the select.
20 * 4. `[p] persist` requires a second confirmation. After picking
21 * persist, we show a separate confirm dialog. The actual write
22 * to disk happens only on explicit `y`.
23 */
24
25import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
26import type { FsTool } from "../policy/decide.js";
27
28export type PromptChoice = "once" | "session" | "persist" | "deny";
29
30export interface PromptInput {
31 tool: FsTool;
32 /** Path the LLM passed in (un-canonicalized). */
33 requestedPath: string;
34 /** realpath'd absolute path. Shown first. */
35 realPath: string;
36 /** Rule that fired (e.g. `filesystem.defaultDenyRead`). */
37 rule: string;
38}
39
40const ANTI_FATFINGER_MS = 500;
41
42/**
43 * Show the grant prompt, return the user's choice.
44 *
45 * Does *not* execute the grant — caller (index.ts) wires the choice
46 * into the grant store, audit log, and (for persist) the disk-edit
47 * code in `grants/persist.ts`.
48 */
49export async function promptForGrant(ctx: ExtensionContext, input: PromptInput): Promise<PromptChoice> {
50 if (!ctx.hasUI) return "deny"; // headless: no prompts, deny is final (PLAN §3)
51
52 await drainInputFor(ctx, ANTI_FATFINGER_MS);
53
54 const title = `pi-fence: ${input.tool} denied`;
55
56 // We render the body lines as the option labels' descriptive prefix.
57 // `select` doesn't have a separate description field, so we fold the
58 // path/tool/rule into the title via a leading notify? No — notify is
59 // transient. Use the option labels themselves to carry the path on
60 // the first line, then four real options.
61 //
62 // In practice ctx.ui.select shows just the title + options. We jam
63 // the salient details into the title.
64 const titleWithDetails =
65 `${title}\n` +
66 ` path: ${input.realPath}\n` +
67 (input.realPath !== input.requestedPath ? ` requested: ${input.requestedPath}\n` : "") +
68 ` rule: ${input.rule}`;
69
70 // Order matters: deny is FIRST so it's the default-focused option (PLAN §8).
71 const labels = [
72 "[d] deny",
73 "[a] allow once",
74 "[s] allow for this session",
75 "[p] persist to fence.jsonc",
76 ] as const;
77
78 const choice = await ctx.ui.select(titleWithDetails, labels as unknown as string[]);
79 if (choice === undefined) return "deny"; // dialog dismissed → deny
80 if (choice === labels[0]) return "deny";
81 if (choice === labels[1]) return "once";
82 if (choice === labels[2]) return "session";
83
84 // Persist needs a second confirmation step. The first selection just
85 // expresses intent; the second is the irrevocable disk-write commit.
86 const confirmed = await ctx.ui.confirm(
87 "pi-fence: persist to fence.jsonc?",
88 `This will modify your project's fence.jsonc to permanently allow this path.\n\n ${input.realPath}\n\nProceed?`,
89 );
90 if (!confirmed) return "deny";
91 return "persist";
92}
93
94/**
95 * Show a batched prompt when the rate limiter has tripped (PLAN §8 —
96 * "Rate-limiting prompts"). Three-way choice: allow individually, allow
97 * all once, deny all. Returns the choice; caller sets the per-turn
98 * mode flag based on the result.
99 */
100export async function promptForBatch(
101 ctx: ExtensionContext,
102 deniedCount: number,
103): Promise<"individual" | "allow-all-once" | "deny-all"> {
104 if (!ctx.hasUI) return "deny-all";
105 await drainInputFor(ctx, ANTI_FATFINGER_MS);
106
107 const labels = [
108 "[d] deny all (recommended)",
109 "[i] keep prompting individually",
110 "[a] allow all remaining for this turn",
111 ] as const;
112 const choice = await ctx.ui.select(
113 `pi-fence: ${deniedCount} denials this turn — agent appears to be probing. How do you want to handle the rest?`,
114 labels as unknown as string[],
115 );
116 if (choice === labels[1]) return "individual";
117 if (choice === labels[2]) return "allow-all-once";
118 return "deny-all";
119}
120
121/**
122 * Drain raw terminal input for `ms` milliseconds before resolving.
123 *
124 * `onTerminalInput` is interactive-only. In RPC/print modes the
125 * registration is a no-op (or absent), but those modes also have
126 * `hasUI === false`, so we never reach here.
127 *
128 * The handler returns `{ consume: true }` so swallowed input is *not*
129 * forwarded to whatever the next focused component is. This is the
130 * essential property: we drop keys that were buffered before the
131 * prompt opened.
132 */
133function drainInputFor(ctx: ExtensionContext, ms: number): Promise<void> {
134 return new Promise((resolve) => {
135 const unsubscribe = ctx.ui.onTerminalInput((_data) => {
136 return { consume: true };
137 });
138 setTimeout(() => {
139 unsubscribe();
140 resolve();
141 }, ms);
142 });
143}