@czottmann/pi-automode 1.12.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,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,
@@ -15,6 +23,7 @@ import {
15
23
  DEFAULT_PROTECTED_PATHS,
16
24
  DEFAULT_SOFT_DENY,
17
25
  PI_GLOBAL_SETTINGS,
26
+ PI_LEGACY_GLOBAL_SETTINGS,
18
27
  PI_PROJECT_LOCAL_SETTINGS,
19
28
  PI_PROJECT_SHARED_SETTINGS,
20
29
  } from "./constants.ts";
@@ -35,6 +44,169 @@ import type {
35
44
  } from "./types.ts";
36
45
  import { hasOwn, stringArray } from "./utils.ts";
37
46
 
47
+ export type GlobalConfigPreparation = {
48
+ status: "current" | "migrated" | "conflict" | "failed";
49
+ activePath: string;
50
+ diagnostic?: string;
51
+ notification?: string;
52
+ writeBlockedReason?: string;
53
+ };
54
+
55
+ export type PrepareGlobalConfigOptions = {
56
+ currentPath?: string;
57
+ legacyPath?: string;
58
+ moveFile?: (source: string, destination: string) => void;
59
+ unlinkFile?: (path: string) => void;
60
+ };
61
+
62
+ function sameFileIdentity(firstPath: string, secondPath: string): boolean {
63
+ try {
64
+ const first = lstatSync(firstPath);
65
+ const second = lstatSync(secondPath);
66
+ return first.dev === second.dev && first.ino === second.ino;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function cleanupPublishedDestination(
73
+ destination: string,
74
+ unlinkFile: (path: string) => void,
75
+ originalError: unknown,
76
+ ): AggregateError | undefined {
77
+ try {
78
+ unlinkFile(destination);
79
+ return undefined;
80
+ } catch (cleanupError) {
81
+ return new AggregateError(
82
+ [originalError, cleanupError],
83
+ `Could not clean up interrupted Auto Mode config migration at ${destination}`,
84
+ );
85
+ }
86
+ }
87
+
88
+ function moveFileWithoutOverwrite(
89
+ source: string,
90
+ destination: string,
91
+ unlinkFile: (path: string) => void,
92
+ ): void {
93
+ linkSync(source, destination);
94
+ try {
95
+ unlinkFile(source);
96
+ } catch (error) {
97
+ throw cleanupPublishedDestination(destination, unlinkFile, error) ?? error;
98
+ }
99
+ }
100
+
101
+ function globalConfigFailure(
102
+ currentPath: string,
103
+ legacyPath: string,
104
+ error: unknown,
105
+ writeBlockedReason?: string,
106
+ ): GlobalConfigPreparation {
107
+ const message =
108
+ `Could not move Auto Mode config from ${legacyPath} to ${currentPath}: ${
109
+ error instanceof Error ? error.message : String(error)
110
+ }. Using the legacy config for this session${
111
+ writeBlockedReason ? "; global config writes are disabled" : ""
112
+ }.`;
113
+ return {
114
+ status: "failed",
115
+ activePath: legacyPath,
116
+ diagnostic: message,
117
+ notification: message,
118
+ writeBlockedReason,
119
+ };
120
+ }
121
+
122
+ function globalConfigConflict(
123
+ currentPath: string,
124
+ legacyPath: string,
125
+ ): GlobalConfigPreparation {
126
+ const message =
127
+ `Auto Mode config conflict: using ${currentPath}; legacy config ${legacyPath} is ignored and was not changed.`;
128
+ return {
129
+ status: "conflict",
130
+ activePath: currentPath,
131
+ diagnostic: message,
132
+ notification: message,
133
+ };
134
+ }
135
+
136
+ /** Select one global config path for this runtime and migrate a legacy file when possible. */
137
+ export function prepareGlobalConfig(
138
+ options: PrepareGlobalConfigOptions = {},
139
+ ): GlobalConfigPreparation {
140
+ const currentPath = options.currentPath ?? PI_GLOBAL_SETTINGS[0];
141
+ const legacyPath = options.legacyPath ?? PI_LEGACY_GLOBAL_SETTINGS;
142
+ const currentExists = existsSync(currentPath);
143
+ const legacyExists = existsSync(legacyPath);
144
+ const unlinkFile = options.unlinkFile ?? unlinkSync;
145
+
146
+ if (currentExists && legacyExists) {
147
+ if (!sameFileIdentity(currentPath, legacyPath)) {
148
+ return globalConfigConflict(currentPath, legacyPath);
149
+ }
150
+ try {
151
+ unlinkFile(legacyPath);
152
+ return {
153
+ status: "migrated",
154
+ activePath: currentPath,
155
+ notification:
156
+ `Completed interrupted Auto Mode config migration from ${legacyPath} to ${currentPath}.`,
157
+ };
158
+ } catch (error) {
159
+ const cleanupError = cleanupPublishedDestination(
160
+ currentPath,
161
+ unlinkFile,
162
+ error,
163
+ );
164
+ return globalConfigFailure(
165
+ currentPath,
166
+ legacyPath,
167
+ cleanupError ?? error,
168
+ cleanupError?.message,
169
+ );
170
+ }
171
+ }
172
+ if (currentExists || !legacyExists) {
173
+ return { status: "current", activePath: currentPath };
174
+ }
175
+
176
+ try {
177
+ mkdirSync(dirname(currentPath), { recursive: true });
178
+ const moveFile = options.moveFile ??
179
+ ((source: string, destination: string) =>
180
+ moveFileWithoutOverwrite(source, destination, unlinkFile));
181
+ moveFile(legacyPath, currentPath);
182
+ return {
183
+ status: "migrated",
184
+ activePath: currentPath,
185
+ notification: `Moved Auto Mode config from ${legacyPath} to ${currentPath}.`,
186
+ };
187
+ } catch (error) {
188
+ if (existsSync(currentPath)) {
189
+ if (!sameFileIdentity(currentPath, legacyPath)) {
190
+ return globalConfigConflict(currentPath, legacyPath);
191
+ }
192
+ const cleanupError = cleanupPublishedDestination(
193
+ currentPath,
194
+ unlinkFile,
195
+ error,
196
+ );
197
+ if (cleanupError) {
198
+ return globalConfigFailure(
199
+ currentPath,
200
+ legacyPath,
201
+ cleanupError,
202
+ cleanupError.message,
203
+ );
204
+ }
205
+ }
206
+ return globalConfigFailure(currentPath, legacyPath, error);
207
+ }
208
+ }
209
+
38
210
  function readSettingsFile(path: string): LoadedSettingsFile | undefined {
39
211
  if (!existsSync(path)) return undefined;
40
212
  try {
@@ -611,6 +783,7 @@ function ignoredSharedAllowDiagnostics(
611
783
  export function loadEffectiveConfigWithDiagnostics(
612
784
  cwd: string,
613
785
  projectTrusted = false,
786
+ globalSettingsPath = PI_GLOBAL_SETTINGS[0],
614
787
  ): ConfigLoadResult {
615
788
  const inlineSettings: SettingsFile[] = [];
616
789
  const diagnostics: string[] = [];
@@ -632,7 +805,7 @@ export function loadEffectiveConfigWithDiagnostics(
632
805
  }
633
806
  }
634
807
 
635
- const globalFiles = PI_GLOBAL_SETTINGS.map(readSettingsFile);
808
+ const globalFiles = [readSettingsFile(globalSettingsPath)];
636
809
  const projectLocalPaths = PI_PROJECT_LOCAL_SETTINGS.map((file) =>
637
810
  resolve(cwd, file)
638
811
  );
@@ -682,8 +855,13 @@ export function loadEffectiveConfigWithDiagnostics(
682
855
  export function loadEffectiveConfig(
683
856
  cwd: string,
684
857
  projectTrusted = false,
858
+ globalSettingsPath = PI_GLOBAL_SETTINGS[0],
685
859
  ): EffectiveConfig {
686
- return loadEffectiveConfigWithDiagnostics(cwd, projectTrusted).config;
860
+ return loadEffectiveConfigWithDiagnostics(
861
+ cwd,
862
+ projectTrusted,
863
+ globalSettingsPath,
864
+ ).config;
687
865
  }
688
866
 
689
867
  function readWritableSettingsFile(path: string): SettingsFile {
@@ -164,7 +164,10 @@ Valid decision/tier combinations:
164
164
  - block: hard_deny, soft_deny, or none
165
165
  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
166
 
167
- export const PI_GLOBAL_SETTINGS = [resolve(HOME, ".pi/agent/automode.json")];
167
+ export const PI_GLOBAL_SETTINGS = [
168
+ resolve(HOME, ".pi/agent/extensions/pi-automode/config.json"),
169
+ ];
170
+ export const PI_LEGACY_GLOBAL_SETTINGS = resolve(HOME, ".pi/agent/automode.json");
168
171
  export const PI_PROJECT_LOCAL_SETTINGS = [".pi/automode.local.json"];
169
172
  export const PI_PROJECT_SHARED_SETTINGS = [".pi/automode.json"];
170
173
 
@@ -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