[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
0

Configure Feed

Select the types of activity you want to include in your feed.

pi-fence / grants / prompt.ts
5.4 kB 143 lines
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}