char-slop/ai-dots

ai dotfiles

git clone https://git.t4t.associates/char-slop/ai-dots

Charlotte Sompermission-gate: analyze through cwd changes6ff1139

main
15.3 KiB437 linesraw
1import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
2import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3import { Markdown } from "@earendil-works/pi-tui";
4import {
5  analyzeBash,
6  assessAnalysis,
7  assessPath,
8  type PermissionPolicy,
9  type PermissionVerdict,
10} from "bash-effect-analyzer";
11import { hostname } from "node:os";
12import * as path from "node:path";
13import { compactConfirm } from "../_char_common/compact-confirm";
14import { loadSettings } from "../_char_common/settings";
15import { resolvesToFilesystemRoot, rootTargetCommand } from "./root-target";
16
17interface PermissionConfig {
18  allowedCommandPrefixes: string[];
19  writableDirectories: string[];
20  readableDirectories: string[];
21  yoloDisabledHosts: string[];
22}
23
24function readPermissionConfig(cwd: string): PermissionConfig {
25  const result: PermissionConfig = {
26    allowedCommandPrefixes: [],
27    writableDirectories: [],
28    readableDirectories: [],
29    yoloDisabledHosts: [],
30  };
31  const settings = loadSettings(cwd);
32  const pg = settings.permissionGate;
33  if (pg && typeof pg === "object") {
34    const gate = pg as Record<string, unknown>;
35    const stringList = (v: unknown): string[] =>
36      Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : [];
37    result.allowedCommandPrefixes.push(...stringList(gate.allowedCommandPrefixes));
38    result.writableDirectories.push(...stringList(gate.writableDirectories));
39    result.readableDirectories.push(...stringList(gate.readableDirectories));
40    result.yoloDisabledHosts.push(...stringList(gate.yoloDisabledHosts));
41  }
42  return result;
43}
44
45function bullet(items: string[], prefix = "-"): string {
46  return items.map((s) => `${prefix} ${s}`).join("\n");
47}
48
49function formatAllowMessage(label: string, verdict: PermissionVerdict): string {
50  return `🔓 auto-approved ${label}\n${bullet(verdict.allowReasons, "  •")}`;
51}
52
53interface PromptDetail {
54  heading: string;
55  body: string;
56}
57
58function buildPromptMarkdown(detail: PromptDetail, verdict: PermissionVerdict): string {
59  const sections = [detail.body, `**Concerns**\n${bullet(verdict.promptReasons)}`];
60  if (verdict.allowReasons.length > 0) {
61    sections.push(
62      `**Benign effects** (would auto-approve on their own)\n${bullet(verdict.allowReasons)}`,
63    );
64  }
65  return sections.join("\n\n");
66}
67
68// Render the verdict details as a Markdown widget pinned above the editor,
69// then ask via the compact single-line prompt. The built-in `ctx.ui.confirm`
70// draws ~11 rows of chrome (borders, spacers, key hints) for what is
71// fundamentally a y/n question, and embedding the long body into its title
72// also triggers a redraw loop when the rendered prompt exceeds the terminal
73// height. `compactConfirm` keeps the dialog itself to a single wrapped line
74// and requires the user to type "yes" or "no" explicitly so a stray
75// keystroke can't approve a destructive operation.
76async function promptForApproval(
77  ctx: ExtensionContext,
78  detail: PromptDetail,
79  verdict: PermissionVerdict,
80): Promise<boolean> {
81  const markdown = buildPromptMarkdown(detail, verdict);
82  ctx.ui.setWidget(
83    "permission-gate-prompt",
84    (_tui, _theme) => new Markdown(markdown, 1, 0, getMarkdownTheme()),
85  );
86  try {
87    return await compactConfirm(ctx, detail.heading, "");
88  } finally {
89    ctx.ui.setWidget("permission-gate-prompt", undefined);
90  }
91}
92
93export default function (pi: ExtensionAPI) {
94  let yolo = false;
95  let yoloDisabled = false;
96  let strict = false;
97  let strictReads = false;
98  let allowedCommands: string[] = [];
99  let configWritableDirs: string[] = [];
100  let configReadableDirs: string[] = [];
101  let lastPersistedSnapshot: string | null = null;
102
103  function persistAllowedCommands() {
104    const snapshot = JSON.stringify(allowedCommands);
105    if (snapshot === lastPersistedSnapshot) return;
106    pi.appendEntry("permission-gate-allowed", { commands: [...allowedCommands] });
107    lastPersistedSnapshot = snapshot;
108  }
109
110  const DIM = "\x1b[2m";
111  const RESET = "\x1b[22m";
112
113  function buildPolicy(cwd: string): PermissionPolicy {
114    return {
115      cwd,
116      allowedPrefixes: allowedCommands,
117      // /dev/null is the universal Unix bit-bucket; redirecting to it has no
118      // filesystem effect, so it's always safe regardless of cwd.
119      writablePaths: ["/dev/null", ...configWritableDirs],
120      restrictReads: strictReads,
121      readablePaths: configReadableDirs,
122    };
123  }
124
125  function updateStatus(ctx: {
126    ui: { setStatus(id: string, text: string | undefined): void };
127  }) {
128    if (yolo) {
129      ctx.ui.setStatus("permission-gate", `${DIM}🔓 yolo${RESET}`);
130    } else if (strict && strictReads) {
131      ctx.ui.setStatus("permission-gate", `${DIM}🔒 strict + strict-reads${RESET}`);
132    } else if (strict) {
133      ctx.ui.setStatus("permission-gate", `${DIM}🔒 strict${RESET}`);
134    } else if (strictReads) {
135      ctx.ui.setStatus("permission-gate", `${DIM}🔒 strict-reads${RESET}`);
136    } else if (allowedCommands.length > 0) {
137      ctx.ui.setStatus(
138        "permission-gate",
139        `${DIM}🔓 ${allowedCommands.length} allowed command(s)${RESET}`,
140      );
141    } else {
142      ctx.ui.setStatus("permission-gate", undefined);
143    }
144  }
145
146  pi.on("session_start", async (_event, ctx) => {
147    const config = readPermissionConfig(ctx.cwd);
148    allowedCommands = [...config.allowedCommandPrefixes];
149    configWritableDirs = config.writableDirectories.map((d) => path.resolve(ctx.cwd, d));
150    configReadableDirs = config.readableDirectories.map((d) => path.resolve(ctx.cwd, d));
151    yoloDisabled = config.yoloDisabledHosts.includes(hostname());
152    // Each turn_start writes a full snapshot, so only the most recent one
153    // reflects the session's actual state - unioning all historical snapshots
154    // would resurrect any command the user ever toggled off.
155    let lastSnapshot: string[] | null = null;
156    for (const entry of ctx.sessionManager.getEntries()) {
157      if (entry.type === "custom" && entry.customType === "permission-gate-allowed") {
158        lastSnapshot = (entry.data as { commands?: string[] })?.commands ?? [];
159      }
160    }
161    if (lastSnapshot) {
162      for (const p of lastSnapshot) {
163        if (!allowedCommands.includes(p)) allowedCommands.push(p);
164      }
165      // Seed the dedup key so we don't immediately re-append an identical
166      // snapshot on the next turn_start.
167      lastPersistedSnapshot = JSON.stringify(allowedCommands);
168    }
169    updateStatus(ctx);
170  });
171
172  pi.on("turn_start", async () => {
173    if (allowedCommands.length > 0) persistAllowedCommands();
174  });
175
176  // prettier-ignore
177  const PERM_MODES = {
178    yolo: {
179      on: "🔓 yolo mode on - all permission gates disabled",
180      off: "🔒 yolo mode off - permission gates active",
181      toggle: () => { yolo = !yolo; if (yolo) { strict = false; strictReads = false; } return yolo; },
182    },
183    strict: {
184      on: "🔒 strict mode on - any action requiring approval will be auto-denied",
185      off: "🔓 strict mode off - approval prompts re-enabled",
186      toggle: () => { strict = !strict; if (strict) yolo = false; return strict; },
187    },
188    "strict-reads": {
189      on: "🔒 strict-reads mode on - reads confined to cwd + readableDirectories (bash commands and read/ls/grep/find tools)",
190      off: "🔓 strict-reads mode off",
191      toggle: () => { strictReads = !strictReads; if (strictReads) yolo = false; return strictReads; },
192    },
193  } as const satisfies Record<string, { on: string; off: string; toggle: () => boolean }>;
194  type PermMode = keyof typeof PERM_MODES;
195
196  function describeState(): string {
197    const flags = [
198      yolo ? "yolo" : null,
199      strict ? "strict" : null,
200      strictReads ? "strict-reads" : null,
201    ].filter((f): f is string => f !== null);
202    const modeLine =
203      flags.length === 0
204        ? "all modes off (interactive prompting)"
205        : `active: ${flags.join(", ")}`;
206    const allowLine =
207      allowedCommands.length === 0
208        ? "no allowed command prefixes"
209        : `allowed prefixes:\n${allowedCommands.map((c) => `  • ${c}`).join("\n")}`;
210    return `${modeLine}\n${allowLine}`;
211  }
212
213  pi.registerCommand("perms", {
214    description: "Show or toggle permission-gate modes: /perms [yolo|strict|strict-reads]",
215    getArgumentCompletions: (prefix: string) => {
216      const items = (Object.keys(PERM_MODES) as PermMode[])
217        .filter((m) => m.startsWith(prefix))
218        .map((m) => ({ value: m, label: m }));
219      return items.length > 0 ? items : null;
220    },
221    handler: async (args, ctx) => {
222      const arg = args?.trim();
223      if (!arg) {
224        ctx.ui.notify(describeState(), "info");
225        return;
226      }
227      if (arg === "yolo" && !yolo && yoloDisabled) {
228        ctx.ui.notify("yolo mode is disabled on this host (permissionGate.yoloDisabledHosts)", "warning");
229        return;
230      }
231      if (!(arg in PERM_MODES)) {
232        ctx.ui.notify(
233          `Unknown mode "${arg}". Valid: ${Object.keys(PERM_MODES).join(", ")}`,
234          "warning",
235        );
236        return;
237      }
238      const mode = PERM_MODES[arg as PermMode];
239      const enabled = mode.toggle();
240      ctx.ui.notify(enabled ? mode.on : mode.off, "info");
241      updateStatus(ctx);
242    },
243  });
244
245  pi.registerCommand("allow", {
246    description:
247      "Toggle a command prefix on the session allow-list - ALL effects of matching commands are auto-approved",
248    getArgumentCompletions: (prefix: string) => {
249      if (allowedCommands.length === 0) return null;
250      const items = allowedCommands.map((c) => ({ value: c, label: c }));
251      const filtered = items.filter((i) => i.value.startsWith(prefix));
252      return filtered.length > 0 ? filtered : null;
253    },
254    handler: async (args, ctx) => {
255      const command = args?.trim();
256      if (!command) {
257        if (allowedCommands.length === 0) {
258          ctx.ui.notify("No allowed commands. Usage: /allow <command>", "info");
259        } else {
260          ctx.ui.notify(
261            `Allowed commands:\n${allowedCommands.map((c) => `  • ${c}`).join("\n")}`,
262            "info",
263          );
264        }
265        return;
266      }
267      const existingIndex = allowedCommands.indexOf(command);
268      if (existingIndex >= 0) {
269        allowedCommands.splice(existingIndex, 1);
270        ctx.ui.notify(`🔒 "${command}" is no longer auto-approved`, "info");
271      } else {
272        allowedCommands.push(command);
273        ctx.ui.notify(
274          `🔓 Commands matching "${command}" are now auto-approved for this session`,
275          "info",
276        );
277      }
278      persistAllowedCommands();
279      updateStatus(ctx);
280    },
281  });
282
283  async function enforceVerdict(opts: {
284    ctx: ExtensionContext;
285    verdict: PermissionVerdict;
286    allowLabel: string;
287    deniedLine: string;
288    promptDetail: PromptDetail;
289  }): Promise<{ block: true; reason: string } | undefined> {
290    const { ctx, verdict, allowLabel, deniedLine, promptDetail } = opts;
291
292    if (verdict.decision === "allow") {
293      if (ctx.hasUI) {
294        ctx.ui.notify(formatAllowMessage(allowLabel, verdict), "info");
295      }
296      return;
297    }
298
299    if (strict) {
300      return {
301        block: true,
302        reason: `Denied by strict mode. Concerns:\n${bullet(verdict.promptReasons)}`,
303      };
304    }
305    if (!ctx.hasUI) {
306      return {
307        block: true,
308        reason: `Cannot prompt for permission (no UI). ${deniedLine}\n${bullet(verdict.promptReasons, "  •")}`,
309      };
310    }
311    const ok = await promptForApproval(ctx, promptDetail, verdict);
312    if (!ok) {
313      return {
314        block: true,
315        reason: `Denied by user. Concerns:\n${bullet(verdict.promptReasons)}`,
316      };
317    }
318    return;
319  }
320
321  pi.on("tool_call", async (event, ctx) => {
322    if (event.toolName === "bash") {
323      const command = (event.input as { command?: string }).command ?? "";
324      const analysis = await analyzeBash(command, {
325        environment: {
326          HOME: process.env.HOME,
327          OLDPWD: process.env.OLDPWD,
328          CDPATH: process.env.CDPATH,
329        },
330      });
331      const deniedCommand = rootTargetCommand(analysis, ctx.cwd);
332      if (deniedCommand) {
333        return {
334          block: true,
335          reason:
336            deniedCommand === "find"
337              ? "Denied: `find` may not search filesystem root `/` because it would take too long. Search a narrower directory instead."
338              : "Denied: `rm` may not target filesystem root `/`.",
339        };
340      }
341      if (yolo) return;
342
343      const policy = buildPolicy(ctx.cwd);
344      const verdict = assessAnalysis(analysis, policy);
345      return enforceVerdict({
346        ctx,
347        verdict,
348        allowLabel: `\`${command}\``,
349        deniedLine: `Denied: ${command}`,
350        promptDetail: { heading: "Bash command", body: "```bash\n" + command + "\n```" },
351      });
352    }
353
354    if (event.toolName === "find") {
355      const rawPath = (event.input as { path?: string }).path ?? ".";
356      if (resolvesToFilesystemRoot(ctx.cwd, rawPath)) {
357        return {
358          block: true,
359          reason:
360            "Denied: `find` may not search filesystem root `/` because it would take too long. Search a narrower directory instead.",
361        };
362      }
363    }
364
365    if (event.toolName === "read") {
366      const rawPath = (event.input as { path?: string }).path;
367      if (rawPath) {
368        const filename = path.basename(path.resolve(ctx.cwd, rawPath));
369        if (filename === ".env" || filename === ".env.local") {
370          return {
371            block: true,
372            reason: `Denied: \`read\` may not access \`${filename}\` because it may contain secrets.`,
373          };
374        }
375      }
376    }
377
378    if (yolo) return;
379
380    if (event.toolName === "edit" || event.toolName === "write") {
381      return resolvePathDecision({
382        ctx,
383        rawPath:
384          (event.input as { path?: string; file_path?: string }).path ??
385          (event.input as { file_path?: string }).file_path,
386        verdict: (p) => assessPath("write", p, buildPolicy(ctx.cwd)),
387        confirmTitle: "Write outside CWD",
388        label: `${event.toolName}`,
389      });
390    }
391
392    // Read-style tools are only gated in strict-reads mode — in normal
393    // operation reads are unrestricted and the bash safe-command list does the
394    // policy work.
395    if (
396      strictReads &&
397      (event.toolName === "read" ||
398        event.toolName === "ls" ||
399        event.toolName === "grep" ||
400        event.toolName === "find")
401    ) {
402      const rawPath = (event.input as { path?: string }).path;
403      // ls/grep/find without a path default to cwd, which is always allowed.
404      if (!rawPath) return;
405      return resolvePathDecision({
406        ctx,
407        rawPath,
408        verdict: (p) => assessPath("read", p, buildPolicy(ctx.cwd)),
409        confirmTitle: "Read outside CWD",
410        label: `${event.toolName}`,
411      });
412    }
413  });
414
415  async function resolvePathDecision(opts: {
416    ctx: ExtensionContext;
417    rawPath: string | undefined;
418    verdict: (rawPath: string) => PermissionVerdict;
419    confirmTitle: string;
420    label: string;
421  }): Promise<{ block: true; reason: string } | undefined> {
422    const { ctx, rawPath, verdict: makeVerdict, confirmTitle, label } = opts;
423    if (!rawPath) return;
424
425    const resolved = path.resolve(ctx.cwd, rawPath);
426    return enforceVerdict({
427      ctx,
428      verdict: makeVerdict(rawPath),
429      allowLabel: `${label} \`${rawPath}\``,
430      deniedLine: `Denied ${label} ${rawPath}`,
431      promptDetail: {
432        heading: confirmTitle,
433        body: `**Tool:** \`${label}\`\n\n**Path:** \`${rawPath}\`\n\n**Resolved:** \`${resolved}\``,
434      },
435    });
436  }
437}