@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.
- package/CHANGELOG.md +27 -0
- package/README.md +6 -4
- package/docs/adr/ADR-001-permission-precedence-and-trust-boundaries.md +2 -0
- package/docs/adr/ADR-002-global-config-in-extension-data-directory.md +60 -0
- package/docs/adr/INDEX.md +1 -0
- package/docs/automode-classifier-flow.md +4 -4
- package/docs/configuration.md +20 -4
- package/docs/observability-logging.md +1 -1
- package/extensions/auto-mode/bash.ts +692 -0
- package/extensions/auto-mode/classifier.ts +55 -4
- package/extensions/auto-mode/config.ts +188 -6
- package/extensions/auto-mode/constants.ts +6 -1
- package/extensions/auto-mode/extension.ts +143 -36
- package/extensions/auto-mode/hard-deny.ts +225 -159
- package/extensions/auto-mode/paths.ts +32 -6
- package/extensions/auto-mode/permissions.ts +344 -22
- package/extensions/auto-mode.ts +1 -0
- package/package.json +4 -1
|
@@ -1,4 +1,12 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
1
3
|
import { resolve } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
analyzeBash,
|
|
6
|
+
type BashAnalysis,
|
|
7
|
+
type BashCommandAnalysis,
|
|
8
|
+
type EffectiveCommand,
|
|
9
|
+
} from "./bash.ts";
|
|
2
10
|
import { HOME } from "./constants.ts";
|
|
3
11
|
import {
|
|
4
12
|
isProfileOrAuthorizedKeysPath,
|
|
@@ -8,157 +16,177 @@ import {
|
|
|
8
16
|
shellPathTokenToPath,
|
|
9
17
|
} from "./paths.ts";
|
|
10
18
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
function isRecursiveRmArg(arg: string): boolean {
|
|
20
|
+
return (
|
|
21
|
+
(arg.length > 2 && arg.startsWith("--") && "--recursive".startsWith(arg)) ||
|
|
22
|
+
/^-[A-Za-z]*r[A-Za-z]*f?[A-Za-z]*$/i.test(arg) ||
|
|
23
|
+
/^-[A-Za-z]*f[A-Za-z]*r[A-Za-z]*$/i.test(arg)
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type RmInvocation = {
|
|
28
|
+
recursive: boolean;
|
|
29
|
+
operands: Array<{ value: string; text: string; tildeExpansion: boolean }>;
|
|
15
30
|
};
|
|
16
31
|
|
|
17
|
-
function
|
|
18
|
-
|
|
19
|
-
let
|
|
20
|
-
|
|
21
|
-
let escaped = false;
|
|
32
|
+
export function parseRmInvocation(command: EffectiveCommand): RmInvocation {
|
|
33
|
+
let recursive = false;
|
|
34
|
+
let optionsEnded = false;
|
|
35
|
+
const operands: RmInvocation["operands"] = [];
|
|
22
36
|
|
|
23
|
-
for (
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
escaped = false;
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
if (char === "\\" && quote !== "'") {
|
|
32
|
-
current += char;
|
|
33
|
-
escaped = true;
|
|
34
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
if (quote) {
|
|
37
|
-
current += char;
|
|
38
|
-
if (char === quote) quote = undefined;
|
|
37
|
+
for (const [index, value] of command.args.entries()) {
|
|
38
|
+
const text = command.argTexts[index] ?? value;
|
|
39
|
+
const tildeExpansion = command.argTildeExpansions[index] ?? false;
|
|
40
|
+
if (!optionsEnded && value === "--") {
|
|
41
|
+
optionsEnded = true;
|
|
39
42
|
continue;
|
|
40
43
|
}
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
current += char;
|
|
44
|
+
if (!optionsEnded && value !== "-" && value.startsWith("-")) {
|
|
45
|
+
if (isRecursiveRmArg(value)) recursive = true;
|
|
44
46
|
continue;
|
|
45
47
|
}
|
|
46
|
-
|
|
47
|
-
char === ";" ||
|
|
48
|
-
char === "\n" ||
|
|
49
|
-
char === "|" ||
|
|
50
|
-
(char === "&" && next === "&") ||
|
|
51
|
-
(char === "|" && next === "|")
|
|
52
|
-
) {
|
|
53
|
-
if (current.trim()) segments.push(current.trim());
|
|
54
|
-
current = "";
|
|
55
|
-
if ((char === "&" && next === "&") || (char === "|" && next === "|")) {
|
|
56
|
-
i += 1;
|
|
57
|
-
}
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
current += char;
|
|
48
|
+
operands.push({ value, text, tildeExpansion });
|
|
61
49
|
}
|
|
62
|
-
|
|
63
|
-
return
|
|
50
|
+
|
|
51
|
+
return { recursive, operands };
|
|
64
52
|
}
|
|
65
53
|
|
|
66
|
-
function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
54
|
+
function isUnresolvedUserHomeToken(
|
|
55
|
+
shellText: string,
|
|
56
|
+
tildeExpansion: boolean,
|
|
57
|
+
): boolean {
|
|
58
|
+
return tildeExpansion && shellText !== "~" && !shellText.startsWith("~/");
|
|
59
|
+
}
|
|
71
60
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (char === "\\" && quote !== "'") {
|
|
80
|
-
escaped = true;
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
if (quote) {
|
|
84
|
-
if (char === quote) quote = undefined;
|
|
85
|
-
else current += char;
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
if (char === "'" || char === '"' || char === "`") {
|
|
89
|
-
quote = char;
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
if (/\s/.test(char)) {
|
|
93
|
-
if (current) tokens.push(current);
|
|
94
|
-
current = "";
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
if (char === ">" || char === "<") {
|
|
98
|
-
let op = char;
|
|
99
|
-
if (/^\d+$/.test(current)) {
|
|
100
|
-
op = current + char;
|
|
101
|
-
} else if (current) {
|
|
102
|
-
tokens.push(current);
|
|
103
|
-
}
|
|
104
|
-
if (text[i + 1] === ">" || text[i + 1] === "&") {
|
|
105
|
-
op += text[i + 1];
|
|
106
|
-
i += 1;
|
|
107
|
-
}
|
|
108
|
-
tokens.push(op);
|
|
109
|
-
current = "";
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
current += char;
|
|
61
|
+
function isSameExistingPath(left: string, right: string): boolean {
|
|
62
|
+
try {
|
|
63
|
+
const leftStat = statSync(left);
|
|
64
|
+
const rightStat = statSync(right);
|
|
65
|
+
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
113
68
|
}
|
|
114
|
-
if (current) tokens.push(current);
|
|
115
|
-
return tokens;
|
|
116
69
|
}
|
|
117
70
|
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const target = tokens[i + 1];
|
|
127
|
-
if (target) redirectTargets.push(target);
|
|
128
|
-
i += 1;
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
const attachedRedirect = token.match(/^(?:\d?>|\d?>>|>|>>|&>)(.+)$/);
|
|
132
|
-
if (attachedRedirect?.[1]) {
|
|
133
|
-
redirectTargets.push(attachedRedirect[1]);
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
words.push(token);
|
|
137
|
-
}
|
|
138
|
-
return { text, words, redirectTargets };
|
|
139
|
-
});
|
|
71
|
+
function matchesPathRoot(path: string, root: string): boolean {
|
|
72
|
+
if (path === root || path.startsWith(`${root}/`)) return true;
|
|
73
|
+
const lowerPath = path.toLowerCase();
|
|
74
|
+
const lowerRoot = root.toLowerCase();
|
|
75
|
+
if (lowerPath !== lowerRoot && !lowerPath.startsWith(`${lowerRoot}/`)) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
return isSameExistingPath(path.slice(0, root.length), root);
|
|
140
79
|
}
|
|
141
80
|
|
|
142
|
-
|
|
143
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Top-level directories whose deletion or wholesale modification is a
|
|
83
|
+
* system-wide event. Shared between candidate validation and path
|
|
84
|
+
* classification; do not inline copies.
|
|
85
|
+
*/
|
|
86
|
+
const SYSTEM_ROOTS: ReadonlyArray<string> = [
|
|
87
|
+
"/bin",
|
|
88
|
+
"/boot",
|
|
89
|
+
"/dev",
|
|
90
|
+
"/etc",
|
|
91
|
+
"/home",
|
|
92
|
+
"/lib",
|
|
93
|
+
"/lib64",
|
|
94
|
+
"/Library",
|
|
95
|
+
"/private",
|
|
96
|
+
"/proc",
|
|
97
|
+
"/root",
|
|
98
|
+
"/run",
|
|
99
|
+
"/sbin",
|
|
100
|
+
"/sys",
|
|
101
|
+
"/System",
|
|
102
|
+
"/usr",
|
|
103
|
+
"/var",
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Normalize a proposed temp-root value into a comparable absolute path.
|
|
108
|
+
* Returns undefined for values that cannot denote a subdirectory: the
|
|
109
|
+
* empty string, `/`, and slash-only artifacts.
|
|
110
|
+
*/
|
|
111
|
+
function normalizeRootCandidate(value: string): string | undefined {
|
|
112
|
+
const stripped = value.replace(/\/+$/, "");
|
|
113
|
+
// An empty stripped value must not fall through to `resolve()`, which
|
|
114
|
+
// would silently turn `/` into the process working directory.
|
|
115
|
+
if (!stripped) return undefined;
|
|
116
|
+
const normalized = resolve(stripped);
|
|
117
|
+
if (normalized === "/") return undefined;
|
|
118
|
+
return normalized;
|
|
144
119
|
}
|
|
145
120
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
121
|
+
/**
|
|
122
|
+
* True when a candidate temp root would weaken the deterministic deny tiers:
|
|
123
|
+
* it aliases (exact, case-folded, or dev/inode) `HOME`, `/`, or any system
|
|
124
|
+
* root, or it is a proper ancestor of the canonical home directory.
|
|
125
|
+
*
|
|
126
|
+
* Dev/inode identity covers symlinked spellings of existing directories;
|
|
127
|
+
* string comparison alone handles candidates that do not exist yet. See
|
|
128
|
+
* issue #31: without these guards, `TMPDIR=/` reduced every absolute path
|
|
129
|
+
* below an empty-string prefix match, and `TMPDIR=/private` made
|
|
130
|
+
* `/private/etc/**` disposable.
|
|
131
|
+
*/
|
|
132
|
+
function conflictsWithProtectedRoots(candidate: string, home: string): boolean {
|
|
133
|
+
if (candidate === "/") return true;
|
|
134
|
+
for (const protectedRoot of ["/", home, ...SYSTEM_ROOTS]) {
|
|
135
|
+
const canonical = resolve(protectedRoot);
|
|
136
|
+
if (
|
|
137
|
+
candidate === canonical ||
|
|
138
|
+
candidate.toLowerCase() === canonical.toLowerCase()
|
|
139
|
+
) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
if (isSameExistingPath(candidate, canonical)) return true;
|
|
143
|
+
}
|
|
144
|
+
const homeCanonical = resolvePathForPolicy(home) ?? resolve(home);
|
|
145
|
+
return homeCanonical.toLowerCase().startsWith(`${candidate.toLowerCase()}/`);
|
|
149
146
|
}
|
|
150
147
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
148
|
+
let cachedTempRoots: ReadonlyArray<string> | undefined;
|
|
149
|
+
let cachedTmpdirValue: string | undefined;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Validated launcher-declared temp-dir roots whose subtrees are treated as
|
|
153
|
+
* disposable by `isRootHomeOrSystemPath`: the platform tmpdir, plus `/tmp` on
|
|
154
|
+
* macOS where it exists independently of `os.tmpdir()`. Each root appears in
|
|
155
|
+
* canonical and resolved spelling because callers pass symlink-resolved
|
|
156
|
+
* policy paths (`/tmp` → `/private/tmp` on macOS) and unresolved fallbacks
|
|
157
|
+
* alike. Values from `os.tmpdir()` are not trusted blindly; see
|
|
158
|
+
* `conflictsWithProtectedRoots`. The roots themselves are never exempt:
|
|
159
|
+
* deleting one is a system-wide delete.
|
|
160
|
+
*
|
|
161
|
+
* The memoization keys on the effective `os.tmpdir()` return value so tests
|
|
162
|
+
* can mutate `TMPDIR`, `TMP`, or `TEMP` between calls.
|
|
163
|
+
*/
|
|
164
|
+
export function tempRootCandidates(): ReadonlyArray<string> {
|
|
165
|
+
const currentTmpdir = tmpdir();
|
|
166
|
+
if (cachedTempRoots && cachedTmpdirValue === currentTmpdir) {
|
|
167
|
+
return cachedTempRoots;
|
|
168
|
+
}
|
|
169
|
+
cachedTmpdirValue = currentTmpdir;
|
|
170
|
+
cachedTempRoots = (() => {
|
|
171
|
+
const roots = new Set<string>();
|
|
172
|
+
const consider = (value: string) => {
|
|
173
|
+
const candidate = normalizeRootCandidate(value);
|
|
174
|
+
if (!candidate || conflictsWithProtectedRoots(candidate, HOME)) return;
|
|
175
|
+
roots.add(candidate);
|
|
176
|
+
const resolved = resolvePathForPolicy(candidate);
|
|
177
|
+
if (resolved) roots.add(resolved);
|
|
178
|
+
};
|
|
179
|
+
consider(currentTmpdir);
|
|
180
|
+
if (process.platform === "darwin") consider("/tmp");
|
|
181
|
+
return [...roots];
|
|
182
|
+
})();
|
|
183
|
+
return cachedTempRoots;
|
|
157
184
|
}
|
|
158
185
|
|
|
159
186
|
/**
|
|
160
187
|
* True for `/`, the user's home root, or a top-level system root such as
|
|
161
|
-
* `/etc`, `/usr`, or `/var`. Excludes the home *subtree
|
|
188
|
+
* `/etc`, `/usr`, or `/var`. Excludes the home *subtree* and the subtrees of
|
|
189
|
+
* platform temp directories (`os.tmpdir()` and `/tmp` on macOS).
|
|
162
190
|
*
|
|
163
191
|
* On some distros (e.g. Fedora Silverblue) HOME lives under `/var`, which is
|
|
164
192
|
* in `systemRoots`. Without the subtree exemption, `path.startsWith("/var/")`
|
|
@@ -166,31 +194,41 @@ function isRecursiveRmArg(arg: string): boolean {
|
|
|
166
194
|
* `rm -rf ~/...`. HOME itself is still matched below, so `rm -rf ~` stays
|
|
167
195
|
* blocked. `home` is a parameter so this can be unit-tested with a synthetic
|
|
168
196
|
* `/var/home/...` value.
|
|
197
|
+
*
|
|
198
|
+
* The temp exemption mirrors the home one and covers cleanup of directories
|
|
199
|
+
* created with `mktemp`, `os.tmpdir()`, or plain `/tmp` paths. On macOS these
|
|
200
|
+
* resolve into `/private/tmp` or `/private/var/folders`, which used to match
|
|
201
|
+
* the `/private` system root and hard-deny every temp cleanup. Deleting a
|
|
202
|
+
* temp root itself still returns true; `tempRoots` is injectable for tests.
|
|
203
|
+
*
|
|
204
|
+
* Protection order matters (issue #31): exact `/`, the exact home root, and
|
|
205
|
+
* system roots win over every exemption. Injected `tempRoots` values are
|
|
206
|
+
* validated per call so hostile or malformed candidates cannot weaken the
|
|
207
|
+
* deterministic tiers.
|
|
169
208
|
*/
|
|
170
|
-
export function isRootHomeOrSystemPath(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
209
|
+
export function isRootHomeOrSystemPath(
|
|
210
|
+
path: string,
|
|
211
|
+
home: string,
|
|
212
|
+
tempRoots: ReadonlyArray<string> = tempRootCandidates(),
|
|
213
|
+
): boolean {
|
|
214
|
+
if (path === "/") return true;
|
|
215
|
+
if (path === home || isSameExistingPath(path, home)) return true;
|
|
216
|
+
if (matchesPathRoot(path, home) && path.length > home.length) return false;
|
|
217
|
+
for (const root of tempRoots) {
|
|
218
|
+
const candidate = normalizeRootCandidate(root);
|
|
219
|
+
if (!candidate || conflictsWithProtectedRoots(candidate, home)) continue;
|
|
220
|
+
if (!matchesPathRoot(path, candidate)) continue;
|
|
221
|
+
// Subtree: disposable. Exact match: the temp root stays protected.
|
|
222
|
+
return path.length > candidate.length ? false : true;
|
|
223
|
+
}
|
|
185
224
|
return (
|
|
186
|
-
path
|
|
187
|
-
|
|
188
|
-
systemRoots.some((root) => path === root || path.startsWith(`${root}/`))
|
|
225
|
+
matchesPathRoot(path, home) ||
|
|
226
|
+
SYSTEM_ROOTS.some((root) => matchesPathRoot(path, root))
|
|
189
227
|
);
|
|
190
228
|
}
|
|
191
229
|
|
|
192
230
|
function segmentHardDeny(
|
|
193
|
-
segment:
|
|
231
|
+
segment: BashCommandAnalysis,
|
|
194
232
|
cwd: string,
|
|
195
233
|
): string | undefined {
|
|
196
234
|
for (const target of segment.redirectTargets) {
|
|
@@ -213,9 +251,10 @@ function segmentHardDeny(
|
|
|
213
251
|
}
|
|
214
252
|
}
|
|
215
253
|
|
|
216
|
-
const
|
|
254
|
+
const command = segment.effectiveCommand;
|
|
255
|
+
const name = command.name;
|
|
217
256
|
if (!name) return undefined;
|
|
218
|
-
const args =
|
|
257
|
+
const args = command.args;
|
|
219
258
|
const lowerArgs = args.map((arg) => arg.toLowerCase());
|
|
220
259
|
|
|
221
260
|
if (
|
|
@@ -270,18 +309,32 @@ function segmentHardDeny(
|
|
|
270
309
|
return "platform security weakening is hard-denied";
|
|
271
310
|
}
|
|
272
311
|
|
|
273
|
-
if (name === "rm"
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
312
|
+
if (name === "rm") {
|
|
313
|
+
const rm = parseRmInvocation(command);
|
|
314
|
+
if (rm.recursive) {
|
|
315
|
+
for (const { value: arg, text: shellText, tildeExpansion } of rm.operands) {
|
|
316
|
+
if (isUnresolvedUserHomeToken(shellText, tildeExpansion)) {
|
|
317
|
+
return "irreversible deletion of a user-home expansion is hard-denied";
|
|
318
|
+
}
|
|
319
|
+
const path = shellPathTokenToPath(arg, cwd, shellText);
|
|
320
|
+
const policyPath = path ? (resolvePathForPolicy(path) ?? path) : undefined;
|
|
321
|
+
const policyHome = resolvePathForPolicy(HOME) ?? HOME;
|
|
322
|
+
if (policyPath && isRootHomeOrSystemPath(policyPath, policyHome)) {
|
|
323
|
+
return "irreversible deletion of home/root/system paths is hard-denied";
|
|
324
|
+
}
|
|
278
325
|
}
|
|
279
326
|
}
|
|
280
327
|
}
|
|
281
328
|
|
|
282
329
|
if (name === "find" && lowerArgs.includes("-delete")) {
|
|
283
330
|
const root = shellPathTokenToPath(args[0] ?? "", cwd);
|
|
284
|
-
|
|
331
|
+
const policyRoot = root ? (resolvePathForPolicy(root) ?? root) : undefined;
|
|
332
|
+
const policyHome = resolvePathForPolicy(HOME) ?? HOME;
|
|
333
|
+
if (
|
|
334
|
+
policyRoot &&
|
|
335
|
+
isRootHomeOrSystemPath(policyRoot, policyHome) &&
|
|
336
|
+
policyRoot !== policyHome
|
|
337
|
+
) {
|
|
285
338
|
return "system-wide delete is hard-denied";
|
|
286
339
|
}
|
|
287
340
|
}
|
|
@@ -320,7 +373,7 @@ function segmentHardDeny(
|
|
|
320
373
|
"sed",
|
|
321
374
|
].includes(name) &&
|
|
322
375
|
/\.pi\/automode|\.pi\/extensions|pi-automode|auto-mode\.json/i.test(
|
|
323
|
-
segment.
|
|
376
|
+
segment.raw,
|
|
324
377
|
)
|
|
325
378
|
) {
|
|
326
379
|
return "auto-mode or permission safety-control modification is hard-denied";
|
|
@@ -332,14 +385,14 @@ function segmentHardDeny(
|
|
|
332
385
|
/**
|
|
333
386
|
* Deterministic deny checks for actions too risky to delegate to the classifier.
|
|
334
387
|
*
|
|
335
|
-
* Bash checks use
|
|
336
|
-
*
|
|
337
|
-
* `;` well enough to avoid the common "safe prefix hides risky suffix" bypass.
|
|
388
|
+
* Bash checks use the shared unbash AST analysis. The hook passes one analysis
|
|
389
|
+
* through every enforcement stage so nested commands are not reparsed.
|
|
338
390
|
*/
|
|
339
391
|
export function deterministicHardDeny(
|
|
340
392
|
toolName: string,
|
|
341
393
|
input: Record<string, unknown>,
|
|
342
394
|
cwd: string,
|
|
395
|
+
bashAnalysis?: BashAnalysis,
|
|
343
396
|
): string | undefined {
|
|
344
397
|
if (toolName === "write" || toolName === "edit") {
|
|
345
398
|
const path = resolveInputPath(cwd, input.path);
|
|
@@ -355,7 +408,20 @@ export function deterministicHardDeny(
|
|
|
355
408
|
|
|
356
409
|
if (toolName !== "bash") return undefined;
|
|
357
410
|
const command = typeof input.command === "string" ? input.command : "";
|
|
358
|
-
|
|
411
|
+
const analysis = bashAnalysis ?? analyzeBash(command);
|
|
412
|
+
if (analysis.errors.length > 0) {
|
|
413
|
+
return `Bash input could not be parsed safely: ${analysis.errors[0]?.message ?? "unknown parser error"}`;
|
|
414
|
+
}
|
|
415
|
+
for (const target of analysis.redirectTargets) {
|
|
416
|
+
const path = shellPathTokenToPath(target, cwd);
|
|
417
|
+
if (!path) continue;
|
|
418
|
+
const profileReason = isProfileOrAuthorizedKeysPath(path);
|
|
419
|
+
if (profileReason) return profileReason;
|
|
420
|
+
if (isSafetyControlPath(path, cwd)) {
|
|
421
|
+
return "auto-mode or permission safety-control modification is hard-denied";
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
for (const segment of analysis.commands) {
|
|
359
425
|
const reason = segmentHardDeny(segment, cwd);
|
|
360
426
|
if (reason) return reason;
|
|
361
427
|
}
|
|
@@ -180,13 +180,21 @@ function resolvePathForPolicyInner(
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
function normalizeProtectedPathForMatch(value: string): string {
|
|
184
|
+
return value
|
|
185
|
+
.replace(/\\/g, "/")
|
|
186
|
+
.normalize("NFC")
|
|
187
|
+
.toLowerCase()
|
|
188
|
+
.normalize("NFC");
|
|
189
|
+
}
|
|
190
|
+
|
|
183
191
|
export function matchesProtectedPath(
|
|
184
192
|
relativePath: string,
|
|
185
193
|
protectedPaths: string[],
|
|
186
194
|
): boolean {
|
|
187
|
-
const normalizedPath = relativePath
|
|
195
|
+
const normalizedPath = normalizeProtectedPathForMatch(relativePath);
|
|
188
196
|
return protectedPaths.some((pattern) => {
|
|
189
|
-
const normalizedPattern = pattern
|
|
197
|
+
const normalizedPattern = normalizeProtectedPathForMatch(pattern);
|
|
190
198
|
return normalizedPath === normalizedPattern ||
|
|
191
199
|
normalizedPath.startsWith(`${normalizedPattern}/`);
|
|
192
200
|
});
|
|
@@ -224,8 +232,24 @@ export function isProtectedPath(
|
|
|
224
232
|
}
|
|
225
233
|
|
|
226
234
|
export function isSafetyControlPath(path: string, cwd: string): boolean {
|
|
227
|
-
const
|
|
228
|
-
const
|
|
235
|
+
const policyPath = resolvePathForPolicy(path) ?? resolve(path);
|
|
236
|
+
const policyCwd = resolvePathForPolicy(cwd) ?? resolve(cwd);
|
|
237
|
+
const normalized = normalizeProtectedPathForMatch(policyPath);
|
|
238
|
+
const file = basename(normalized);
|
|
239
|
+
const piAgentRoot = normalizeProtectedPathForMatch(
|
|
240
|
+
resolve(HOME, ".pi/agent"),
|
|
241
|
+
);
|
|
242
|
+
const globalExtensions = `${piAgentRoot}/extensions`;
|
|
243
|
+
const globalSettings = `${piAgentRoot}/settings`;
|
|
244
|
+
if (
|
|
245
|
+
normalized === `${piAgentRoot}/settings.json` ||
|
|
246
|
+
normalized === globalExtensions ||
|
|
247
|
+
normalized.startsWith(`${globalExtensions}/`) ||
|
|
248
|
+
normalized === globalSettings ||
|
|
249
|
+
normalized.startsWith(`${globalSettings}/`)
|
|
250
|
+
) {
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
229
253
|
if (
|
|
230
254
|
normalized.endsWith("/.pi/auto-mode.json") ||
|
|
231
255
|
normalized.endsWith("/auto-mode.json")
|
|
@@ -238,7 +262,7 @@ export function isSafetyControlPath(path: string, cwd: string): boolean {
|
|
|
238
262
|
if (normalized.includes("/.pi/") && file.startsWith("automode")) return true;
|
|
239
263
|
if (
|
|
240
264
|
normalized.includes("/pi-automode/") ||
|
|
241
|
-
(isInside(
|
|
265
|
+
(isInside(policyPath, policyCwd) && file.includes("auto-mode"))
|
|
242
266
|
) {
|
|
243
267
|
return true;
|
|
244
268
|
}
|
|
@@ -248,13 +272,15 @@ export function isSafetyControlPath(path: string, cwd: string): boolean {
|
|
|
248
272
|
export function shellPathTokenToPath(
|
|
249
273
|
token: string,
|
|
250
274
|
cwd: string,
|
|
275
|
+
shellText = token,
|
|
251
276
|
): string | undefined {
|
|
252
277
|
let value = token.trim();
|
|
253
278
|
if (!value || value === "-" || value.startsWith("&")) return undefined;
|
|
254
279
|
value = value
|
|
255
280
|
.replace(/^\$HOME(?=\/|$)/, HOME)
|
|
256
281
|
.replace(/^\$\{HOME\}(?=\/|$)/, HOME);
|
|
257
|
-
if (
|
|
282
|
+
if (shellText === "~") value = HOME;
|
|
283
|
+
else if (shellText.startsWith("~/")) value = resolve(HOME, value.slice(2));
|
|
258
284
|
return isAbsolute(value) ? resolve(value) : resolve(cwd, value);
|
|
259
285
|
}
|
|
260
286
|
|