@czottmann/pi-automode 1.11.0 → 1.13.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.
@@ -1,12 +1,19 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
1
4
  import type {
2
5
  ExtensionAPI,
3
6
  ExtensionCommandContext,
4
7
  ExtensionContext,
5
8
  } from "@earendil-works/pi-coding-agent";
9
+ import { StringEnum } from "@earendil-works/pi-ai";
10
+ import { Type } from "typebox";
6
11
  import {
7
12
  classifierReasoningForConfig,
8
13
  defaultClassifyAction,
14
+ serializeClassifierAction,
9
15
  } from "./classifier.ts";
16
+ import { analyzeBash, type BashAnalysis } from "./bash.ts";
10
17
  import {
11
18
  AUTO_MODE_GUIDANCE,
12
19
  DEFAULT_ALLOW,
@@ -15,11 +22,13 @@ import {
15
22
  DEFAULT_PROTECTED_PATHS,
16
23
  DEFAULT_SOFT_DENY,
17
24
  PATH_BEARING_TOOLS,
25
+ PI_GLOBAL_SETTINGS,
18
26
  READ_ONLY_TOOLS,
19
27
  } from "./constants.ts";
20
28
  import {
21
- loadEffectiveConfig,
29
+ type GlobalConfigPreparation,
22
30
  loadEffectiveConfigWithDiagnostics,
31
+ prepareGlobalConfig,
23
32
  writeGlobalClassifierModel,
24
33
  } from "./config.ts";
25
34
  import { deterministicHardDeny } from "./hard-deny.ts";
@@ -31,14 +40,19 @@ import {
31
40
  } from "./log.ts";
32
41
  import { formatModelSpec, parseModelSpec } from "./model.ts";
33
42
  import { promptForClassifierModel } from "./model-selector.ts";
34
- import { matchesDeniedPath, matchesToolPattern } from "./permissions.ts";
35
43
  import {
36
- expandHomePattern,
44
+ matchesAllowedToolPatterns,
45
+ matchesDeniedPath,
46
+ matchesToolPattern,
47
+ matchingBashCommandText,
48
+ recursiveSearchMayReachDeniedPath,
49
+ } from "./permissions.ts";
50
+ import {
37
51
  extractInputPath,
38
52
  isInside,
39
53
  isProtectedPath,
40
- resolveInputPath,
41
54
  resolvePathForPolicy,
55
+ resolveToolInputPath,
42
56
  } from "./paths.ts";
43
57
  import {
44
58
  actionSummary,
@@ -59,15 +73,55 @@ import type {
59
73
  DenialRecord,
60
74
  EffectiveConfig,
61
75
  } from "./types.ts";
62
- import { safeJson } from "./utils.ts";
76
+ import { safeJson, truncateMiddle } from "./utils.ts";
77
+
78
+ const INSPECT_TOOL = "automode_inspect";
79
+ const INSPECTION_ACTIONS = ["status", "config", "defaults", "denials"] as const;
80
+ type InspectionAction = (typeof INSPECTION_ACTIONS)[number];
81
+
82
+ function matchedCommandSummary(command: string | undefined): string | undefined {
83
+ return command ? truncateMiddle(command, 500) : undefined;
84
+ }
85
+
86
+ function canonicalPath(path: string): string {
87
+ try {
88
+ return realpathSync(path);
89
+ } catch {
90
+ return resolve(path);
91
+ }
92
+ }
93
+
94
+ const EXTENSION_PATH = canonicalPath(fileURLToPath(import.meta.url));
95
+ const EXTENSION_ENTRY_PATH = canonicalPath(
96
+ resolve(dirname(EXTENSION_PATH), "../auto-mode.ts"),
97
+ );
98
+
99
+ export function modelVisibleConfigDiagnostics(
100
+ diagnostics: string[],
101
+ ): string[] {
102
+ return diagnostics.map((diagnostic) =>
103
+ diagnostic.replace(
104
+ /invalid JSON \([\s\S]*\)$/,
105
+ "invalid JSON (parser details omitted from model-visible output)",
106
+ )
107
+ );
108
+ }
63
109
 
64
110
  export type PiAutomodeOptions = {
65
111
  /** Override config loading in tests. Runtime code uses Pi-owned disk settings. */
66
- loadConfig?: (cwd: string) => EffectiveConfig;
112
+ loadConfig?: (cwd: string, projectTrusted: boolean) => EffectiveConfig;
67
113
  /** Override classifier calls in tests so unit tests never need a real LLM/API key. */
68
114
  classifyAction?: ClassifyAction;
69
- /** Override classifier-model persistence in tests. Runtime code writes ~/.pi/agent/automode.json. */
115
+ /** Override classifier-model persistence in tests. Runtime code writes the active global config. */
70
116
  saveClassifierModel?: (classifierModel: string) => void;
117
+ /** Override global config migration and path selection in tests. */
118
+ prepareGlobalConfig?: () => GlobalConfigPreparation;
119
+ /** Override the application-owned observability log root in tests. */
120
+ logRoot?: string;
121
+ /** Override the observability log clock in tests. */
122
+ now?: () => Date;
123
+ /** Override Bash analysis in tests. Runtime code uses unbash. */
124
+ analyzeBash?: typeof analyzeBash;
71
125
  };
72
126
 
73
127
  type LogCtx = {
@@ -117,18 +171,44 @@ function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
117
171
 
118
172
  /** Create a Pi extension instance. Default export uses production dependencies. */
119
173
  export function createPiAutomode(options: PiAutomodeOptions = {}) {
120
- const loadConfigWithDiagnostics = options.loadConfig
121
- ? (cwd: string): ConfigLoadResult => ({
122
- config: options.loadConfig?.(cwd) ?? loadEffectiveConfig(cwd),
123
- diagnostics: [],
124
- })
125
- : loadEffectiveConfigWithDiagnostics;
126
174
  const classify = options.classifyAction ?? defaultClassifyAction;
127
- const saveClassifierModel = options.saveClassifierModel ??
128
- writeGlobalClassifierModel;
175
+ const now = options.now ?? (() => new Date());
129
176
 
130
177
  return function piAutomode(pi: ExtensionAPI) {
131
- let loadResult = loadConfigWithDiagnostics(process.cwd());
178
+ const globalConfig = options.prepareGlobalConfig?.() ??
179
+ (options.loadConfig
180
+ ? { status: "current" as const, activePath: PI_GLOBAL_SETTINGS[0] }
181
+ : prepareGlobalConfig());
182
+ const loadConfigWithDiagnostics = (
183
+ cwd: string,
184
+ projectTrusted: boolean,
185
+ ): ConfigLoadResult => {
186
+ const result = options.loadConfig
187
+ ? {
188
+ config: options.loadConfig(cwd, projectTrusted),
189
+ diagnostics: [],
190
+ }
191
+ : loadEffectiveConfigWithDiagnostics(
192
+ cwd,
193
+ projectTrusted,
194
+ globalConfig.activePath,
195
+ );
196
+ return globalConfig.diagnostic
197
+ ? {
198
+ ...result,
199
+ diagnostics: [...result.diagnostics, globalConfig.diagnostic],
200
+ }
201
+ : result;
202
+ };
203
+ const persistClassifierModel = options.saveClassifierModel ??
204
+ ((classifierModel: string) =>
205
+ writeGlobalClassifierModel(classifierModel, globalConfig.activePath));
206
+ const saveClassifierModel = globalConfig.writeBlockedReason
207
+ ? (_classifierModel: string) => {
208
+ throw new Error(globalConfig.writeBlockedReason);
209
+ }
210
+ : persistClassifierModel;
211
+ let loadResult = loadConfigWithDiagnostics(process.cwd(), false);
132
212
  let config: EffectiveConfig = loadResult.config;
133
213
  let configDiagnostics: string[] = loadResult.diagnostics;
134
214
  let state: AutoModeState = {
@@ -139,6 +219,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
139
219
  recentDenials: [],
140
220
  };
141
221
  let loadedContext = "";
222
+ let globalConfigNoticeShown = false;
142
223
 
143
224
  function effectiveConfig(): EffectiveConfig {
144
225
  return {
@@ -147,6 +228,13 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
147
228
  };
148
229
  }
149
230
 
231
+ function ownsInspectionTool(): boolean {
232
+ const tool = pi.getAllTools().find(({ name }) => name === INSPECT_TOOL);
233
+ if (!tool) return false;
234
+ const sourcePath = canonicalPath(tool.sourceInfo.path);
235
+ return sourcePath === EXTENSION_PATH || sourcePath === EXTENSION_ENTRY_PATH;
236
+ }
237
+
150
238
  function persist(): void {
151
239
  pi.appendEntry("pi-automode-state", state);
152
240
  }
@@ -163,6 +251,78 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
163
251
  );
164
252
  }
165
253
 
254
+ function inspectAutomode(
255
+ action: InspectionAction,
256
+ ctx: ExtensionContext,
257
+ ): unknown {
258
+ const cfg = effectiveConfig();
259
+ if (action === "status") {
260
+ const status = [
261
+ `enabled: ${cfg.enabled ? "yes" : "no"}`,
262
+ `classifier: ${cfg.classifierModel ?? "current session model"}`,
263
+ `classifier reasoning: ${cfg.classifierReasoningLevel ?? "server default"}`,
264
+ `checked actions: ${state.checkedActions}`,
265
+ `blocked actions: ${state.blockedActions}`,
266
+ `classifier allowed: ${state.classifierAllowed}`,
267
+ `classifier denied: ${state.classifierDenied}`,
268
+ `permissions.deny rules: ${cfg.permissionDeny.length}`,
269
+ `permissions.ask rules: ${cfg.permissionAsk.length}`,
270
+ `permissions.allow rules: ${cfg.permissionAllow.length}`,
271
+ `environment entries: ${cfg.environment.length}`,
272
+ `allow entries: ${cfg.allow.length}`,
273
+ `soft_deny entries: ${cfg.softDeny.length}`,
274
+ `hard_deny entries: ${cfg.hardDeny.length}`,
275
+ `last decision: ${state.lastDecision ?? "none"}`,
276
+ "last reason: omitted from model-visible inspection",
277
+ ].join("\n");
278
+ return {
279
+ status,
280
+ state: {
281
+ enabledOverride: state.enabledOverride,
282
+ lastDecision: state.lastDecision,
283
+ checkedActions: state.checkedActions,
284
+ blockedActions: state.blockedActions,
285
+ classifierAllowed: state.classifierAllowed,
286
+ classifierDenied: state.classifierDenied,
287
+ },
288
+ };
289
+ }
290
+ if (action === "config") {
291
+ return {
292
+ config: cfg,
293
+ logFile: resolveLogPath(
294
+ ctx.sessionManager.getSessionFile?.(),
295
+ ctx.sessionManager.getSessionDir?.() ?? "",
296
+ ctx.sessionManager.getSessionId?.() ?? "unknown",
297
+ ctx.cwd,
298
+ options.logRoot,
299
+ now(),
300
+ ),
301
+ diagnostics: modelVisibleConfigDiagnostics(configDiagnostics),
302
+ };
303
+ }
304
+ if (action === "defaults") {
305
+ return {
306
+ environment: DEFAULT_ENVIRONMENT,
307
+ allow: DEFAULT_ALLOW,
308
+ protectedPaths: DEFAULT_PROTECTED_PATHS,
309
+ soft_deny: DEFAULT_SOFT_DENY,
310
+ hard_deny: DEFAULT_HARD_DENY,
311
+ };
312
+ }
313
+ const denials = state.recentDenials.slice().reverse().map((denial) => ({
314
+ timestamp: denial.timestamp,
315
+ kind: denial.kind,
316
+ toolName: denial.toolName,
317
+ }));
318
+ return {
319
+ summary: denials.length === 0
320
+ ? "No recent auto-mode denials."
321
+ : `${denials.length} recent auto-mode denial(s). Reasons and action payloads are omitted.`,
322
+ denials,
323
+ };
324
+ }
325
+
166
326
  function block(
167
327
  ctx: ExtensionContext,
168
328
  denial: DenialRecord,
@@ -231,10 +391,24 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
231
391
  }
232
392
 
233
393
  pi.on("session_start", (_event, ctx) => {
234
- loadResult = loadConfigWithDiagnostics(ctx.cwd);
394
+ loadResult = loadConfigWithDiagnostics(
395
+ ctx.cwd,
396
+ ctx.isProjectTrusted(),
397
+ );
235
398
  config = loadResult.config;
236
399
  configDiagnostics = loadResult.diagnostics;
237
400
  state = restoreState(ctx);
401
+ if (
402
+ ctx.hasUI &&
403
+ globalConfig.notification &&
404
+ !globalConfigNoticeShown
405
+ ) {
406
+ ctx.ui.notify(
407
+ globalConfig.notification,
408
+ globalConfig.status === "migrated" ? "info" : "warning",
409
+ );
410
+ globalConfigNoticeShown = true;
411
+ }
238
412
  updateUi(ctx);
239
413
  });
240
414
 
@@ -251,22 +425,51 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
251
425
  // Enforcement order:
252
426
  // 1. permission deny/ask rules,
253
427
  // 2. deterministic hard-deny checks that never consult the model,
254
- // 3. read-only built-in fast path (skipped when classifyReadOnlyTools is set),
255
- // 4. classifier for every remaining action, fail-closed on setup/parse errors.
428
+ // 3. extension-owned read-only inspection tool,
429
+ // 4. deterministic path denials,
430
+ // 5. accepted ask rules force classifier review and skip all allow tiers,
431
+ // 6. inside-CWD, permissions.allow, and read-only allow tiers,
432
+ // 7. classifier for every remaining action, fail-closed on setup/parse errors.
256
433
  const cfg = effectiveConfig();
257
434
  if (!cfg.enabled) return undefined;
258
435
  if (ctx.signal?.aborted) return { block: true, reason: "Cancelled" };
259
436
 
437
+ const isOwnedInspection = event.toolName === INSPECT_TOOL &&
438
+ ownsInspectionTool();
260
439
  const input = event.input as Record<string, unknown>;
440
+ let bashAnalysis: BashAnalysis | undefined;
441
+ if (event.toolName === "bash") {
442
+ const source = typeof input.command === "string" ? input.command : "";
443
+ try {
444
+ bashAnalysis = (options.analyzeBash ?? analyzeBash)(source);
445
+ } catch (error) {
446
+ bashAnalysis = {
447
+ source,
448
+ commands: [],
449
+ redirects: [],
450
+ redirectTargets: [],
451
+ structure: [],
452
+ allowStructureSafe: false,
453
+ errors: [{
454
+ message: `Bash analysis failed: ${
455
+ error instanceof Error ? error.message : String(error)
456
+ }`,
457
+ }],
458
+ };
459
+ }
460
+ }
261
461
  const summary = actionSummary(event.toolName, input);
262
- state.checkedActions += 1;
462
+ if (!isOwnedInspection) state.checkedActions += 1;
263
463
  const logCtx: LogCtx = {
264
464
  logger: createLogger({
265
465
  enabled: cfg.log.enabled,
266
466
  classifierIo: cfg.log.classifierIo,
267
467
  sessionFile: ctx.sessionManager.getSessionFile?.(),
268
- sessionDir: ctx.sessionManager.getSessionDir?.() ?? ctx.cwd,
468
+ sessionDir: ctx.sessionManager.getSessionDir?.() ?? "",
469
+ sessionCwd: ctx.cwd,
269
470
  sessionId: ctx.sessionManager.getSessionId?.() ?? "unknown",
471
+ logRoot: options.logRoot,
472
+ now: now(),
270
473
  }),
271
474
  decisionId: newDecisionId(),
272
475
  classifierModel: cfg.classifierModel,
@@ -274,27 +477,58 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
274
477
  };
275
478
 
276
479
  for (const pattern of cfg.permissionDeny) {
277
- if (matchesToolPattern(pattern, event.toolName, input, ctx.cwd)) {
480
+ if (
481
+ matchesToolPattern(
482
+ pattern,
483
+ event.toolName,
484
+ input,
485
+ ctx.cwd,
486
+ "match",
487
+ bashAnalysis,
488
+ )
489
+ ) {
490
+ const matchedCommand = matchedCommandSummary(
491
+ matchingBashCommandText(pattern, bashAnalysis),
492
+ );
493
+ if (isOwnedInspection) state.checkedActions += 1;
278
494
  return block(ctx, {
279
495
  timestamp: Date.now(),
280
496
  toolName: event.toolName,
281
- reason: `Blocked by permissions.deny: ${pattern.raw}`,
497
+ reason: `Blocked by permissions.deny: ${pattern.raw}${
498
+ matchedCommand ? `; matched command: ${matchedCommand}` : ""
499
+ }`,
282
500
  action: summary,
283
501
  kind: "permissions.deny",
284
502
  }, logCtx);
285
503
  }
286
504
  }
287
505
 
506
+ let askRequiresClassifier = false;
288
507
  for (const pattern of cfg.permissionAsk) {
289
- if (!matchesToolPattern(pattern, event.toolName, input, ctx.cwd)) {
508
+ if (
509
+ !matchesToolPattern(
510
+ pattern,
511
+ event.toolName,
512
+ input,
513
+ ctx.cwd,
514
+ "match",
515
+ bashAnalysis,
516
+ )
517
+ ) {
290
518
  continue;
291
519
  }
292
520
  if (!ctx.hasUI) {
521
+ const matchedCommand = matchedCommandSummary(
522
+ matchingBashCommandText(pattern, bashAnalysis),
523
+ );
524
+ if (isOwnedInspection) state.checkedActions += 1;
293
525
  return block(ctx, {
294
526
  timestamp: Date.now(),
295
527
  toolName: event.toolName,
296
528
  reason:
297
- `Matched permissions.ask (${pattern.raw}) but no UI is available`,
529
+ `Matched permissions.ask (${pattern.raw})${
530
+ matchedCommand ? ` for command: ${matchedCommand}` : ""
531
+ } but no UI is available`,
298
532
  action: summary,
299
533
  kind: "permissions.ask",
300
534
  }, logCtx);
@@ -305,22 +539,31 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
305
539
  { signal: ctx.signal },
306
540
  );
307
541
  if (!allowed) {
542
+ const matchedCommand = matchedCommandSummary(
543
+ matchingBashCommandText(pattern, bashAnalysis),
544
+ );
545
+ if (isOwnedInspection) state.checkedActions += 1;
308
546
  return block(ctx, {
309
547
  timestamp: Date.now(),
310
548
  toolName: event.toolName,
311
- reason: `Declined permissions.ask: ${pattern.raw}`,
549
+ reason: `Declined permissions.ask: ${pattern.raw}${
550
+ matchedCommand ? `; matched command: ${matchedCommand}` : ""
551
+ }`,
312
552
  action: summary,
313
553
  kind: "permissions.ask",
314
554
  }, logCtx);
315
555
  }
556
+ askRequiresClassifier = true;
316
557
  }
317
558
 
318
559
  const deterministicReason = deterministicHardDeny(
319
560
  event.toolName,
320
561
  input,
321
562
  ctx.cwd,
563
+ bashAnalysis,
322
564
  );
323
565
  if (deterministicReason) {
566
+ if (isOwnedInspection) state.checkedActions += 1;
324
567
  return block(ctx, {
325
568
  timestamp: Date.now(),
326
569
  toolName: event.toolName,
@@ -330,6 +573,9 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
330
573
  }, logCtx);
331
574
  }
332
575
 
576
+ if (isOwnedInspection && !askRequiresClassifier) return undefined;
577
+ if (isOwnedInspection) state.checkedActions += 1;
578
+
333
579
  // Deterministic path gate for file tools.
334
580
  //
335
581
  // `deniedPaths` always applies: a matching path is hard-denied before any
@@ -343,15 +589,18 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
343
589
  // The gate is skipped entirely when both features are off, so the
344
590
  // default configuration costs no extra filesystem calls.
345
591
  let readOnlyFastPath =
346
- !cfg.classifyReadOnlyTools && READ_ONLY_TOOLS.has(event.toolName);
592
+ !askRequiresClassifier &&
593
+ !cfg.classifyReadOnlyTools &&
594
+ READ_ONLY_TOOLS.has(event.toolName);
347
595
  if (
348
596
  (cfg.deniedPaths.length > 0 || cfg.allowInsideWorkingDirectory) &&
349
597
  PATH_BEARING_TOOLS.has(event.toolName)
350
598
  ) {
351
599
  const inputPath = extractInputPath(event.toolName, input);
352
600
  if (inputPath !== undefined) {
353
- const expanded = expandHomePattern(inputPath);
354
- const resolved = resolveInputPath(ctx.cwd, expanded) ?? expanded;
601
+ const resolved =
602
+ resolveToolInputPath(event.toolName, ctx.cwd, inputPath) ??
603
+ inputPath;
355
604
  const policyPath = resolvePathForPolicy(resolved) ?? resolved;
356
605
  const denied =
357
606
  cfg.deniedPaths.length > 0 &&
@@ -366,19 +615,42 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
366
615
  kind: "deterministic-path-deny",
367
616
  }, logCtx);
368
617
  }
618
+ let recursiveSearch =
619
+ event.toolName === "grep" || event.toolName === "find";
620
+ if (recursiveSearch) {
621
+ try {
622
+ recursiveSearch = statSync(policyPath).isDirectory();
623
+ } catch {
624
+ // A missing search root will fail in the tool. Treat it as a
625
+ // directory here so a denied scope cannot fail open in a race.
626
+ }
627
+ }
628
+ const deniedSearchScope =
629
+ recursiveSearch &&
630
+ cfg.deniedPaths.length > 0 &&
631
+ (recursiveSearchMayReachDeniedPath(resolved, cfg.deniedPaths) ||
632
+ recursiveSearchMayReachDeniedPath(
633
+ policyPath,
634
+ cfg.deniedPaths,
635
+ ));
636
+ if (deniedSearchScope) {
637
+ return block(ctx, {
638
+ timestamp: Date.now(),
639
+ toolName: event.toolName,
640
+ reason: `Search scope can contain a path denied by policy: ${policyPath}`,
641
+ action: summary,
642
+ kind: "deterministic-path-deny",
643
+ }, logCtx);
644
+ }
369
645
  if (cfg.allowInsideWorkingDirectory) {
370
646
  const policyCwd = resolvePathForPolicy(ctx.cwd) ?? ctx.cwd;
371
647
  if (isInside(policyPath, policyCwd)) {
372
- // Protected in-tree writes must still reach the classifier;
373
- // otherwise the allow tier bypasses the protected-path policy
374
- // for sensitive repository content such as .git/hooks/*, .pi/*,
375
- // .husky/*, or .gitignore.
376
- if (
648
+ // Protected in-tree writes and accepted ask rules must still
649
+ // reach the classifier. They cannot use the inside-CWD tier.
650
+ const protectedWrite =
377
651
  (event.toolName === "write" || event.toolName === "edit") &&
378
- isProtectedPath(policyPath, policyCwd, cfg.protectedPaths)
379
- ) {
380
- readOnlyFastPath = false;
381
- } else {
652
+ isProtectedPath(policyPath, policyCwd, cfg.protectedPaths);
653
+ if (!askRequiresClassifier && !protectedWrite) {
382
654
  return allow(
383
655
  ctx,
384
656
  "inside-working-directory",
@@ -389,13 +661,54 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
389
661
  );
390
662
  }
391
663
  }
392
- // Outside the working directory: the read-only fast path must not
393
- // apply; the classifier reviews this call.
664
+ // Outside the working directory, protected writes, and accepted
665
+ // ask rules must not use the read-only fast path.
394
666
  readOnlyFastPath = false;
395
667
  }
396
668
  }
397
669
  }
398
670
 
671
+ // Deterministic allow tier. It runs after every deterministic denial.
672
+ // Accepted ask rules skip this tier and always reach the classifier.
673
+ if (!askRequiresClassifier) {
674
+ if (
675
+ matchesAllowedToolPatterns(
676
+ cfg.permissionAllow,
677
+ event.toolName,
678
+ input,
679
+ ctx.cwd,
680
+ bashAnalysis,
681
+ )
682
+ ) {
683
+ // A protected-path write/edit is never covered by permissions.allow;
684
+ // it stays on the classifier path (same rule as the inside-CWD tier).
685
+ let protectedWrite = false;
686
+ if (event.toolName === "write" || event.toolName === "edit") {
687
+ const inputPath = extractInputPath(event.toolName, input);
688
+ const resolved = inputPath === undefined
689
+ ? undefined
690
+ : resolveToolInputPath(event.toolName, ctx.cwd, inputPath) ??
691
+ inputPath;
692
+ if (
693
+ resolved !== undefined &&
694
+ isProtectedPath(resolved, ctx.cwd, cfg.protectedPaths)
695
+ ) {
696
+ protectedWrite = true;
697
+ }
698
+ }
699
+ if (!protectedWrite) {
700
+ return allow(
701
+ ctx,
702
+ "permissions.allow",
703
+ "Allowed by permissions.allow",
704
+ event.toolName,
705
+ summary,
706
+ logCtx,
707
+ );
708
+ }
709
+ }
710
+ }
711
+
399
712
  if (readOnlyFastPath) {
400
713
  return allow(
401
714
  ctx,
@@ -407,7 +720,12 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
407
720
  );
408
721
  }
409
722
 
410
- const decision = await classify(ctx, cfg, summary, loadedContext);
723
+ const decision = await classify(
724
+ ctx,
725
+ cfg,
726
+ serializeClassifierAction(event.toolName, input),
727
+ loadedContext,
728
+ );
411
729
  logClassifierIo(decision, logCtx);
412
730
  if (decision.decision === "allow") {
413
731
  state.classifierAllowed += 1;
@@ -431,6 +749,28 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
431
749
  }, logCtx);
432
750
  });
433
751
 
752
+ pi.registerTool({
753
+ name: INSPECT_TOOL,
754
+ label: "Inspect Auto Mode",
755
+ description:
756
+ "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.",
757
+ promptSnippet:
758
+ "Inspect active pi-automode state and diagnostic information without changing it",
759
+ parameters: Type.Object({
760
+ action: StringEnum(INSPECTION_ACTIONS, {
761
+ description: "The read-only auto-mode view to return",
762
+ }),
763
+ }),
764
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
765
+ if (signal?.aborted) throw new Error("Auto-mode inspection cancelled");
766
+ const result = inspectAutomode(params.action, ctx);
767
+ return {
768
+ content: [{ type: "text", text: safeJson(result, 16000) }],
769
+ details: result,
770
+ };
771
+ },
772
+ });
773
+
434
774
  async function handleAutomodeCommand(
435
775
  args: string,
436
776
  ctx: ExtensionCommandContext,
@@ -460,7 +800,10 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
460
800
  return;
461
801
  }
462
802
  if (command === "reload") {
463
- loadResult = loadConfigWithDiagnostics(ctx.cwd);
803
+ loadResult = loadConfigWithDiagnostics(
804
+ ctx.cwd,
805
+ ctx.isProjectTrusted(),
806
+ );
464
807
  config = loadResult.config;
465
808
  configDiagnostics = loadResult.diagnostics;
466
809
  persist();
@@ -504,8 +847,11 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
504
847
  if (command === "config") {
505
848
  const logFile = resolveLogPath(
506
849
  ctx.sessionManager.getSessionFile?.(),
507
- ctx.sessionManager.getSessionDir?.() ?? ctx.cwd,
850
+ ctx.sessionManager.getSessionDir?.() ?? "",
508
851
  ctx.sessionManager.getSessionId?.() ?? "unknown",
852
+ ctx.cwd,
853
+ options.logRoot,
854
+ now(),
509
855
  );
510
856
  ctx.ui.notify(
511
857
  safeJson(
@@ -561,7 +907,10 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
561
907
  );
562
908
  return;
563
909
  }
564
- loadResult = loadConfigWithDiagnostics(ctx.cwd);
910
+ loadResult = loadConfigWithDiagnostics(
911
+ ctx.cwd,
912
+ ctx.isProjectTrusted(),
913
+ );
565
914
  config = loadResult.config;
566
915
  configDiagnostics = loadResult.diagnostics;
567
916
  persist();