@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.
- package/CHANGELOG.md +81 -0
- package/LICENSE.md +22 -0
- package/README.md +262 -0
- package/docs/GLOSSARY.md +41 -0
- package/docs/adr/ADR-001-permission-precedence-and-trust-boundaries.md +46 -0
- package/docs/adr/ADR-002-global-config-in-extension-data-directory.md +60 -0
- package/docs/adr/INDEX.md +6 -0
- package/docs/automode-classifier-flow.md +449 -0
- package/docs/configuration.md +226 -0
- package/docs/defaults.md +178 -0
- package/docs/diagnostics.md +90 -0
- package/docs/observability-logging.md +160 -0
- package/examples/automode.local.json +45 -0
- package/extensions/auto-mode/bash.ts +692 -0
- package/extensions/auto-mode/classifier.ts +940 -0
- package/extensions/auto-mode/config.ts +948 -0
- package/extensions/auto-mode/constants.ts +232 -0
- package/extensions/auto-mode/extension.ts +1118 -0
- package/extensions/auto-mode/hard-deny.ts +429 -0
- package/extensions/auto-mode/jev.ts +338 -0
- package/extensions/auto-mode/log.ts +173 -0
- package/extensions/auto-mode/model-selector.ts +113 -0
- package/extensions/auto-mode/model.ts +13 -0
- package/extensions/auto-mode/paths.ts +303 -0
- package/extensions/auto-mode/permissions.ts +667 -0
- package/extensions/auto-mode/state.ts +106 -0
- package/extensions/auto-mode/transcript.ts +236 -0
- package/extensions/auto-mode/types.ts +210 -0
- package/extensions/auto-mode/utils.ts +54 -0
- package/extensions/auto-mode.ts +27 -0
- package/package.json +61 -0
- package/skills/automode-diagnostics/SKILL.md +63 -0
|
@@ -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
|
+
}
|