@czottmann/pi-automode 1.12.0 → 1.14.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.
@@ -155,6 +155,55 @@ export type ClassifierCompletionPlan = {
155
155
  reasoningLevel?: Exclude<EffectiveClassifierReasoningLevel, "off">;
156
156
  };
157
157
 
158
+ async function completeClassifierAttempt(
159
+ completeFn: ClassifierCompletionFn,
160
+ model: Model<any>,
161
+ prompt: Parameters<ClassifierCompletionFn>[1],
162
+ parentSignal: AbortSignal | undefined,
163
+ options: Omit<Parameters<ClassifierCompletionFn>[2], "signal">,
164
+ ): Promise<AssistantMessage> {
165
+ if (options.timeoutMs === undefined) {
166
+ return completeFn(model, prompt, {
167
+ ...options,
168
+ ...(parentSignal === undefined ? {} : { signal: parentSignal }),
169
+ });
170
+ }
171
+
172
+ const controller = new AbortController();
173
+ const onParentAbort = () => controller.abort(parentSignal?.reason);
174
+ if (parentSignal?.aborted) onParentAbort();
175
+ else parentSignal?.addEventListener("abort", onParentAbort, { once: true });
176
+
177
+ let onAbort: (() => void) | undefined;
178
+ const aborted = new Promise<never>((_resolve, reject) => {
179
+ onAbort = () => {
180
+ const reason = controller.signal.reason;
181
+ reject(reason instanceof Error ? reason : new Error("Classifier request aborted."));
182
+ };
183
+ if (controller.signal.aborted) onAbort();
184
+ else controller.signal.addEventListener("abort", onAbort, { once: true });
185
+ });
186
+ const timer = setTimeout(() => {
187
+ controller.abort(
188
+ new Error(`Classifier request timed out after ${options.timeoutMs} ms.`),
189
+ );
190
+ }, options.timeoutMs);
191
+
192
+ try {
193
+ return await Promise.race([
194
+ completeFn(model, prompt, {
195
+ ...options,
196
+ signal: controller.signal,
197
+ }),
198
+ aborted,
199
+ ]);
200
+ } finally {
201
+ clearTimeout(timer);
202
+ if (onAbort) controller.signal.removeEventListener("abort", onAbort);
203
+ parentSignal?.removeEventListener("abort", onParentAbort);
204
+ }
205
+ }
206
+
158
207
  /**
159
208
  * Run normalized Pi AI completion through the provider in Pi's runtime registry.
160
209
  * This temporary bridge is only valid until Pi exposes
@@ -435,14 +484,15 @@ export async function classifyWithRetry(
435
484
  const started = Date.now();
436
485
  let response: AssistantMessage;
437
486
  try {
438
- response = await completeFn(
487
+ response = await completeClassifierAttempt(
488
+ completeFn,
439
489
  classifier.model,
440
490
  prompt,
491
+ signal,
441
492
  {
442
493
  apiKey: classifier.apiKey,
443
494
  headers: classifier.headers,
444
495
  env: classifier.env,
445
- signal,
446
496
  maxTokens,
447
497
  ...(temperature === undefined ? {} : { temperature }),
448
498
  ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
@@ -505,7 +555,8 @@ export async function classifyInStages(
505
555
  const fastStarted = Date.now();
506
556
  let fastResponse: AssistantMessage;
507
557
  try {
508
- fastResponse = await completeFn(
558
+ fastResponse = await completeClassifierAttempt(
559
+ completeFn,
509
560
  classifier.model,
510
561
  {
511
562
  systemPrompt: prompt.systemPrompt,
@@ -515,11 +566,11 @@ export async function classifyInStages(
515
566
  stageMessage(CLASSIFIER_FAST_INSTRUCTION),
516
567
  ],
517
568
  },
569
+ signal,
518
570
  {
519
571
  apiKey: classifier.apiKey,
520
572
  headers: classifier.headers,
521
573
  env: classifier.env,
522
- signal,
523
574
  // Reasoning and OpenAI-compatible models may consume hidden reasoning,
524
575
  // control, and EOS tokens before emitting the required visible digit.
525
576
  maxTokens: options.fastClassifierMaxTokens ??
@@ -1,4 +1,12 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import {
2
+ existsSync,
3
+ linkSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
2
10
  import { dirname, resolve } from "node:path";
3
11
  import {
4
12
  DEFAULT_ALLOW,
@@ -14,7 +22,9 @@ import {
14
22
  DEFAULT_MAX_USER_TRANSCRIPT_TOKENS,
15
23
  DEFAULT_PROTECTED_PATHS,
16
24
  DEFAULT_SOFT_DENY,
25
+ MAX_CLASSIFIER_TIMEOUT_MS,
17
26
  PI_GLOBAL_SETTINGS,
27
+ PI_LEGACY_GLOBAL_SETTINGS,
18
28
  PI_PROJECT_LOCAL_SETTINGS,
19
29
  PI_PROJECT_SHARED_SETTINGS,
20
30
  } from "./constants.ts";
@@ -35,6 +45,169 @@ import type {
35
45
  } from "./types.ts";
36
46
  import { hasOwn, stringArray } from "./utils.ts";
37
47
 
48
+ export type GlobalConfigPreparation = {
49
+ status: "current" | "migrated" | "conflict" | "failed";
50
+ activePath: string;
51
+ diagnostic?: string;
52
+ notification?: string;
53
+ writeBlockedReason?: string;
54
+ };
55
+
56
+ export type PrepareGlobalConfigOptions = {
57
+ currentPath?: string;
58
+ legacyPath?: string;
59
+ moveFile?: (source: string, destination: string) => void;
60
+ unlinkFile?: (path: string) => void;
61
+ };
62
+
63
+ function sameFileIdentity(firstPath: string, secondPath: string): boolean {
64
+ try {
65
+ const first = lstatSync(firstPath);
66
+ const second = lstatSync(secondPath);
67
+ return first.dev === second.dev && first.ino === second.ino;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ function cleanupPublishedDestination(
74
+ destination: string,
75
+ unlinkFile: (path: string) => void,
76
+ originalError: unknown,
77
+ ): AggregateError | undefined {
78
+ try {
79
+ unlinkFile(destination);
80
+ return undefined;
81
+ } catch (cleanupError) {
82
+ return new AggregateError(
83
+ [originalError, cleanupError],
84
+ `Could not clean up interrupted Auto Mode config migration at ${destination}`,
85
+ );
86
+ }
87
+ }
88
+
89
+ function moveFileWithoutOverwrite(
90
+ source: string,
91
+ destination: string,
92
+ unlinkFile: (path: string) => void,
93
+ ): void {
94
+ linkSync(source, destination);
95
+ try {
96
+ unlinkFile(source);
97
+ } catch (error) {
98
+ throw cleanupPublishedDestination(destination, unlinkFile, error) ?? error;
99
+ }
100
+ }
101
+
102
+ function globalConfigFailure(
103
+ currentPath: string,
104
+ legacyPath: string,
105
+ error: unknown,
106
+ writeBlockedReason?: string,
107
+ ): GlobalConfigPreparation {
108
+ const message =
109
+ `Could not move Auto Mode config from ${legacyPath} to ${currentPath}: ${
110
+ error instanceof Error ? error.message : String(error)
111
+ }. Using the legacy config for this session${
112
+ writeBlockedReason ? "; global config writes are disabled" : ""
113
+ }.`;
114
+ return {
115
+ status: "failed",
116
+ activePath: legacyPath,
117
+ diagnostic: message,
118
+ notification: message,
119
+ writeBlockedReason,
120
+ };
121
+ }
122
+
123
+ function globalConfigConflict(
124
+ currentPath: string,
125
+ legacyPath: string,
126
+ ): GlobalConfigPreparation {
127
+ const message =
128
+ `Auto Mode config conflict: using ${currentPath}; legacy config ${legacyPath} is ignored and was not changed.`;
129
+ return {
130
+ status: "conflict",
131
+ activePath: currentPath,
132
+ diagnostic: message,
133
+ notification: message,
134
+ };
135
+ }
136
+
137
+ /** Select one global config path for this runtime and migrate a legacy file when possible. */
138
+ export function prepareGlobalConfig(
139
+ options: PrepareGlobalConfigOptions = {},
140
+ ): GlobalConfigPreparation {
141
+ const currentPath = options.currentPath ?? PI_GLOBAL_SETTINGS[0];
142
+ const legacyPath = options.legacyPath ?? PI_LEGACY_GLOBAL_SETTINGS;
143
+ const currentExists = existsSync(currentPath);
144
+ const legacyExists = existsSync(legacyPath);
145
+ const unlinkFile = options.unlinkFile ?? unlinkSync;
146
+
147
+ if (currentExists && legacyExists) {
148
+ if (!sameFileIdentity(currentPath, legacyPath)) {
149
+ return globalConfigConflict(currentPath, legacyPath);
150
+ }
151
+ try {
152
+ unlinkFile(legacyPath);
153
+ return {
154
+ status: "migrated",
155
+ activePath: currentPath,
156
+ notification:
157
+ `Completed interrupted Auto Mode config migration from ${legacyPath} to ${currentPath}.`,
158
+ };
159
+ } catch (error) {
160
+ const cleanupError = cleanupPublishedDestination(
161
+ currentPath,
162
+ unlinkFile,
163
+ error,
164
+ );
165
+ return globalConfigFailure(
166
+ currentPath,
167
+ legacyPath,
168
+ cleanupError ?? error,
169
+ cleanupError?.message,
170
+ );
171
+ }
172
+ }
173
+ if (currentExists || !legacyExists) {
174
+ return { status: "current", activePath: currentPath };
175
+ }
176
+
177
+ try {
178
+ mkdirSync(dirname(currentPath), { recursive: true });
179
+ const moveFile = options.moveFile ??
180
+ ((source: string, destination: string) =>
181
+ moveFileWithoutOverwrite(source, destination, unlinkFile));
182
+ moveFile(legacyPath, currentPath);
183
+ return {
184
+ status: "migrated",
185
+ activePath: currentPath,
186
+ notification: `Moved Auto Mode config from ${legacyPath} to ${currentPath}.`,
187
+ };
188
+ } catch (error) {
189
+ if (existsSync(currentPath)) {
190
+ if (!sameFileIdentity(currentPath, legacyPath)) {
191
+ return globalConfigConflict(currentPath, legacyPath);
192
+ }
193
+ const cleanupError = cleanupPublishedDestination(
194
+ currentPath,
195
+ unlinkFile,
196
+ error,
197
+ );
198
+ if (cleanupError) {
199
+ return globalConfigFailure(
200
+ currentPath,
201
+ legacyPath,
202
+ cleanupError,
203
+ cleanupError.message,
204
+ );
205
+ }
206
+ }
207
+ return globalConfigFailure(currentPath, legacyPath, error);
208
+ }
209
+ }
210
+
38
211
  function readSettingsFile(path: string): LoadedSettingsFile | undefined {
39
212
  if (!existsSync(path)) return undefined;
40
213
  try {
@@ -152,10 +325,11 @@ export function validateSettingsFile(
152
325
  if (
153
326
  hasOwn(autoMode, "classifierTimeoutMs") &&
154
327
  (!Number.isInteger(autoMode.classifierTimeoutMs) ||
155
- (autoMode.classifierTimeoutMs as number) < 1000)
328
+ (autoMode.classifierTimeoutMs as number) < 1000 ||
329
+ (autoMode.classifierTimeoutMs as number) > MAX_CLASSIFIER_TIMEOUT_MS)
156
330
  ) {
157
331
  diagnostics.push(
158
- `${source}: autoMode.classifierTimeoutMs must be an integer of at least 1000`,
332
+ `${source}: autoMode.classifierTimeoutMs must be an integer from 1000 through ${MAX_CLASSIFIER_TIMEOUT_MS}`,
159
333
  );
160
334
  }
161
335
  if (
@@ -419,7 +593,9 @@ function validFastClassifierBudget(value: unknown): value is number {
419
593
  }
420
594
 
421
595
  function validClassifierTimeout(value: unknown): value is number {
422
- return Number.isInteger(value) && Number(value) >= 1000;
596
+ return Number.isInteger(value) &&
597
+ Number(value) >= 1000 &&
598
+ Number(value) <= MAX_CLASSIFIER_TIMEOUT_MS;
423
599
  }
424
600
 
425
601
  function applyAutoModeScalars(
@@ -611,6 +787,7 @@ function ignoredSharedAllowDiagnostics(
611
787
  export function loadEffectiveConfigWithDiagnostics(
612
788
  cwd: string,
613
789
  projectTrusted = false,
790
+ globalSettingsPath = PI_GLOBAL_SETTINGS[0],
614
791
  ): ConfigLoadResult {
615
792
  const inlineSettings: SettingsFile[] = [];
616
793
  const diagnostics: string[] = [];
@@ -632,7 +809,7 @@ export function loadEffectiveConfigWithDiagnostics(
632
809
  }
633
810
  }
634
811
 
635
- const globalFiles = PI_GLOBAL_SETTINGS.map(readSettingsFile);
812
+ const globalFiles = [readSettingsFile(globalSettingsPath)];
636
813
  const projectLocalPaths = PI_PROJECT_LOCAL_SETTINGS.map((file) =>
637
814
  resolve(cwd, file)
638
815
  );
@@ -682,8 +859,13 @@ export function loadEffectiveConfigWithDiagnostics(
682
859
  export function loadEffectiveConfig(
683
860
  cwd: string,
684
861
  projectTrusted = false,
862
+ globalSettingsPath = PI_GLOBAL_SETTINGS[0],
685
863
  ): EffectiveConfig {
686
- return loadEffectiveConfigWithDiagnostics(cwd, projectTrusted).config;
864
+ return loadEffectiveConfigWithDiagnostics(
865
+ cwd,
866
+ projectTrusted,
867
+ globalSettingsPath,
868
+ ).config;
687
869
  }
688
870
 
689
871
  function readWritableSettingsFile(path: string): SettingsFile {
@@ -61,6 +61,8 @@ export const DENIAL_HISTORY_LIMIT = 12;
61
61
 
62
62
  /** Per-request timeout for classifier completions (fast and detailed stages). */
63
63
  export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 20_000;
64
+ /** Largest timeout that Node can represent without reducing it to 1 ms. */
65
+ export const MAX_CLASSIFIER_TIMEOUT_MS = 2_147_483_647;
64
66
 
65
67
  /** Built-in trusted environment. Users extend this with `$defaults`. */
66
68
  export const DEFAULT_ENVIRONMENT = [
@@ -164,7 +166,10 @@ Valid decision/tier combinations:
164
166
  - block: hard_deny, soft_deny, or none
165
167
  If an allow exception or explicit user intent overrides a soft-deny rule, return allow with tier allow or explicit_intent, never soft_deny.`;
166
168
 
167
- export const PI_GLOBAL_SETTINGS = [resolve(HOME, ".pi/agent/automode.json")];
169
+ export const PI_GLOBAL_SETTINGS = [
170
+ resolve(HOME, ".pi/agent/extensions/pi-automode/config.json"),
171
+ ];
172
+ export const PI_LEGACY_GLOBAL_SETTINGS = resolve(HOME, ".pi/agent/automode.json");
168
173
  export const PI_PROJECT_LOCAL_SETTINGS = [".pi/automode.local.json"];
169
174
  export const PI_PROJECT_SHARED_SETTINGS = [".pi/automode.json"];
170
175
 
@@ -13,6 +13,7 @@ import {
13
13
  defaultClassifyAction,
14
14
  serializeClassifierAction,
15
15
  } from "./classifier.ts";
16
+ import { analyzeBash, type BashAnalysis } from "./bash.ts";
16
17
  import {
17
18
  AUTO_MODE_GUIDANCE,
18
19
  DEFAULT_ALLOW,
@@ -21,10 +22,13 @@ import {
21
22
  DEFAULT_PROTECTED_PATHS,
22
23
  DEFAULT_SOFT_DENY,
23
24
  PATH_BEARING_TOOLS,
25
+ PI_GLOBAL_SETTINGS,
24
26
  READ_ONLY_TOOLS,
25
27
  } from "./constants.ts";
26
28
  import {
29
+ type GlobalConfigPreparation,
27
30
  loadEffectiveConfigWithDiagnostics,
31
+ prepareGlobalConfig,
28
32
  writeGlobalClassifierModel,
29
33
  } from "./config.ts";
30
34
  import { deterministicHardDeny } from "./hard-deny.ts";
@@ -37,8 +41,10 @@ import {
37
41
  import { formatModelSpec, parseModelSpec } from "./model.ts";
38
42
  import { promptForClassifierModel } from "./model-selector.ts";
39
43
  import {
44
+ matchesAllowedToolPatterns,
40
45
  matchesDeniedPath,
41
46
  matchesToolPattern,
47
+ matchingBashCommandText,
42
48
  recursiveSearchMayReachDeniedPath,
43
49
  } from "./permissions.ts";
44
50
  import {
@@ -67,12 +73,16 @@ import type {
67
73
  DenialRecord,
68
74
  EffectiveConfig,
69
75
  } from "./types.ts";
70
- import { safeJson } from "./utils.ts";
76
+ import { safeJson, truncateMiddle } from "./utils.ts";
71
77
 
72
78
  const INSPECT_TOOL = "automode_inspect";
73
79
  const INSPECTION_ACTIONS = ["status", "config", "defaults", "denials"] as const;
74
80
  type InspectionAction = (typeof INSPECTION_ACTIONS)[number];
75
81
 
82
+ function matchedCommandSummary(command: string | undefined): string | undefined {
83
+ return command ? truncateMiddle(command, 500) : undefined;
84
+ }
85
+
76
86
  function canonicalPath(path: string): string {
77
87
  try {
78
88
  return realpathSync(path);
@@ -102,12 +112,16 @@ export type PiAutomodeOptions = {
102
112
  loadConfig?: (cwd: string, projectTrusted: boolean) => EffectiveConfig;
103
113
  /** Override classifier calls in tests so unit tests never need a real LLM/API key. */
104
114
  classifyAction?: ClassifyAction;
105
- /** 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. */
106
116
  saveClassifierModel?: (classifierModel: string) => void;
117
+ /** Override global config migration and path selection in tests. */
118
+ prepareGlobalConfig?: () => GlobalConfigPreparation;
107
119
  /** Override the application-owned observability log root in tests. */
108
120
  logRoot?: string;
109
121
  /** Override the observability log clock in tests. */
110
122
  now?: () => Date;
123
+ /** Override Bash analysis in tests. Runtime code uses unbash. */
124
+ analyzeBash?: typeof analyzeBash;
111
125
  };
112
126
 
113
127
  type LogCtx = {
@@ -157,18 +171,43 @@ function logClassifierIo(decision: ClassifyResult, log: LogCtx): void {
157
171
 
158
172
  /** Create a Pi extension instance. Default export uses production dependencies. */
159
173
  export function createPiAutomode(options: PiAutomodeOptions = {}) {
160
- const loadConfigWithDiagnostics = options.loadConfig
161
- ? (cwd: string, projectTrusted: boolean): ConfigLoadResult => ({
162
- config: options.loadConfig!(cwd, projectTrusted),
163
- diagnostics: [],
164
- })
165
- : loadEffectiveConfigWithDiagnostics;
166
174
  const classify = options.classifyAction ?? defaultClassifyAction;
167
- const saveClassifierModel = options.saveClassifierModel ??
168
- writeGlobalClassifierModel;
169
175
  const now = options.now ?? (() => new Date());
170
176
 
171
177
  return function piAutomode(pi: ExtensionAPI) {
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;
172
211
  let loadResult = loadConfigWithDiagnostics(process.cwd(), false);
173
212
  let config: EffectiveConfig = loadResult.config;
174
213
  let configDiagnostics: string[] = loadResult.diagnostics;
@@ -180,6 +219,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
180
219
  recentDenials: [],
181
220
  };
182
221
  let loadedContext = "";
222
+ let globalConfigNoticeShown = false;
183
223
 
184
224
  function effectiveConfig(): EffectiveConfig {
185
225
  return {
@@ -227,6 +267,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
227
267
  `classifier denied: ${state.classifierDenied}`,
228
268
  `permissions.deny rules: ${cfg.permissionDeny.length}`,
229
269
  `permissions.ask rules: ${cfg.permissionAsk.length}`,
270
+ `permissions.allow rules: ${cfg.permissionAllow.length}`,
230
271
  `environment entries: ${cfg.environment.length}`,
231
272
  `allow entries: ${cfg.allow.length}`,
232
273
  `soft_deny entries: ${cfg.softDeny.length}`,
@@ -357,6 +398,17 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
357
398
  config = loadResult.config;
358
399
  configDiagnostics = loadResult.diagnostics;
359
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
+ }
360
412
  updateUi(ctx);
361
413
  });
362
414
 
@@ -385,6 +437,27 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
385
437
  const isOwnedInspection = event.toolName === INSPECT_TOOL &&
386
438
  ownsInspectionTool();
387
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
+ }
388
461
  const summary = actionSummary(event.toolName, input);
389
462
  if (!isOwnedInspection) state.checkedActions += 1;
390
463
  const logCtx: LogCtx = {
@@ -404,12 +477,26 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
404
477
  };
405
478
 
406
479
  for (const pattern of cfg.permissionDeny) {
407
- 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
+ );
408
493
  if (isOwnedInspection) state.checkedActions += 1;
409
494
  return block(ctx, {
410
495
  timestamp: Date.now(),
411
496
  toolName: event.toolName,
412
- reason: `Blocked by permissions.deny: ${pattern.raw}`,
497
+ reason: `Blocked by permissions.deny: ${pattern.raw}${
498
+ matchedCommand ? `; matched command: ${matchedCommand}` : ""
499
+ }`,
413
500
  action: summary,
414
501
  kind: "permissions.deny",
415
502
  }, logCtx);
@@ -418,16 +505,30 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
418
505
 
419
506
  let askRequiresClassifier = false;
420
507
  for (const pattern of cfg.permissionAsk) {
421
- 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
+ ) {
422
518
  continue;
423
519
  }
424
520
  if (!ctx.hasUI) {
521
+ const matchedCommand = matchedCommandSummary(
522
+ matchingBashCommandText(pattern, bashAnalysis),
523
+ );
425
524
  if (isOwnedInspection) state.checkedActions += 1;
426
525
  return block(ctx, {
427
526
  timestamp: Date.now(),
428
527
  toolName: event.toolName,
429
528
  reason:
430
- `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`,
431
532
  action: summary,
432
533
  kind: "permissions.ask",
433
534
  }, logCtx);
@@ -438,11 +539,16 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
438
539
  { signal: ctx.signal },
439
540
  );
440
541
  if (!allowed) {
542
+ const matchedCommand = matchedCommandSummary(
543
+ matchingBashCommandText(pattern, bashAnalysis),
544
+ );
441
545
  if (isOwnedInspection) state.checkedActions += 1;
442
546
  return block(ctx, {
443
547
  timestamp: Date.now(),
444
548
  toolName: event.toolName,
445
- reason: `Declined permissions.ask: ${pattern.raw}`,
549
+ reason: `Declined permissions.ask: ${pattern.raw}${
550
+ matchedCommand ? `; matched command: ${matchedCommand}` : ""
551
+ }`,
446
552
  action: summary,
447
553
  kind: "permissions.ask",
448
554
  }, logCtx);
@@ -454,6 +560,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
454
560
  event.toolName,
455
561
  input,
456
562
  ctx.cwd,
563
+ bashAnalysis,
457
564
  );
458
565
  if (deterministicReason) {
459
566
  if (isOwnedInspection) state.checkedActions += 1;
@@ -564,20 +671,18 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
564
671
  // Deterministic allow tier. It runs after every deterministic denial.
565
672
  // Accepted ask rules skip this tier and always reach the classifier.
566
673
  if (!askRequiresClassifier) {
567
- for (const pattern of cfg.permissionAllow) {
568
- if (
569
- !matchesToolPattern(
570
- pattern,
571
- event.toolName,
572
- input,
573
- ctx.cwd,
574
- "no-match",
575
- )
576
- ) {
577
- continue;
578
- }
674
+ if (
675
+ matchesAllowedToolPatterns(
676
+ cfg.permissionAllow,
677
+ event.toolName,
678
+ input,
679
+ ctx.cwd,
680
+ bashAnalysis,
681
+ )
682
+ ) {
579
683
  // A protected-path write/edit is never covered by permissions.allow;
580
684
  // it stays on the classifier path (same rule as the inside-CWD tier).
685
+ let protectedWrite = false;
581
686
  if (event.toolName === "write" || event.toolName === "edit") {
582
687
  const inputPath = extractInputPath(event.toolName, input);
583
688
  const resolved = inputPath === undefined
@@ -588,17 +693,19 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
588
693
  resolved !== undefined &&
589
694
  isProtectedPath(resolved, ctx.cwd, cfg.protectedPaths)
590
695
  ) {
591
- break;
696
+ protectedWrite = true;
592
697
  }
593
698
  }
594
- return allow(
595
- ctx,
596
- "permissions.allow",
597
- `Allowed by permissions.allow: ${pattern.raw}`,
598
- event.toolName,
599
- summary,
600
- logCtx,
601
- );
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
+ }
602
709
  }
603
710
  }
604
711