@czottmann/pi-automode 1.0.0 → 1.1.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.
package/README.md CHANGED
@@ -66,6 +66,7 @@ Example:
66
66
  "Trusted internal domains: *.corp.example.com"
67
67
  ],
68
68
  "allow": ["$defaults"],
69
+ "protectedPaths": ["$defaults"],
69
70
  "soft_deny": ["$defaults"],
70
71
  "hard_deny": [
71
72
  "$defaults",
@@ -106,6 +107,16 @@ Example:
106
107
 
107
108
  These are exceptions to `soft_deny`, not to `hard_deny`.
108
109
 
110
+ #### `protectedPaths`
111
+
112
+ `$defaults` expands to paths where writes are never auto-approved — they always go to the classifier, regardless of `allow` rules. This matches Claude Code's protected-paths behavior.
113
+
114
+ Protected directories: `.git`, `.config/git`, `.vscode`, `.idea`, `.husky`, `.cargo`, `.devcontainer`, `.yarn`, `.mvn`, `.pi`.
115
+
116
+ Protected files: `.gitconfig`, `.gitmodules`, `.gitignore`, `.gitattributes`, shell profiles (`.bashrc`, `.zshrc`, `.profile`, etc.), `.envrc`, package manager configs (`.npmrc`, `.yarnrc`, `.yarnrc.yml`, `.pnp.cjs`, `bunfig.toml`, etc.), Bazel configs (`.bazelrc`, `.bazelversion`, `.bazeliskrc`), hook configs (`.pre-commit-config.yaml`, `lefthook.yml`), Gradle/Maven wrappers, `.devcontainer.json`, `.ripgreprc`, `pyrightconfig.json`, `.mcp.json`.
117
+
118
+ Read-only tools (`read`, `grep`, `find`, `ls`) bypass this check — reads to protected paths are always allowed. Only `write` and `edit` are affected.
119
+
109
120
  #### `soft_deny`
110
121
 
111
122
  `$defaults` expands to soft blocks for:
@@ -167,7 +178,7 @@ If you omit `$defaults`, you replace the built-ins for that section:
167
178
  }
168
179
  ```
169
180
 
170
- That means: use only that one `allow` entry. The built-in `allow` entries are not used. Replacing `allow` does not replace `soft_deny`, `hard_deny`, or `environment`.
181
+ That means: use only that one `allow` entry. The built-in `allow` entries are not used. Replacing `allow` does not replace `soft_deny`, `hard_deny`, `protectedPaths`, or `environment`.
171
182
 
172
183
  `$defaults` is not used in `permissions.deny` or `permissions.ask`. Those lists contain only explicit Pi tool patterns.
173
184
 
@@ -190,6 +201,8 @@ The extension blocks these before any allow or classifier decision:
190
201
 
191
202
  Read-only Pi tools (`read`, `grep`, `find`, `ls`) are allowed after those checks.
192
203
 
204
+ Writes to [protected paths](#protectedpaths) (shell profiles, tool configs, `.git`, `.vscode`, `.pi`, etc.) always go to the classifier, even if an `allow` rule matches. The classifier decides whether to permit the write.
205
+
193
206
  Everything else goes to the classifier. If the classifier is missing, fails, or returns invalid JSON, the action is blocked.
194
207
 
195
208
  ## Examples
@@ -204,7 +217,7 @@ npm test
204
217
  npm pack --dry-run
205
218
  ```
206
219
 
207
- The tests cover the risky parts: scoped permission matching, config-source precedence, `$defaults` behavior, config diagnostics, deterministic hard-deny checks, shell parsing for risky bash fragments, classifier JSON parsing, hook-level blocking/allowing, and classifier mocking.
220
+ The tests cover the risky parts: scoped permission matching, config-source precedence, `$defaults` behavior, config diagnostics, deterministic hard-deny checks, shell parsing for risky bash fragments, classifier JSON parsing, hook-level blocking/allowing, classifier mocking, and protected-path enforcement.
208
221
 
209
222
  ## Known limits
210
223
 
@@ -3,9 +3,9 @@ import type { AssistantMessage, Model, UserMessage } from "@earendil-works/pi-ai
3
3
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { Container, Input, SelectList, Text, fuzzyFilter, matchesKey } from "@earendil-works/pi-tui";
5
5
  import type { SelectItem } from "@earendil-works/pi-tui";
6
- import { existsSync, readFileSync } from "node:fs";
6
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
7
7
  import os from "node:os";
8
- import { basename, isAbsolute, normalize, relative, resolve } from "node:path";
8
+ import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
9
9
 
10
10
  /**
11
11
  * Claude Code-style auto mode for Pi.
@@ -15,6 +15,58 @@ import { basename, isAbsolute, normalize, relative, resolve } from "node:path";
15
15
  * Only then do read-only tools pass, and all remaining tools go through the classifier.
16
16
  */
17
17
  const HOME = os.homedir();
18
+ /** Built-in protected paths. Writes to these go to the classifier regardless of allow rules. */
19
+ export const DEFAULT_PROTECTED_PATHS = [
20
+ ".git",
21
+ ".config/git",
22
+ ".vscode",
23
+ ".idea",
24
+ ".husky",
25
+ ".cargo",
26
+ ".devcontainer",
27
+ ".yarn",
28
+ ".mvn",
29
+ ".pi",
30
+ ".gitconfig",
31
+ ".gitmodules",
32
+ ".gitignore",
33
+ ".gitattributes",
34
+ ".bashrc",
35
+ ".bash_profile",
36
+ ".bash_login",
37
+ ".bash_aliases",
38
+ ".bash_logout",
39
+ ".zshrc",
40
+ ".zprofile",
41
+ ".zshenv",
42
+ ".zlogin",
43
+ ".zlogout",
44
+ ".profile",
45
+ ".envrc",
46
+ ".npmrc",
47
+ ".yarnrc",
48
+ ".yarnrc.yml",
49
+ ".pnp.cjs",
50
+ ".pnp.loader.mjs",
51
+ ".pnpmfile.cjs",
52
+ "bunfig.toml",
53
+ ".bunfig.toml",
54
+ ".bazelrc",
55
+ ".bazelversion",
56
+ ".bazeliskrc",
57
+ ".pre-commit-config.yaml",
58
+ "lefthook.yml",
59
+ "lefthook.yaml",
60
+ ".lefthook.yml",
61
+ ".lefthook.yaml",
62
+ "gradle-wrapper.properties",
63
+ "maven-wrapper.properties",
64
+ ".devcontainer.json",
65
+ ".ripgreprc",
66
+ "pyrightconfig.json",
67
+ ".mcp.json",
68
+ ];
69
+
18
70
  const DEFAULT_MAX_TRANSCRIPT_LINES = 80;
19
71
  const DENIAL_HISTORY_LIMIT = 12;
20
72
 
@@ -24,6 +76,7 @@ export type AutoModeSettings = {
24
76
  maxTranscriptLines?: number;
25
77
  environment?: unknown;
26
78
  allow?: unknown;
79
+ protectedPaths?: unknown;
27
80
  soft_deny?: unknown;
28
81
  softDeny?: unknown;
29
82
  hard_deny?: unknown;
@@ -56,6 +109,7 @@ export type EffectiveConfig = {
56
109
  maxTranscriptLines: number;
57
110
  environment: string[];
58
111
  allow: string[];
112
+ protectedPaths: string[];
59
113
  softDeny: string[];
60
114
  hardDeny: string[];
61
115
  permissionDeny: ToolPattern[];
@@ -281,6 +335,7 @@ export function validateSettingsFile(
281
335
  "maxTranscriptLines",
282
336
  "environment",
283
337
  "allow",
338
+ "protectedPaths",
284
339
  "soft_deny",
285
340
  "softDeny",
286
341
  "hard_deny",
@@ -319,6 +374,12 @@ export function validateSettingsFile(
319
374
  "autoMode.allow",
320
375
  diagnostics,
321
376
  );
377
+ validateStringArraySetting(
378
+ autoMode.protectedPaths,
379
+ source,
380
+ "autoMode.protectedPaths",
381
+ diagnostics,
382
+ );
322
383
  validateStringArraySetting(
323
384
  autoMode.soft_deny ?? autoMode.softDeny,
324
385
  source,
@@ -483,6 +544,7 @@ export function buildEffectiveConfigFromSources(
483
544
  maxTranscriptLines: DEFAULT_MAX_TRANSCRIPT_LINES,
484
545
  environment: [...DEFAULT_ENVIRONMENT],
485
546
  allow: [...DEFAULT_ALLOW],
547
+ protectedPaths: [...DEFAULT_PROTECTED_PATHS],
486
548
  softDeny: [...DEFAULT_SOFT_DENY],
487
549
  hardDeny: [...DEFAULT_HARD_DENY],
488
550
  permissionDeny: [],
@@ -501,6 +563,7 @@ export function buildEffectiveConfigFromSources(
501
563
  ];
502
564
  const environment = createRuleAccumulator(DEFAULT_ENVIRONMENT);
503
565
  const allow = createRuleAccumulator(DEFAULT_ALLOW);
566
+ const protectedPaths = createRuleAccumulator(DEFAULT_PROTECTED_PATHS);
504
567
  const softDeny = createRuleAccumulator(DEFAULT_SOFT_DENY);
505
568
  const hardDeny = createRuleAccumulator(DEFAULT_HARD_DENY);
506
569
 
@@ -508,6 +571,7 @@ export function buildEffectiveConfigFromSources(
508
571
  config = applyAutoModeScalars(config, settings.autoMode);
509
572
  applyRuleSetting(environment, settings.autoMode?.environment);
510
573
  applyRuleSetting(allow, settings.autoMode?.allow);
574
+ applyRuleSetting(protectedPaths, settings.autoMode?.protectedPaths);
511
575
  applyRuleSetting(
512
576
  softDeny,
513
577
  settings.autoMode?.soft_deny ?? settings.autoMode?.softDeny,
@@ -522,6 +586,7 @@ export function buildEffectiveConfigFromSources(
522
586
  ...config,
523
587
  environment: finalizeRuleSetting(environment),
524
588
  allow: finalizeRuleSetting(allow),
589
+ protectedPaths: finalizeRuleSetting(protectedPaths),
525
590
  softDeny: finalizeRuleSetting(softDeny),
526
591
  hardDeny: finalizeRuleSetting(hardDeny),
527
592
  };
@@ -679,6 +744,57 @@ function isInside(child: string, parent: string): boolean {
679
744
  return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
680
745
  }
681
746
 
747
+ function isProtectedPath(path: string, cwd: string, protectedPaths: string[]): boolean {
748
+ // Resolve symlinks so writes through symlinks (e.g. not-git -> .git) are caught.
749
+ let resolved = path;
750
+ try {
751
+ resolved = realpathSync(path);
752
+ } catch {
753
+ // File doesn't exist yet — try resolving the parent directory.
754
+ try {
755
+ const dir = dirname(path);
756
+ const base = basename(path);
757
+ resolved = join(realpathSync(dir), base);
758
+ } catch {
759
+ // Parent doesn't exist either — fall through with raw path.
760
+ }
761
+ }
762
+
763
+ // For paths inside the project: use relative path for matching.
764
+ if (resolved.startsWith(cwd)) {
765
+ const relativePath = relative(cwd, resolved);
766
+ for (const pattern of protectedPaths) {
767
+ if (
768
+ relativePath === pattern ||
769
+ relativePath.startsWith(`${pattern}/`)
770
+ ) {
771
+ return true;
772
+ }
773
+ }
774
+ return false;
775
+ }
776
+
777
+ // For paths outside the project: check every path component suffix.
778
+ // This catches writes like ../other-project/.git/config even when cwd
779
+ // doesn't contain the target.
780
+ //
781
+ // e.g. /Users/x/other-project/.git/config → segments ["Users", "x", "other-project", ".git", "config"]
782
+ // check ".git/config" against ".git" → ".git/config".startsWith(".git/") → match
783
+ const segments = resolved.split("/").filter(Boolean);
784
+ for (let i = 0; i < segments.length; i++) {
785
+ const suffix = segments.slice(i).join("/");
786
+ for (const pattern of protectedPaths) {
787
+ if (
788
+ suffix === pattern ||
789
+ suffix.startsWith(`${pattern}/`)
790
+ ) {
791
+ return true;
792
+ }
793
+ }
794
+ }
795
+ return false;
796
+ }
797
+
682
798
  function isSafetyControlPath(path: string, cwd: string): boolean {
683
799
  const normalized = path.replace(/\\/g, "/");
684
800
  const file = basename(normalized).toLowerCase();
@@ -1649,6 +1765,28 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
1649
1765
  return undefined;
1650
1766
  }
1651
1767
 
1768
+ // Protected paths go to the classifier regardless of allow rules.
1769
+ if (event.toolName === "write" || event.toolName === "edit") {
1770
+ const path = resolveInputPath(ctx.cwd, input.path);
1771
+ if (path && isProtectedPath(path, ctx.cwd, cfg.protectedPaths)) {
1772
+ const decision = await classify(ctx, cfg, summary, loadedContext);
1773
+ if (decision.decision === "allow") {
1774
+ state.lastDecision = "allow";
1775
+ state.lastReason = decision.reason;
1776
+ persist();
1777
+ updateUi(ctx);
1778
+ return undefined;
1779
+ }
1780
+ return block(ctx, {
1781
+ timestamp: Date.now(),
1782
+ toolName: event.toolName,
1783
+ reason: decision.reason,
1784
+ action: summary,
1785
+ kind: "classifier",
1786
+ });
1787
+ }
1788
+ }
1789
+
1652
1790
  const decision = await classify(ctx, cfg, summary, loadedContext);
1653
1791
  if (decision.decision === "allow") {
1654
1792
  state.lastDecision = "allow";
@@ -1726,6 +1864,7 @@ export function createPiAutomode(options: PiAutomodeOptions = {}) {
1726
1864
  {
1727
1865
  environment: DEFAULT_ENVIRONMENT,
1728
1866
  allow: DEFAULT_ALLOW,
1867
+ protectedPaths: DEFAULT_PROTECTED_PATHS,
1729
1868
  soft_deny: DEFAULT_SOFT_DENY,
1730
1869
  hard_deny: DEFAULT_HARD_DENY,
1731
1870
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@czottmann/pi-automode",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Claude Code-style auto mode guardrail for pi.",
5
5
  "repository": {
6
6
  "url": "https://github.com/czottmann/pi-automode"