@ibartel74/pi-automode-ext 1.0.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.
@@ -0,0 +1,429 @@
1
+ import { statSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { resolve } from "node:path";
4
+ import {
5
+ analyzeBash,
6
+ type BashAnalysis,
7
+ type BashCommandAnalysis,
8
+ type EffectiveCommand,
9
+ } from "./bash.ts";
10
+ import { HOME } from "./constants.ts";
11
+ import {
12
+ isProfileOrAuthorizedKeysPath,
13
+ isSafetyControlPath,
14
+ resolveInputPath,
15
+ resolvePathForPolicy,
16
+ shellPathTokenToPath,
17
+ } from "./paths.ts";
18
+
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 }>;
30
+ };
31
+
32
+ export function parseRmInvocation(command: EffectiveCommand): RmInvocation {
33
+ let recursive = false;
34
+ let optionsEnded = false;
35
+ const operands: RmInvocation["operands"] = [];
36
+
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;
42
+ continue;
43
+ }
44
+ if (!optionsEnded && value !== "-" && value.startsWith("-")) {
45
+ if (isRecursiveRmArg(value)) recursive = true;
46
+ continue;
47
+ }
48
+ operands.push({ value, text, tildeExpansion });
49
+ }
50
+
51
+ return { recursive, operands };
52
+ }
53
+
54
+ function isUnresolvedUserHomeToken(
55
+ shellText: string,
56
+ tildeExpansion: boolean,
57
+ ): boolean {
58
+ return tildeExpansion && shellText !== "~" && !shellText.startsWith("~/");
59
+ }
60
+
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;
68
+ }
69
+ }
70
+
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);
79
+ }
80
+
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;
119
+ }
120
+
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()}/`);
146
+ }
147
+
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;
184
+ }
185
+
186
+ /**
187
+ * True for `/`, the user's home root, or a top-level system root such as
188
+ * `/etc`, `/usr`, or `/var`. Excludes the home *subtree* and the subtrees of
189
+ * platform temp directories (`os.tmpdir()` and `/tmp` on macOS).
190
+ *
191
+ * On some distros (e.g. Fedora Silverblue) HOME lives under `/var`, which is
192
+ * in `systemRoots`. Without the subtree exemption, `path.startsWith("/var/")`
193
+ * would treat every path under HOME as a system root and hard-deny routine
194
+ * `rm -rf ~/...`. HOME itself is still matched below, so `rm -rf ~` stays
195
+ * blocked. `home` is a parameter so this can be unit-tested with a synthetic
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.
208
+ */
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
+ }
224
+ return (
225
+ matchesPathRoot(path, home) ||
226
+ SYSTEM_ROOTS.some((root) => matchesPathRoot(path, root))
227
+ );
228
+ }
229
+
230
+ function segmentHardDeny(
231
+ segment: BashCommandAnalysis,
232
+ cwd: string,
233
+ ): string | undefined {
234
+ for (const target of segment.redirectTargets) {
235
+ const path = shellPathTokenToPath(target, cwd);
236
+ if (!path) continue;
237
+ const profileReason = isProfileOrAuthorizedKeysPath(path);
238
+ if (profileReason) return profileReason;
239
+ if (isSafetyControlPath(path, cwd)) {
240
+ return "auto-mode or permission safety-control modification is hard-denied";
241
+ }
242
+ }
243
+
244
+ for (const word of segment.words) {
245
+ if (
246
+ /^(NODE_TLS_REJECT_UNAUTHORIZED=0|GIT_SSL_NO_VERIFY=(1|true))$/i.test(
247
+ word,
248
+ )
249
+ ) {
250
+ return "TLS verification weakening is hard-denied";
251
+ }
252
+ }
253
+
254
+ const command = segment.effectiveCommand;
255
+ const name = command.name;
256
+ if (!name) return undefined;
257
+ const args = command.args;
258
+ const lowerArgs = args.map((arg) => arg.toLowerCase());
259
+
260
+ if (
261
+ ["curl", "wget"].includes(name) &&
262
+ lowerArgs.some((arg) =>
263
+ ["--insecure", "-k", "--no-check-certificate"].includes(arg)
264
+ )
265
+ ) {
266
+ return "certificate verification weakening is hard-denied";
267
+ }
268
+ if (
269
+ ["npm", "yarn", "pnpm"].includes(name) &&
270
+ lowerArgs[0] === "config" &&
271
+ lowerArgs[1] === "set" &&
272
+ ["strict-ssl", "cafile"].includes(lowerArgs[2] ?? "") &&
273
+ ["false", "null"].includes(lowerArgs[3] ?? "")
274
+ ) {
275
+ return "package-manager TLS weakening is hard-denied";
276
+ }
277
+ if (
278
+ name === "git" &&
279
+ lowerArgs[0] === "config" &&
280
+ lowerArgs.some(
281
+ (arg) => arg === "sslverify" || arg.endsWith(".sslverify"),
282
+ ) &&
283
+ lowerArgs.includes("false")
284
+ ) {
285
+ return "git TLS verification weakening is hard-denied";
286
+ }
287
+ if (name === "crontab" && !lowerArgs.includes("-l")) {
288
+ return "persistence or system service mutation is hard-denied";
289
+ }
290
+ if (
291
+ name === "launchctl" &&
292
+ ["load", "bootstrap", "enable"].includes(lowerArgs[0] ?? "")
293
+ ) {
294
+ return "persistence or system service mutation is hard-denied";
295
+ }
296
+ if (
297
+ name === "systemctl" &&
298
+ ["enable", "disable"].includes(lowerArgs[0] ?? "")
299
+ ) {
300
+ return "persistence or system service mutation is hard-denied";
301
+ }
302
+ if (name === "security" && lowerArgs[0] === "add-trusted-cert") {
303
+ return "platform security weakening is hard-denied";
304
+ }
305
+ if (name === "spctl" && lowerArgs.includes("--master-disable")) {
306
+ return "platform security weakening is hard-denied";
307
+ }
308
+ if (name === "csrutil" && lowerArgs[0] === "disable") {
309
+ return "platform security weakening is hard-denied";
310
+ }
311
+
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
+ }
325
+ }
326
+ }
327
+ }
328
+
329
+ if (name === "find" && lowerArgs.includes("-delete")) {
330
+ const root = shellPathTokenToPath(args[0] ?? "", cwd);
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
+ ) {
338
+ return "system-wide delete is hard-denied";
339
+ }
340
+ }
341
+
342
+ if (["chmod", "chown"].includes(name)) {
343
+ for (const arg of args.filter((arg) => !arg.startsWith("-"))) {
344
+ const path = shellPathTokenToPath(arg, cwd);
345
+ if (
346
+ path &&
347
+ (path.startsWith("/etc/") ||
348
+ path.startsWith("/usr/") ||
349
+ path.startsWith("/bin/") ||
350
+ path.startsWith("/sbin/") ||
351
+ path.startsWith("/System/") ||
352
+ path.startsWith(resolve(HOME, ".ssh")))
353
+ ) {
354
+ return "system or SSH permission mutation is hard-denied";
355
+ }
356
+ }
357
+ }
358
+
359
+ if (
360
+ [
361
+ "tee",
362
+ "mv",
363
+ "cp",
364
+ "rm",
365
+ "unlink",
366
+ "truncate",
367
+ "python",
368
+ "python3",
369
+ "node",
370
+ "perl",
371
+ "ruby",
372
+ "sd",
373
+ "sed",
374
+ ].includes(name) &&
375
+ /\.pi\/automode|\.pi\/extensions|pi-automode|auto-mode\.json/i.test(
376
+ segment.raw,
377
+ )
378
+ ) {
379
+ return "auto-mode or permission safety-control modification is hard-denied";
380
+ }
381
+
382
+ return undefined;
383
+ }
384
+
385
+ /**
386
+ * Deterministic deny checks for actions too risky to delegate to the classifier.
387
+ *
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.
390
+ */
391
+ export function deterministicHardDeny(
392
+ toolName: string,
393
+ input: Record<string, unknown>,
394
+ cwd: string,
395
+ bashAnalysis?: BashAnalysis,
396
+ ): string | undefined {
397
+ if (toolName === "write" || toolName === "edit") {
398
+ const path = resolveInputPath(cwd, input.path);
399
+ if (!path) return undefined;
400
+ const policyPath = resolvePathForPolicy(path) ?? path;
401
+ const policyCwd = resolvePathForPolicy(cwd) ?? cwd;
402
+ const profileReason = isProfileOrAuthorizedKeysPath(policyPath);
403
+ if (profileReason) return profileReason;
404
+ if (isSafetyControlPath(policyPath, policyCwd)) {
405
+ return "auto-mode or permission safety-control modification is hard-denied";
406
+ }
407
+ }
408
+
409
+ if (toolName !== "bash") return undefined;
410
+ const command = typeof input.command === "string" ? input.command : "";
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) {
425
+ const reason = segmentHardDeny(segment, cwd);
426
+ if (reason) return reason;
427
+ }
428
+ return undefined;
429
+ }