@ibartel74/pi-automode-ext 1.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,1118 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type {
5
+ ExtensionAPI,
6
+ ExtensionCommandContext,
7
+ ExtensionContext,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { StringEnum } from "@earendil-works/pi-ai";
10
+ import { Type } from "typebox";
11
+ import {
12
+ classifierReasoningForConfig,
13
+ defaultClassifyAction,
14
+ resolveJevApiKey,
15
+ serializeClassifierAction,
16
+ } from "./classifier.ts";
17
+ import { analyzeBash, type BashAnalysis } from "./bash.ts";
18
+ import {
19
+ AUTO_MODE_GUIDANCE,
20
+ DEFAULT_ALLOW,
21
+ DEFAULT_ENVIRONMENT,
22
+ DEFAULT_HARD_DENY,
23
+ DEFAULT_PROTECTED_PATHS,
24
+ DEFAULT_SOFT_DENY,
25
+ PATH_BEARING_TOOLS,
26
+ PI_GLOBAL_SETTINGS,
27
+ READ_ONLY_TOOLS,
28
+ } from "./constants.ts";
29
+ import {
30
+ type GlobalConfigPreparation,
31
+ loadEffectiveConfigWithDiagnostics,
32
+ prepareGlobalConfig,
33
+ persistAllowRule,
34
+ writeGlobalClassifierModel,
35
+ } from "./config.ts";
36
+ import { deterministicHardDeny } from "./hard-deny.ts";
37
+ import { isJevClassifierModel, JEV_API_KEY_ENV, JEV_TYPESAFE_API_KEY_ENV } from "./jev.ts";
38
+ import {
39
+ createLogger,
40
+ newDecisionId,
41
+ resolveLogPath,
42
+ type Logger,
43
+ } from "./log.ts";
44
+ import { formatModelSpec, parseModelSpec } from "./model.ts";
45
+ import { promptForClassifierModel } from "./model-selector.ts";
46
+ import {
47
+ allowRuleForAction,
48
+ matchesAllowedToolPatterns,
49
+ normalizeEditedAllowRule,
50
+ matchesDeniedPath,
51
+ matchesToolPattern,
52
+ matchingBashCommandText,
53
+ recursiveSearchMayReachDeniedPath,
54
+ } from "./permissions.ts";
55
+ import {
56
+ extractInputPath,
57
+ isInside,
58
+ isProtectedPath,
59
+ resolvePathForPolicy,
60
+ resolveToolInputPath,
61
+ } from "./paths.ts";
62
+ import {
63
+ actionSummary,
64
+ formatDenials,
65
+ pushDenial,
66
+ restoreState,
67
+ statusLine,
68
+ statusText,
69
+ } from "./state.ts";
70
+ import { loadedContextFromSystemPromptOptions } from "./transcript.ts";
71
+ import type {
72
+ AutoModeState,
73
+ ClassifierReasoningLog,
74
+ ClassifyAction,
75
+ ClassifyResult,
76
+ ConfigLoadResult,
77
+ DecisionKind,
78
+ DenialRecord,
79
+ EffectiveConfig,
80
+ } from "./types.ts";
81
+ import { safeJson, truncateMiddle } from "./utils.ts";
82
+
83
+ const INSPECT_TOOL = "automode_inspect";
84
+ const INSPECTION_ACTIONS = ["status", "config", "defaults", "denials"] as const;
85
+ type InspectionAction = (typeof INSPECTION_ACTIONS)[number];
86
+
87
+ const CONFIRM_ALLOW_ONCE = "Allow once";
88
+ const CONFIRM_CUSTOM_PROJECT = "Custom allow rule (this project)…";
89
+ const CONFIRM_CUSTOM_GLOBAL = "Custom allow rule (global)…";
90
+ const CONFIRM_BLOCK = "Block";
91
+
92
+ function matchedCommandSummary(command: string | undefined): string | undefined {
93
+ return command ? truncateMiddle(command, 500) : undefined;
94
+ }
95
+
96
+ function canonicalPath(path: string): string {
97
+ try {
98
+ return realpathSync(path);
99
+ } catch {
100
+ return resolve(path);
101
+ }
102
+ }
103
+
104
+ const EXTENSION_PATH = canonicalPath(fileURLToPath(import.meta.url));
105
+ const EXTENSION_ENTRY_PATH = canonicalPath(
106
+ resolve(dirname(EXTENSION_PATH), "../auto-mode.ts"),
107
+ );
108
+
109
+ export function modelVisibleConfigDiagnostics(
110
+ diagnostics: string[],
111
+ ): string[] {
112
+ return diagnostics.map((diagnostic) =>
113
+ diagnostic.replace(
114
+ /invalid JSON \([\s\S]*\)$/,
115
+ "invalid JSON (parser details omitted from model-visible output)",
116
+ )
117
+ );
118
+ }
119
+
120
+ function projectIsTrusted(
121
+ ctx: { isProjectTrusted?: () => boolean },
122
+ ): boolean {
123
+ return typeof ctx.isProjectTrusted === "function"
124
+ ? ctx.isProjectTrusted()
125
+ : false;
126
+ }
127
+
128
+ export type PiAutomodeOptions = {
129
+ /** Override config loading in tests. Runtime code uses Pi-owned disk settings. */
130
+ loadConfig?: (cwd: string, projectTrusted: boolean) => EffectiveConfig;
131
+ /** Override classifier calls in tests so unit tests never need a real LLM/API key. */
132
+ classifyAction?: ClassifyAction;
133
+ /** Override classifier-model persistence in tests. Runtime code writes the active global config. */
134
+ saveClassifierModel?: (classifierModel: string) => void;
135
+ /** Override allow-rule persistence in tests. Runtime code writes the target config file. */
136
+ saveAllowRule?: (
137
+ rule: string,
138
+ scope: "global" | "project",
139
+ cwd: string,
140
+ ) => { path: string; added: boolean };
141
+ /** Override global config migration and path selection in tests. */
142
+ prepareGlobalConfig?: () => GlobalConfigPreparation;
143
+ /** Override the application-owned observability log root in tests. */
144
+ logRoot?: string;
145
+ /** Override the observability log clock in tests. */
146
+ now?: () => Date;
147
+ /** Override Bash analysis in tests. Runtime code uses unbash. */
148
+ analyzeBash?: typeof analyzeBash;
149
+ };
150
+
151
+ type LogCtx = {
152
+ logger: Logger;
153
+ decisionId: string;
154
+ classifierModel?: string;
155
+ reasoning: ClassifierReasoningLog;
156
+ };
157
+
158
+ /** Append ccusage-compatible usage and optional classifier I/O entries. */
159
+ function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
160
+ if (decision.reasoning) log.reasoning = decision.reasoning;
161
+ if (decision.io) log.reasoning = decision.io.reasoning;
162
+ if (!log.logger.enabled || !decision.io) return;
163
+
164
+ for (const attempt of decision.io.attempts) {
165
+ const response = attempt.response;
166
+ if (!response) continue;
167
+ log.logger.append({
168
+ type: "message",
169
+ timestamp: new Date(response.timestamp).toISOString(),
170
+ message: {
171
+ role: "assistant",
172
+ model: response.model,
173
+ usage: response.usage,
174
+ },
175
+ });
176
+ }
177
+
178
+ if (!log.logger.classifierIo) return;
179
+ log.logger.append({
180
+ type: "classifier",
181
+ ts: new Date().toISOString(),
182
+ decisionId: log.decisionId,
183
+ model: decision.io.model,
184
+ reasoning: decision.io.reasoning,
185
+ prompt: decision.io.prompt,
186
+ attempts: decision.io.attempts,
187
+ durationMs: decision.io.durationMs,
188
+ parsed: {
189
+ decision: decision.decision,
190
+ tier: decision.tier,
191
+ reason: decision.reason,
192
+ },
193
+ });
194
+ }
195
+
196
+ /** Create a Pi extension instance. Default export uses production dependencies. */
197
+ export function createPiAutomode(options: PiAutomodeOptions = {}) {
198
+ const classify = options.classifyAction ?? defaultClassifyAction;
199
+ const now = options.now ?? (() => new Date());
200
+
201
+ return function piAutomode(pi: ExtensionAPI) {
202
+ const globalConfig = options.prepareGlobalConfig?.() ??
203
+ (options.loadConfig
204
+ ? { status: "current" as const, activePath: PI_GLOBAL_SETTINGS[0] }
205
+ : prepareGlobalConfig());
206
+ const loadConfigWithDiagnostics = (
207
+ cwd: string,
208
+ projectTrusted: boolean,
209
+ ): ConfigLoadResult => {
210
+ const result = options.loadConfig
211
+ ? {
212
+ config: options.loadConfig(cwd, projectTrusted),
213
+ diagnostics: [],
214
+ }
215
+ : loadEffectiveConfigWithDiagnostics(
216
+ cwd,
217
+ projectTrusted,
218
+ globalConfig.activePath,
219
+ );
220
+ return globalConfig.diagnostic
221
+ ? {
222
+ ...result,
223
+ diagnostics: [...result.diagnostics, globalConfig.diagnostic],
224
+ }
225
+ : result;
226
+ };
227
+ const persistClassifierModel = options.saveClassifierModel ??
228
+ ((classifierModel: string) =>
229
+ writeGlobalClassifierModel(classifierModel, globalConfig.activePath));
230
+ const saveClassifierModel = globalConfig.writeBlockedReason
231
+ ? (_classifierModel: string) => {
232
+ throw new Error(globalConfig.writeBlockedReason);
233
+ }
234
+ : persistClassifierModel;
235
+ let loadResult = loadConfigWithDiagnostics(process.cwd(), false);
236
+ let config: EffectiveConfig = loadResult.config;
237
+ let configDiagnostics: string[] = loadResult.diagnostics;
238
+ let state: AutoModeState = {
239
+ checkedActions: 0,
240
+ blockedActions: 0,
241
+ classifierAllowed: 0,
242
+ classifierDenied: 0,
243
+ userConfirmed: 0,
244
+ recentDenials: [],
245
+ };
246
+ let loadedContext = "";
247
+ let globalConfigNoticeShown = false;
248
+
249
+ function effectiveConfig(): EffectiveConfig {
250
+ return {
251
+ ...config,
252
+ enabled: state.enabledOverride ?? config.enabled,
253
+ };
254
+ }
255
+
256
+ function ownsInspectionTool(): boolean {
257
+ const tool = pi.getAllTools().find(({ name }) => name === INSPECT_TOOL);
258
+ if (!tool) return false;
259
+ const sourcePath = canonicalPath(tool.sourceInfo.path);
260
+ return sourcePath === EXTENSION_PATH || sourcePath === EXTENSION_ENTRY_PATH;
261
+ }
262
+
263
+ function persist(): void {
264
+ pi.appendEntry("pi-automode-state", state);
265
+ }
266
+
267
+ function updateUi(ctx: ExtensionContext): void {
268
+ if (!ctx.hasUI) return;
269
+ const cfg = effectiveConfig();
270
+ const text = statusLine(cfg, state);
271
+ ctx.ui.setStatus(
272
+ "pi-automode",
273
+ cfg.enabled
274
+ ? ctx.ui.theme.fg("accent", text)
275
+ : ctx.ui.theme.fg("dim", text),
276
+ );
277
+ }
278
+
279
+ function inspectAutomode(
280
+ action: InspectionAction,
281
+ ctx: ExtensionContext,
282
+ ): unknown {
283
+ const cfg = effectiveConfig();
284
+ if (action === "status") {
285
+ const status = [
286
+ `enabled: ${cfg.enabled ? "yes" : "no"}`,
287
+ `classifier: ${cfg.classifierModel ?? "current session model"}`,
288
+ `classifier reasoning: ${cfg.classifierReasoningLevel ?? "server default"}`,
289
+ `checked actions: ${state.checkedActions}`,
290
+ `blocked actions: ${state.blockedActions}`,
291
+ `classifier allowed: ${state.classifierAllowed}`,
292
+ `classifier denied: ${state.classifierDenied}`,
293
+ `permissions.deny rules: ${cfg.permissionDeny.length}`,
294
+ `permissions.ask rules: ${cfg.permissionAsk.length}`,
295
+ `permissions.allow rules: ${cfg.permissionAllow.length}`,
296
+ `environment entries: ${cfg.environment.length}`,
297
+ `allow entries: ${cfg.allow.length}`,
298
+ `soft_deny entries: ${cfg.softDeny.length}`,
299
+ `hard_deny entries: ${cfg.hardDeny.length}`,
300
+ `last decision: ${state.lastDecision ?? "none"}`,
301
+ "last reason: omitted from model-visible inspection",
302
+ ].join("\n");
303
+ return {
304
+ status,
305
+ state: {
306
+ enabledOverride: state.enabledOverride,
307
+ lastDecision: state.lastDecision,
308
+ checkedActions: state.checkedActions,
309
+ blockedActions: state.blockedActions,
310
+ classifierAllowed: state.classifierAllowed,
311
+ classifierDenied: state.classifierDenied,
312
+ },
313
+ };
314
+ }
315
+ if (action === "config") {
316
+ return {
317
+ config: cfg,
318
+ logFile: resolveLogPath(
319
+ ctx.sessionManager.getSessionFile?.(),
320
+ ctx.sessionManager.getSessionDir?.() ?? "",
321
+ ctx.sessionManager.getSessionId?.() ?? "unknown",
322
+ ctx.cwd,
323
+ options.logRoot,
324
+ now(),
325
+ ),
326
+ diagnostics: modelVisibleConfigDiagnostics(configDiagnostics),
327
+ };
328
+ }
329
+ if (action === "defaults") {
330
+ return {
331
+ environment: DEFAULT_ENVIRONMENT,
332
+ allow: DEFAULT_ALLOW,
333
+ protectedPaths: DEFAULT_PROTECTED_PATHS,
334
+ soft_deny: DEFAULT_SOFT_DENY,
335
+ hard_deny: DEFAULT_HARD_DENY,
336
+ };
337
+ }
338
+ const denials = state.recentDenials.slice().reverse().map((denial) => ({
339
+ timestamp: denial.timestamp,
340
+ kind: denial.kind,
341
+ toolName: denial.toolName,
342
+ }));
343
+ return {
344
+ summary: denials.length === 0
345
+ ? "No recent auto-mode denials."
346
+ : `${denials.length} recent auto-mode denial(s). Reasons and action payloads are omitted.`,
347
+ denials,
348
+ };
349
+ }
350
+
351
+ function block(
352
+ ctx: ExtensionContext,
353
+ denial: DenialRecord,
354
+ logCtx: LogCtx,
355
+ ): { block: true; reason: string } {
356
+ state.blockedActions += 1;
357
+ state.lastDecision = "block";
358
+ state.lastReason = denial.reason;
359
+ pushDenial(state, denial);
360
+ persist();
361
+ updateUi(ctx);
362
+ if (logCtx.logger.enabled) {
363
+ logCtx.logger.append({
364
+ type: "decision",
365
+ ts: new Date().toISOString(),
366
+ decisionId: logCtx.decisionId,
367
+ sessionId: ctx.sessionManager.getSessionId?.(),
368
+ cwd: ctx.cwd,
369
+ tool: denial.toolName,
370
+ summary: denial.action,
371
+ kind: denial.kind,
372
+ outcome: "block",
373
+ reason: denial.reason,
374
+ classifierModel: logCtx.classifierModel,
375
+ reasoning: logCtx.reasoning,
376
+ });
377
+ }
378
+ if (ctx.hasUI) {
379
+ ctx.ui.notify(
380
+ `Auto mode blocked ${denial.toolName}: ${denial.reason}`,
381
+ "warning",
382
+ );
383
+ }
384
+ return { block: true, reason: `[pi-automode] ${denial.reason}` };
385
+ }
386
+
387
+ function allow(
388
+ ctx: ExtensionContext,
389
+ kind: DecisionKind,
390
+ reason: string,
391
+ toolName: string,
392
+ summary: string,
393
+ logCtx: LogCtx,
394
+ ): undefined {
395
+ state.lastDecision = "allow";
396
+ state.lastReason = reason;
397
+ persist();
398
+ updateUi(ctx);
399
+ if (logCtx.logger.enabled) {
400
+ logCtx.logger.append({
401
+ type: "decision",
402
+ ts: new Date().toISOString(),
403
+ decisionId: logCtx.decisionId,
404
+ sessionId: ctx.sessionManager.getSessionId?.(),
405
+ cwd: ctx.cwd,
406
+ tool: toolName,
407
+ summary,
408
+ kind,
409
+ outcome: "allow",
410
+ reason,
411
+ classifierModel: logCtx.classifierModel,
412
+ reasoning: logCtx.reasoning,
413
+ });
414
+ }
415
+ return undefined;
416
+ }
417
+
418
+ pi.on("session_start", (_event, ctx) => {
419
+ loadResult = loadConfigWithDiagnostics(
420
+ ctx.cwd,
421
+ projectIsTrusted(ctx),
422
+ );
423
+ config = loadResult.config;
424
+ configDiagnostics = loadResult.diagnostics;
425
+ state = restoreState(ctx);
426
+ if (
427
+ ctx.hasUI &&
428
+ globalConfig.notification &&
429
+ !globalConfigNoticeShown
430
+ ) {
431
+ ctx.ui.notify(
432
+ globalConfig.notification,
433
+ globalConfig.status === "migrated" ? "info" : "warning",
434
+ );
435
+ globalConfigNoticeShown = true;
436
+ }
437
+ updateUi(ctx);
438
+ });
439
+
440
+ pi.on("before_agent_start", (event) => {
441
+ const cfg = effectiveConfig();
442
+ if (!cfg.enabled) return undefined;
443
+ loadedContext = loadedContextFromSystemPromptOptions(
444
+ event.systemPromptOptions,
445
+ );
446
+ return { systemPrompt: `${event.systemPrompt}\n\n${AUTO_MODE_GUIDANCE}` };
447
+ });
448
+
449
+ pi.on("tool_call", async (event, ctx) => {
450
+ // Enforcement order:
451
+ // 1. permission deny/ask rules,
452
+ // 2. deterministic hard-deny checks that never consult the model,
453
+ // 3. extension-owned read-only inspection tool,
454
+ // 4. deterministic path denials,
455
+ // 5. accepted ask rules force classifier review and skip all allow tiers,
456
+ // 6. inside-CWD, permissions.allow, and read-only allow tiers,
457
+ // 7. classifier for every remaining action, fail-closed on setup/parse errors.
458
+ const cfg = effectiveConfig();
459
+ if (!cfg.enabled) return undefined;
460
+ if (ctx.signal?.aborted) return { block: true, reason: "Cancelled" };
461
+
462
+ const isOwnedInspection = event.toolName === INSPECT_TOOL &&
463
+ ownsInspectionTool();
464
+ const input = event.input as Record<string, unknown>;
465
+ let bashAnalysis: BashAnalysis | undefined;
466
+ if (event.toolName === "bash") {
467
+ const source = typeof input.command === "string" ? input.command : "";
468
+ try {
469
+ bashAnalysis = (options.analyzeBash ?? analyzeBash)(source);
470
+ } catch (error) {
471
+ bashAnalysis = {
472
+ source,
473
+ commands: [],
474
+ redirects: [],
475
+ redirectTargets: [],
476
+ structure: [],
477
+ allowStructureSafe: false,
478
+ errors: [{
479
+ message: `Bash analysis failed: ${
480
+ error instanceof Error ? error.message : String(error)
481
+ }`,
482
+ }],
483
+ };
484
+ }
485
+ }
486
+ const summary = actionSummary(event.toolName, input);
487
+ if (!isOwnedInspection) state.checkedActions += 1;
488
+ const logCtx: LogCtx = {
489
+ logger: createLogger({
490
+ enabled: cfg.log.enabled,
491
+ classifierIo: cfg.log.classifierIo,
492
+ sessionFile: ctx.sessionManager.getSessionFile?.(),
493
+ sessionDir: ctx.sessionManager.getSessionDir?.() ?? "",
494
+ sessionCwd: ctx.cwd,
495
+ sessionId: ctx.sessionManager.getSessionId?.() ?? "unknown",
496
+ logRoot: options.logRoot,
497
+ now: now(),
498
+ }),
499
+ decisionId: newDecisionId(),
500
+ classifierModel: cfg.classifierModel,
501
+ reasoning: classifierReasoningForConfig(cfg.classifierReasoningLevel),
502
+ };
503
+
504
+ for (const pattern of cfg.permissionDeny) {
505
+ if (
506
+ matchesToolPattern(
507
+ pattern,
508
+ event.toolName,
509
+ input,
510
+ ctx.cwd,
511
+ "match",
512
+ bashAnalysis,
513
+ )
514
+ ) {
515
+ const matchedCommand = matchedCommandSummary(
516
+ matchingBashCommandText(pattern, bashAnalysis),
517
+ );
518
+ if (isOwnedInspection) state.checkedActions += 1;
519
+ return block(ctx, {
520
+ timestamp: Date.now(),
521
+ toolName: event.toolName,
522
+ reason: `Blocked by permissions.deny: ${pattern.raw}${
523
+ matchedCommand ? `; matched command: ${matchedCommand}` : ""
524
+ }`,
525
+ action: summary,
526
+ kind: "permissions.deny",
527
+ }, logCtx);
528
+ }
529
+ }
530
+
531
+ let askRequiresClassifier = false;
532
+ for (const pattern of cfg.permissionAsk) {
533
+ if (
534
+ !matchesToolPattern(
535
+ pattern,
536
+ event.toolName,
537
+ input,
538
+ ctx.cwd,
539
+ "match",
540
+ bashAnalysis,
541
+ )
542
+ ) {
543
+ continue;
544
+ }
545
+ if (!ctx.hasUI) {
546
+ const matchedCommand = matchedCommandSummary(
547
+ matchingBashCommandText(pattern, bashAnalysis),
548
+ );
549
+ if (isOwnedInspection) state.checkedActions += 1;
550
+ return block(ctx, {
551
+ timestamp: Date.now(),
552
+ toolName: event.toolName,
553
+ reason:
554
+ `Matched permissions.ask (${pattern.raw})${
555
+ matchedCommand ? ` for command: ${matchedCommand}` : ""
556
+ } but no UI is available`,
557
+ action: summary,
558
+ kind: "permissions.ask",
559
+ }, logCtx);
560
+ }
561
+ const allowed = await ctx.ui.confirm(
562
+ "Auto mode permission ask",
563
+ `Rule: ${pattern.raw}\n\nAction:\n${summary}\n\nAllow this action to continue to auto-mode classification?`,
564
+ { signal: ctx.signal },
565
+ );
566
+ if (!allowed) {
567
+ const matchedCommand = matchedCommandSummary(
568
+ matchingBashCommandText(pattern, bashAnalysis),
569
+ );
570
+ if (isOwnedInspection) state.checkedActions += 1;
571
+ return block(ctx, {
572
+ timestamp: Date.now(),
573
+ toolName: event.toolName,
574
+ reason: `Declined permissions.ask: ${pattern.raw}${
575
+ matchedCommand ? `; matched command: ${matchedCommand}` : ""
576
+ }`,
577
+ action: summary,
578
+ kind: "permissions.ask",
579
+ }, logCtx);
580
+ }
581
+ askRequiresClassifier = true;
582
+ }
583
+
584
+ const deterministicReason = deterministicHardDeny(
585
+ event.toolName,
586
+ input,
587
+ ctx.cwd,
588
+ bashAnalysis,
589
+ );
590
+ if (deterministicReason) {
591
+ if (isOwnedInspection) state.checkedActions += 1;
592
+ return block(ctx, {
593
+ timestamp: Date.now(),
594
+ toolName: event.toolName,
595
+ reason: deterministicReason,
596
+ action: summary,
597
+ kind: "deterministic-hard-deny",
598
+ }, logCtx);
599
+ }
600
+
601
+ if (isOwnedInspection && !askRequiresClassifier) return undefined;
602
+ if (isOwnedInspection) state.checkedActions += 1;
603
+
604
+ // Deterministic path gate for file tools.
605
+ //
606
+ // `deniedPaths` always applies: a matching path is hard-denied before any
607
+ // classifier or fast path, so secrets and system dirs never reach the
608
+ // model. `allowInsideWorkingDirectory` adds a deterministic silent-allow
609
+ // tier for file tools whose resolved path is inside the working
610
+ // directory, and routes outside-CWD file access to the classifier
611
+ // (bypassing the read-only fast path so reads outside the tree are
612
+ // reviewed too).
613
+ //
614
+ // The gate is skipped entirely when both features are off, so the
615
+ // default configuration costs no extra filesystem calls.
616
+ let readOnlyFastPath =
617
+ !askRequiresClassifier &&
618
+ !cfg.classifyReadOnlyTools &&
619
+ READ_ONLY_TOOLS.has(event.toolName);
620
+ if (
621
+ (cfg.deniedPaths.length > 0 || cfg.allowInsideWorkingDirectory) &&
622
+ PATH_BEARING_TOOLS.has(event.toolName)
623
+ ) {
624
+ const inputPath = extractInputPath(event.toolName, input);
625
+ if (inputPath !== undefined) {
626
+ const resolved =
627
+ resolveToolInputPath(event.toolName, ctx.cwd, inputPath) ??
628
+ inputPath;
629
+ const policyPath = resolvePathForPolicy(resolved) ?? resolved;
630
+ const denied =
631
+ cfg.deniedPaths.length > 0 &&
632
+ (matchesDeniedPath(resolved, cfg.deniedPaths) ||
633
+ matchesDeniedPath(policyPath, cfg.deniedPaths));
634
+ if (denied) {
635
+ return block(ctx, {
636
+ timestamp: Date.now(),
637
+ toolName: event.toolName,
638
+ reason: `Path denied by policy: ${policyPath}`,
639
+ action: summary,
640
+ kind: "deterministic-path-deny",
641
+ }, logCtx);
642
+ }
643
+ let recursiveSearch =
644
+ event.toolName === "grep" || event.toolName === "find";
645
+ if (recursiveSearch) {
646
+ try {
647
+ recursiveSearch = statSync(policyPath).isDirectory();
648
+ } catch {
649
+ // A missing search root will fail in the tool. Treat it as a
650
+ // directory here so a denied scope cannot fail open in a race.
651
+ }
652
+ }
653
+ const deniedSearchScope =
654
+ recursiveSearch &&
655
+ cfg.deniedPaths.length > 0 &&
656
+ (recursiveSearchMayReachDeniedPath(resolved, cfg.deniedPaths) ||
657
+ recursiveSearchMayReachDeniedPath(
658
+ policyPath,
659
+ cfg.deniedPaths,
660
+ ));
661
+ if (deniedSearchScope) {
662
+ return block(ctx, {
663
+ timestamp: Date.now(),
664
+ toolName: event.toolName,
665
+ reason: `Search scope can contain a path denied by policy: ${policyPath}`,
666
+ action: summary,
667
+ kind: "deterministic-path-deny",
668
+ }, logCtx);
669
+ }
670
+ if (cfg.allowInsideWorkingDirectory) {
671
+ const policyCwd = resolvePathForPolicy(ctx.cwd) ?? ctx.cwd;
672
+ if (isInside(policyPath, policyCwd)) {
673
+ // Protected in-tree writes and accepted ask rules must still
674
+ // reach the classifier. They cannot use the inside-CWD tier.
675
+ const protectedWrite =
676
+ (event.toolName === "write" || event.toolName === "edit") &&
677
+ isProtectedPath(policyPath, policyCwd, cfg.protectedPaths);
678
+ if (!askRequiresClassifier && !protectedWrite) {
679
+ return allow(
680
+ ctx,
681
+ "inside-working-directory",
682
+ `Path inside working directory: ${policyPath}`,
683
+ event.toolName,
684
+ summary,
685
+ logCtx,
686
+ );
687
+ }
688
+ }
689
+ // Outside the working directory, protected writes, and accepted
690
+ // ask rules must not use the read-only fast path.
691
+ readOnlyFastPath = false;
692
+ }
693
+ }
694
+ }
695
+
696
+ // Deterministic allow tier. It runs after every deterministic denial.
697
+ // Accepted ask rules skip this tier and always reach the classifier.
698
+ if (!askRequiresClassifier) {
699
+ if (
700
+ matchesAllowedToolPatterns(
701
+ cfg.permissionAllow,
702
+ event.toolName,
703
+ input,
704
+ ctx.cwd,
705
+ bashAnalysis,
706
+ )
707
+ ) {
708
+ // A protected-path write/edit is never covered by permissions.allow;
709
+ // it stays on the classifier path (same rule as the inside-CWD tier).
710
+ let protectedWrite = false;
711
+ if (event.toolName === "write" || event.toolName === "edit") {
712
+ const inputPath = extractInputPath(event.toolName, input);
713
+ const resolved = inputPath === undefined
714
+ ? undefined
715
+ : resolveToolInputPath(event.toolName, ctx.cwd, inputPath) ??
716
+ inputPath;
717
+ if (
718
+ resolved !== undefined &&
719
+ isProtectedPath(resolved, ctx.cwd, cfg.protectedPaths)
720
+ ) {
721
+ protectedWrite = true;
722
+ }
723
+ }
724
+ if (!protectedWrite) {
725
+ return allow(
726
+ ctx,
727
+ "permissions.allow",
728
+ "Allowed by permissions.allow",
729
+ event.toolName,
730
+ summary,
731
+ logCtx,
732
+ );
733
+ }
734
+ }
735
+ }
736
+
737
+ if (readOnlyFastPath) {
738
+ return allow(
739
+ ctx,
740
+ "read-only",
741
+ `Read-only built-in tool: ${event.toolName}`,
742
+ event.toolName,
743
+ summary,
744
+ logCtx,
745
+ );
746
+ }
747
+
748
+ const decision = await classify(
749
+ ctx,
750
+ cfg,
751
+ serializeClassifierAction(event.toolName, input),
752
+ loadedContext,
753
+ );
754
+ logClassifierIo(decision, logCtx);
755
+ if (decision.decision === "allow") {
756
+ state.classifierAllowed += 1;
757
+ return allow(
758
+ ctx,
759
+ "classifier",
760
+ decision.reason,
761
+ event.toolName,
762
+ summary,
763
+ logCtx,
764
+ );
765
+ }
766
+
767
+ // Claude Code-style interactive override: when enabled and a UI is
768
+ // available, a classifier block (any tier, including fail-closed errors)
769
+ // asks the user instead of blocking outright. Approval is a live human
770
+ // decision, never transcript content, so prompt-injection defenses in
771
+ // the classifier prompt still hold. The "always allow" choices persist
772
+ // a permissions.allow rule — exact-match by default, or a user-edited
773
+ // pattern via the custom-rule choices — which by design skips classifier
774
+ // review for future matching actions. Without a UI the block stands.
775
+ if (cfg.interactiveConfirm && ctx.hasUI) {
776
+ const exactRule = allowRuleForAction(event.toolName, input);
777
+ const allowGlobal = exactRule ? `Always allow (global): ${exactRule}` : undefined;
778
+ const allowProject = exactRule ? `Always allow (this project): ${exactRule}` : undefined;
779
+ const normalizedToolName = event.toolName.trim().replace(/^@/, "").toLowerCase();
780
+ const supportsPatterns = normalizedToolName === "bash" || PATH_BEARING_TOOLS.has(normalizedToolName);
781
+ const choiceOptions = [
782
+ CONFIRM_ALLOW_ONCE,
783
+ ...(allowGlobal ? [allowGlobal] : []),
784
+ ...(allowProject ? [allowProject] : []),
785
+ ...(supportsPatterns ? [CONFIRM_CUSTOM_PROJECT, CONFIRM_CUSTOM_GLOBAL] : []),
786
+ CONFIRM_BLOCK,
787
+ ];
788
+ let rule: string | undefined;
789
+ let scope: "global" | "project" | undefined;
790
+ let confirmed = false;
791
+ while (!confirmed) {
792
+ const choice = await ctx.ui.select(
793
+ `Auto mode blocked this action (${decision.tier})\n\nReason: ${decision.reason}\n\nAction:\n${summary}`,
794
+ choiceOptions,
795
+ { signal: ctx.signal },
796
+ );
797
+ if (choice === CONFIRM_ALLOW_ONCE) {
798
+ confirmed = true;
799
+ } else if (choice === allowGlobal && exactRule) {
800
+ confirmed = true;
801
+ rule = exactRule;
802
+ scope = "global";
803
+ } else if (choice === allowProject && exactRule) {
804
+ confirmed = true;
805
+ rule = exactRule;
806
+ scope = "project";
807
+ } else if (choice === CONFIRM_CUSTOM_PROJECT || choice === CONFIRM_CUSTOM_GLOBAL) {
808
+ // The exact rule is the prefill; the user owns any widening, e.g.
809
+ // adding a `*` to `bash(npm test*)`. Wildcards are the user's
810
+ // explicit scope choice, never inferred.
811
+ const edited = await ctx.ui.input(
812
+ "Auto mode allow rule",
813
+ exactRule ?? `${normalizedToolName}(…)`,
814
+ { signal: ctx.signal },
815
+ );
816
+ const normalized = normalizeEditedAllowRule(event.toolName, edited);
817
+ if (normalized) {
818
+ confirmed = true;
819
+ rule = normalized;
820
+ scope = choice === CONFIRM_CUSTOM_PROJECT ? "project" : "global";
821
+ } else if (edited !== undefined && edited.trim() !== "") {
822
+ ctx.ui.notify(
823
+ "Invalid allow rule; expected a scoped pattern for this tool, e.g. bash(npm test*)",
824
+ "warning",
825
+ );
826
+ }
827
+ // Cancelled or empty input falls through and re-prompts the choice
828
+ // dialog, so an accidental escape cannot silently block.
829
+ } else {
830
+ break;
831
+ }
832
+ }
833
+ if (confirmed) {
834
+ if (scope !== undefined && rule) {
835
+ try {
836
+ const saved = (options.saveAllowRule ?? persistAllowRule)(
837
+ rule,
838
+ scope,
839
+ ctx.cwd,
840
+ );
841
+ loadResult = loadConfigWithDiagnostics(
842
+ ctx.cwd,
843
+ projectIsTrusted(ctx),
844
+ );
845
+ config = loadResult.config;
846
+ configDiagnostics = loadResult.diagnostics;
847
+ ctx.ui.notify(
848
+ `pi-automode allow rule saved to ${saved.path}: ${rule}${
849
+ saved.added ? "" : " (already present)"
850
+ }`,
851
+ "info",
852
+ );
853
+ if (scope === "project" && !projectIsTrusted(ctx)) {
854
+ // The file is written on explicit user choice, but untrusted
855
+ // project config is never read, so the rule activates only
856
+ // after the project becomes trusted.
857
+ ctx.ui.notify(
858
+ "Project-local allow rules apply once Pi trusts this project",
859
+ "warning",
860
+ );
861
+ }
862
+ } catch (error) {
863
+ // A failed save must not revoke the user's explicit approval;
864
+ // the action is allowed once and the error is reported.
865
+ ctx.ui.notify(
866
+ `Failed to save pi-automode allow rule: ${
867
+ error instanceof Error ? error.message : String(error)
868
+ }`,
869
+ "error",
870
+ );
871
+ }
872
+ }
873
+ state.userConfirmed += 1;
874
+ return allow(
875
+ ctx,
876
+ "user-confirmed",
877
+ `User confirmed action blocked by classifier (${decision.tier}): ${decision.reason}${
878
+ scope !== undefined && rule
879
+ ? `; saved permissions.allow rule for future actions: ${rule}`
880
+ : ""
881
+ }`,
882
+ event.toolName,
883
+ summary,
884
+ logCtx,
885
+ );
886
+ }
887
+ state.classifierDenied += 1;
888
+ return block(ctx, {
889
+ timestamp: Date.now(),
890
+ toolName: event.toolName,
891
+ reason: `Declined interactive confirmation (${decision.tier}): ${decision.reason}`,
892
+ action: summary,
893
+ kind: "classifier",
894
+ }, logCtx);
895
+ }
896
+
897
+ state.classifierDenied += 1;
898
+ return block(ctx, {
899
+ timestamp: Date.now(),
900
+ toolName: event.toolName,
901
+ reason: decision.reason,
902
+ action: summary,
903
+ kind: "classifier",
904
+ }, logCtx);
905
+ });
906
+
907
+ pi.registerTool({
908
+ name: INSPECT_TOOL,
909
+ label: "Inspect Auto Mode",
910
+ description:
911
+ "Inspect the active pi-automode status, effective config, built-in defaults, or recent denial metadata. This tool is read-only and cannot enable, disable, reload, reset, or reconfigure auto mode. Its output is sent to the current model; denial reasons and action payloads are omitted.",
912
+ promptSnippet:
913
+ "Inspect active pi-automode state and diagnostic information without changing it",
914
+ parameters: Type.Object({
915
+ action: StringEnum(INSPECTION_ACTIONS, {
916
+ description: "The read-only auto-mode view to return",
917
+ }),
918
+ }),
919
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
920
+ if (signal?.aborted) throw new Error("Auto-mode inspection cancelled");
921
+ const result = inspectAutomode(params.action, ctx);
922
+ return {
923
+ content: [{ type: "text", text: safeJson(result, 16000) }],
924
+ details: result,
925
+ };
926
+ },
927
+ });
928
+
929
+ async function handleAutomodeCommand(
930
+ args: string,
931
+ ctx: ExtensionCommandContext,
932
+ ): Promise<void> {
933
+ const [command = "status", ...rest] = args
934
+ .trim()
935
+ .split(/\s+/)
936
+ .filter(Boolean);
937
+ const remainder = rest.join(" ").trim();
938
+
939
+ if (command === "status") {
940
+ ctx.ui.notify(statusText(effectiveConfig(), state), "info");
941
+ return;
942
+ }
943
+ if (command === "on") {
944
+ state.enabledOverride = true;
945
+ persist();
946
+ updateUi(ctx);
947
+ ctx.ui.notify("pi-automode enabled for this session", "info");
948
+ return;
949
+ }
950
+ if (command === "off") {
951
+ state.enabledOverride = false;
952
+ persist();
953
+ updateUi(ctx);
954
+ ctx.ui.notify("pi-automode disabled for this session", "warning");
955
+ return;
956
+ }
957
+ if (command === "reload") {
958
+ loadResult = loadConfigWithDiagnostics(
959
+ ctx.cwd,
960
+ projectIsTrusted(ctx),
961
+ );
962
+ config = loadResult.config;
963
+ configDiagnostics = loadResult.diagnostics;
964
+ persist();
965
+ updateUi(ctx);
966
+ ctx.ui.notify(
967
+ "pi-automode config reloaded",
968
+ configDiagnostics.length > 0 ? "warning" : "info",
969
+ );
970
+ return;
971
+ }
972
+ if (command === "reset") {
973
+ state = {
974
+ checkedActions: 0,
975
+ blockedActions: 0,
976
+ classifierAllowed: 0,
977
+ classifierDenied: 0,
978
+ userConfirmed: 0,
979
+ recentDenials: [],
980
+ enabledOverride: state.enabledOverride,
981
+ };
982
+ persist();
983
+ updateUi(ctx);
984
+ ctx.ui.notify("pi-automode counters reset", "info");
985
+ return;
986
+ }
987
+ if (command === "defaults") {
988
+ ctx.ui.notify(
989
+ safeJson(
990
+ {
991
+ environment: DEFAULT_ENVIRONMENT,
992
+ allow: DEFAULT_ALLOW,
993
+ protectedPaths: DEFAULT_PROTECTED_PATHS,
994
+ soft_deny: DEFAULT_SOFT_DENY,
995
+ hard_deny: DEFAULT_HARD_DENY,
996
+ },
997
+ 12000,
998
+ ),
999
+ "info",
1000
+ );
1001
+ return;
1002
+ }
1003
+ if (command === "config") {
1004
+ const logFile = resolveLogPath(
1005
+ ctx.sessionManager.getSessionFile?.(),
1006
+ ctx.sessionManager.getSessionDir?.() ?? "",
1007
+ ctx.sessionManager.getSessionId?.() ?? "unknown",
1008
+ ctx.cwd,
1009
+ options.logRoot,
1010
+ now(),
1011
+ );
1012
+ ctx.ui.notify(
1013
+ safeJson(
1014
+ {
1015
+ config: effectiveConfig(),
1016
+ logFile,
1017
+ diagnostics: configDiagnostics,
1018
+ },
1019
+ 16000,
1020
+ ),
1021
+ configDiagnostics.length > 0 ? "warning" : "info",
1022
+ );
1023
+ return;
1024
+ }
1025
+ if (command === "denials") {
1026
+ ctx.ui.notify(
1027
+ formatDenials(state),
1028
+ state.recentDenials.length > 0 ? "warning" : "info",
1029
+ );
1030
+ return;
1031
+ }
1032
+ if (command === "model") {
1033
+ const selected = remainder || await promptForClassifierModel(
1034
+ ctx,
1035
+ effectiveConfig().classifierModel,
1036
+ );
1037
+ if (!selected) {
1038
+ ctx.ui.notify("Classifier model unchanged", "info");
1039
+ return;
1040
+ }
1041
+ let modelSpec: string;
1042
+ const jev = isJevClassifierModel(selected);
1043
+ if (jev) {
1044
+ // Jev bypasses Pi's model registry on both transports.
1045
+ if (!(await resolveJevApiKey(ctx, jev.transport))) {
1046
+ ctx.ui.notify(
1047
+ jev.transport === "typesafe"
1048
+ ? `${JEV_TYPESAFE_API_KEY_ENV} is not set and no typesafe provider key is registered`
1049
+ : `${JEV_API_KEY_ENV} is not set and no openrouter provider key is registered`,
1050
+ "error",
1051
+ );
1052
+ return;
1053
+ }
1054
+ modelSpec = selected;
1055
+ } else {
1056
+ const parsed = parseModelSpec(selected);
1057
+ const model = parsed
1058
+ ? ctx.modelRegistry.find(parsed.provider, parsed.id)
1059
+ : undefined;
1060
+ if (!model) {
1061
+ ctx.ui.notify(`Model not found: ${selected}`, "error");
1062
+ return;
1063
+ }
1064
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
1065
+ if (!auth.ok) {
1066
+ ctx.ui.notify(auth.error, "error");
1067
+ return;
1068
+ }
1069
+ modelSpec = formatModelSpec(model);
1070
+ }
1071
+ try {
1072
+ saveClassifierModel(modelSpec);
1073
+ } catch (error) {
1074
+ ctx.ui.notify(
1075
+ `Failed to save classifier model: ${
1076
+ error instanceof Error ? error.message : String(error)
1077
+ }`,
1078
+ "error",
1079
+ );
1080
+ return;
1081
+ }
1082
+ loadResult = loadConfigWithDiagnostics(
1083
+ ctx.cwd,
1084
+ projectIsTrusted(ctx),
1085
+ );
1086
+ config = loadResult.config;
1087
+ configDiagnostics = loadResult.diagnostics;
1088
+ persist();
1089
+ updateUi(ctx);
1090
+ const active = effectiveConfig().classifierModel ??
1091
+ "current session model";
1092
+ ctx.ui.notify(
1093
+ active === modelSpec
1094
+ ? `pi-automode classifier saved globally: ${modelSpec}`
1095
+ : `pi-automode classifier saved globally: ${modelSpec}; current config uses ${active}`,
1096
+ "info",
1097
+ );
1098
+ return;
1099
+ }
1100
+
1101
+ ctx.ui.notify(
1102
+ "Usage: /automode [status|on|off|reload|reset|defaults|config|denials|model [provider/id]]",
1103
+ "error",
1104
+ );
1105
+ }
1106
+
1107
+ pi.registerCommand("automode", {
1108
+ description:
1109
+ "Control pi-automode: status, on, off, reload, reset, defaults, config, denials, model",
1110
+ handler: handleAutomodeCommand,
1111
+ });
1112
+
1113
+ pi.registerCommand("auto-mode", {
1114
+ description: "Alias for /automode",
1115
+ handler: handleAutomodeCommand,
1116
+ });
1117
+ };
1118
+ }