@czottmann/pi-automode 0.1.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 +15 -2
- package/extensions/auto-mode.ts +141 -2
- package/package.json +4 -1
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
|
|
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
|
|
package/extensions/auto-mode.ts
CHANGED
|
@@ -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,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@czottmann/pi-automode",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Claude Code-style auto mode guardrail for pi.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/czottmann/pi-automode"
|
|
7
|
+
},
|
|
5
8
|
"type": "module",
|
|
6
9
|
"license": "MIT",
|
|
7
10
|
"publishConfig": {
|