@yaosu/pi-path-guard 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,1287 @@
1
+ /**
2
+ * Path Guard Extension v3 — protects against accidental deletes / overwrites / edits
3
+ *
4
+ * Built on path-guard-p620.ts, merging strengths from path-guard-ayydesk.ts and fixing known gaps:
5
+ *
6
+ * v2 additions over p620:
7
+ * 1. Prefix-command stripping (sudo/doas/pkexec/env/nohup/command/builtin/time/nice/xargs/
8
+ * timeout/setsid/stdbuf/ionice/chroot/watch) → analyze the real command.
9
+ * Fixes p620 letting "timeout 5 rm -rf /etc", "nohup rm -rf x" etc. through.
10
+ * 2. Git destructive-command checks (clean -f / reset --hard / checkout -- . / restore . /
11
+ * branch -D / push --force / stash drop), honoring -C/-c global option prefixes.
12
+ * Fixes p620 only blocking git clean.
13
+ * 3. Block-device redirect regex without the  boundary, fixing "echo x > /dev/sda" (spaced) misses.
14
+ * 4. realpath resolution upgraded to "walk up to nearest existing ancestor" (ayydesk approach),
15
+ * fixing deep missing paths being written through symlinks to outside the project.
16
+ * 5. mv/cp/install/tee/ln -f/rsync existing-target overwrite detection (incl. -t target form,
17
+ * tee -a append exemption, rsync --delete confirmation) — in-project overwrite → confirm,
18
+ * outside → block.
19
+ * 6. "> existing file" truncate detection (incl. 2> / &>, excluding >> append and devices) → confirm.
20
+ * 7. write/edit resolves the real cwd path before judging in/out, fixing false positives where
21
+ * an in-project write under a symlinked cwd was misjudged as outside.
22
+ *
23
+ * All p620 capabilities retained:
24
+ * - Protected-path interception (.env / .ssh / keys / credentials, regardless of project)
25
+ * - Shell wrapper recursion (bash -c / eval, depth-limited); source / . conservative confirm
26
+ * - dd / curl -o / wget -O / truncate / sed -i / perl -i / ruby -i / unzip -o judgment
27
+ * - Dangerous command regexes (sudo / chmod 777 / ssh / find -delete / mkfs etc.)
28
+ * - Compound-command segmentation aggregation, fail-safe: any hard block blocks everything
29
+ * - Quote-aware tokenization (single/double quotes handled correctly)
30
+ *
31
+ * v3 adds (upgrade from v2): guard modes (/guard command, switchable per session; new sessions
32
+ * reset to normal)
33
+ * - strict full protection: in-project writes also prompt; Confirm-group commands blocked
34
+ * - normal default: Block-group commands blocked directly, Confirm group prompts
35
+ * - loose relaxed: in/out-project creates and deletes pass without prompting (overwrites of
36
+ * existing targets still confirmed)
37
+ * - trusted most permissive: overwrites and ordinary-file deletes pass too
38
+ * - Protected paths (credentials/config/keys) and the Block group (format/shutdown/bulk-delete/
39
+ * block-device writes) are blocked directly in every mode, with no confirmation opportunity
40
+ */
41
+
42
+ import type {
43
+ ExtensionAPI,
44
+ ExtensionContext,
45
+ ExtensionCommandContext,
46
+ BashToolInput,
47
+ EditToolInput,
48
+ WriteToolInput,
49
+ ToolCallEventResult,
50
+ } from "@earendil-works/pi-coding-agent";
51
+ import {
52
+ resolve,
53
+ normalize,
54
+ relative as relativePath,
55
+ join,
56
+ dirname,
57
+ basename,
58
+ sep,
59
+ } from "node:path";
60
+ import { homedir } from "node:os";
61
+ import { realpathSync, existsSync, statSync } from "node:fs";
62
+
63
+ // ─── Configuration ──────────────────────────────────────────────────────
64
+
65
+ /** Protected path fragments — matching paths block writes/edits */
66
+ const PROTECTED_PATH_PATTERNS = [
67
+ ".env",
68
+ ".git/",
69
+ ".ssh/", // SSH config & keys
70
+ // HOME-level credentials/config (intercepted for bash redirects, overwrites, and the write tool)
71
+ ".aws/", // AWS credentials
72
+ ".kube/", // Kubernetes admin config
73
+ ".docker/", // Docker login credentials
74
+ ".gnupg/", // GPG keys
75
+ ".git-credentials", // plaintext git credentials
76
+ ".npmrc", // npm tokens
77
+ ".pypirc", // PyPI tokens
78
+ ".netrc", // generic login credentials
79
+ ".bashrc", // shell config (persistence/backdoor vector)
80
+ ".zshrc",
81
+ ".profile",
82
+ ".bash_profile",
83
+ "credentials", // in-project credential files
84
+ "*.pem", // private keys (suffix match)
85
+ "*.key", // private keys (suffix match)
86
+ "node_modules/",
87
+ ".next/",
88
+ ".nuxt/",
89
+ ".cache/",
90
+ "dist/",
91
+ "build/",
92
+ "coverage/",
93
+ "__pycache__/",
94
+ ".pytest_cache/",
95
+ "target/",
96
+ "vendor/", // Go vendor / PHP composer
97
+ ];
98
+
99
+ /** Block group — system-destructive; blocked in every mode (no confirmation opportunity) */
100
+ const BLOCK_DANGEROUS_PATTERNS: RegExp[] = [
101
+ /\bmkfs\./,
102
+ /\bmkswap\b/,
103
+ /\bpoweroff\b/,
104
+ /\breboot\b/,
105
+ /\bshutdown\b/,
106
+ /\binit\s+0\b/,
107
+ /\binit\s+6\b/,
108
+ /\bdd\b[^;|&]*\bof=\s*\/dev\/(sda|sdb|sdc|nvme|mmcblk)/, // dd writing directly to a block device (ordinary files handled by judgeDd)
109
+ /(>|>>)\s*\/dev\/(sda|sdb|sdc|nvme|mmcblk)/, // direct write to a block device (note: no \b — > is often preceded by a space)
110
+ /\bfind\b[^;|&]*-delete\b/, // find ... -delete bulk delete
111
+ /\bfind\b[^;|&]*-exec(dir)?\b[^;|&]*\brm\b/, // find ... -exec rm bulk delete
112
+ /\bxargs\b[^;|&]*\brm\b/, // xargs rm bulk delete (backup beyond judgeGit)
113
+ ];
114
+
115
+ /** Confirm group — privilege escalation / remote / risky permissions: blocked in strict, confirmed otherwise */
116
+ const CONFIRM_DANGEROUS_PATTERNS: RegExp[] = [
117
+ /\bsudo\b/,
118
+ /\b(doas|pkexec)\b/,
119
+ /\b(chmod|chown)\b.*777/,
120
+ /(?<!\.)\b(ssh|scp|sftp|rsh|telnet)\b/, // remote execution/operation (lookbehind avoids false hits on ~/.ssh/ etc.)
121
+ /\bwget\s+-O\s+\/dev\/null\b/, // download discarded directly (harmless but conservative)
122
+ ];
123
+
124
+ /** Delete commands requiring special handling */
125
+ const DELETE_COMMANDS = new Set(["rm", "rmdir", "unlink", "shred", "wipe"]);
126
+
127
+ /** Overwrite commands — overwrite existing targets by default (ln needs -f/--force; handled separately) */
128
+ const OVERWRITE_COMMANDS = new Set([
129
+ "mv",
130
+ "cp",
131
+ "install",
132
+ "tee",
133
+ "ln",
134
+ "rsync",
135
+ ]);
136
+
137
+ /** In-place edit commands (-i rewrites in place) */
138
+ const INPLACE_EDITORS = new Set(["sed", "perl", "ruby"]);
139
+
140
+ /** Shell wrappers: the -c argument is inline code that needs recursive checking */
141
+ const SHELL_WRAPPERS = new Set([
142
+ "bash",
143
+ "sh",
144
+ "zsh",
145
+ "ksh",
146
+ "dash",
147
+ "fish",
148
+ "csh",
149
+ "tcsh",
150
+ ]);
151
+
152
+ /** Prefix commands: strip before checking the real command */
153
+ const PREFIX_COMMANDS = new Set([
154
+ "sudo",
155
+ "doas",
156
+ "pkexec",
157
+ "env",
158
+ "nohup",
159
+ "command",
160
+ "builtin",
161
+ "time",
162
+ "nice",
163
+ "xargs",
164
+ "timeout",
165
+ "setsid",
166
+ "stdbuf",
167
+ "ionice",
168
+ "chroot",
169
+ "watch",
170
+ ]);
171
+
172
+ /** Prefix flags that take a value (skip one extra token when stripping) */
173
+ const FLAGS_WITH_ARG = new Set(["-u", "--user", "-g", "--group"]);
174
+
175
+ /** Redirecting to these devices is not a destructive truncate */
176
+ const DEVICE_TARGETS = new Set([
177
+ "/dev/null",
178
+ "/dev/stdout",
179
+ "/dev/stderr",
180
+ "/dev/tty",
181
+ "/dev/zero",
182
+ ]);
183
+
184
+ const HOME = homedir();
185
+
186
+ // ─── Guard Modes ─────────────────────────────────────────────────────
187
+
188
+ /** Guard mode: strict (full) / normal (default) / loose (relaxed) / trusted (most permissive) */
189
+ type GuardMode = "strict" | "normal" | "loose" | "trusted";
190
+
191
+ /** Current session guard mode (switched via /guard; reset to normal on session_start) */
192
+ let currentMode: GuardMode = "normal";
193
+
194
+ /** Valid guard modes */
195
+ const GUARD_MODES: readonly GuardMode[] = [
196
+ "strict",
197
+ "normal",
198
+ "loose",
199
+ "trusted",
200
+ ];
201
+
202
+ /** Whether a string is a valid guard mode (for /guard argument validation) */
203
+ function isGuardMode(m: string): m is GuardMode {
204
+ return (GUARD_MODES as readonly string[]).includes(m);
205
+ }
206
+
207
+ /** Mode descriptions (shown in the /guard interactive picker; English first, Chinese brief after) */
208
+ const MODE_DESCRIPTIONS: Record<GuardMode, string> = {
209
+ strict:
210
+ "Strict: confirm in-project writes, block dangerous commands / 全防护:项目内写也询问,危险命令直接阻止",
211
+ normal:
212
+ "Normal: block system-destructive commands, confirm sudo/ssh / 默认:系统级破坏直接阻止,提权/远程询问",
213
+ loose:
214
+ "Loose: pass new-file writes & deletes, confirm overwrites / 放宽:新建/删除免问,覆盖需确认",
215
+ trusted:
216
+ "Trusted: pass overwrites & ordinary-file deletes / 最宽松:覆盖/删除普通文件也免问",
217
+ };
218
+
219
+ /** Full decision matrix (shown as the /guard picker title, English only) */
220
+ const MODE_MATRIX = [
221
+ "Path Guard Mode Matrix (B=block / ?=confirm / .=pass)",
222
+ " Checkpoint strict normal loose trusted",
223
+ " Protected paths .env/.ssh/keys B B B B",
224
+ " System-destructive mkfs/reboot B B B B",
225
+ " Privilege/remote sudo/ssh/chmod777 B ? ? ?",
226
+ " Git destructive reset --hard ? ? ? ?",
227
+ " In-project write/edit/new ? . . .",
228
+ " In-project delete ? ? . .",
229
+ " Outside write (new file) ? ? . .",
230
+ " Outside overwrite existing B B ? .",
231
+ " Outside delete ordinary B B ? .",
232
+ " Truncate existing > file ? ? ? ?",
233
+ " HOME dir write ? ? . .",
234
+ " No UI (headless) B B B B",
235
+ ].join("\n");
236
+
237
+ /** Guard verdict: { block, reason } to block / undefined to allow (askConfirm returns a Promise) */
238
+ type GuardVerdict =
239
+ | ToolCallEventResult
240
+ | undefined
241
+ | Promise<ToolCallEventResult | undefined>;
242
+
243
+ // ─── Entry ────────────────────────────────────────────────────────────
244
+
245
+ export default function (pi: ExtensionAPI) {
246
+ // Reset to normal on every new session (startup, /new, /resume all fire session_start)
247
+ pi.on("session_start", () => {
248
+ currentMode = "normal";
249
+ });
250
+
251
+ // /guard slash command: view / switch guard mode
252
+ pi.registerCommand("guard", {
253
+ description:
254
+ "Path Guard modes: /guard shows the current mode, /guard <strict|normal|loose|trusted> switches",
255
+ handler: async (args, ctx) => {
256
+ const m = args?.trim().toLowerCase() ?? "";
257
+
258
+ // Valid argument → switch directly (shortcut, no picker); trusted requires a warning confirmation
259
+ if (isGuardMode(m)) {
260
+ if (m === "trusted" && !(await confirmTrustedSwitch(ctx))) {
261
+ ctx.ui.notify(
262
+ "Cancelled: switching to trusted requires confirmation",
263
+ "info",
264
+ );
265
+ return;
266
+ }
267
+ currentMode = m;
268
+ ctx.ui.notify(
269
+ `Path Guard switched to: ${m} (session-only; new sessions reset to normal)`,
270
+ "info",
271
+ );
272
+ return;
273
+ }
274
+
275
+ // No UI → cannot interact; just show the current mode
276
+ if (!ctx.hasUI) {
277
+ ctx.ui.notify(`Path Guard current mode: ${currentMode}`, "info");
278
+ return;
279
+ }
280
+
281
+ // Interactive picker (fallback for no/invalid arg): matrix as title, mode choices
282
+ const choices = GUARD_MODES.map(
283
+ (mo) =>
284
+ `${mo} — ${MODE_DESCRIPTIONS[mo]}${mo === currentMode ? " (current)" : ""}`,
285
+ );
286
+ const chosen = await ctx.ui.select(
287
+ `${MODE_MATRIX}\n\nCurrent mode: ${currentMode} — choose one:`,
288
+ choices,
289
+ );
290
+ if (!chosen) {
291
+ ctx.ui.notify("Cancelled, mode unchanged", "info");
292
+ return;
293
+ }
294
+ const picked = chosen.split(/\s+/)[0] as GuardMode;
295
+ if (isGuardMode(picked)) {
296
+ if (picked === "trusted" && !(await confirmTrustedSwitch(ctx))) {
297
+ ctx.ui.notify(
298
+ "Cancelled: switching to trusted requires confirmation",
299
+ "info",
300
+ );
301
+ return;
302
+ }
303
+ currentMode = picked;
304
+ ctx.ui.notify(
305
+ `Path Guard switched to: ${picked} (session-only; new sessions reset to normal)`,
306
+ "info",
307
+ );
308
+ }
309
+ },
310
+ });
311
+
312
+ pi.on("tool_call", (event, ctx) => {
313
+ // ── write / edit ──────────────────────────────────────────
314
+ if (event.toolName === "write" || event.toolName === "edit") {
315
+ return checkWriteEdit(event.input as WriteToolInput | EditToolInput, ctx);
316
+ }
317
+
318
+ // ── bash ────────────────────────────────────────────────
319
+ if (event.toolName === "bash") {
320
+ return checkBashCommand(event.input as BashToolInput, ctx);
321
+ }
322
+ });
323
+ }
324
+
325
+ /** write/edit guard: protected → block; outside project / cwd is HOME → confirm */
326
+ function checkWriteEdit(
327
+ input: WriteToolInput | EditToolInput,
328
+ ctx: ExtensionContext,
329
+ ): GuardVerdict {
330
+ const path = input.path;
331
+ if (!path) return;
332
+
333
+ // Resolve the real cwd first (cwd may itself be a symlink), then the real target path,
334
+ // preventing symlink escape to protected locations and symlink-cwd false positives
335
+ const realCwd = resolveReal(ctx.cwd);
336
+ const real = resolveReal(resolve(realCwd, expandHome(path)));
337
+
338
+ // ① Protected path (incl. HOME-level credentials/config, inside or outside project) → block
339
+ if (matchesProtectedPath(real)) {
340
+ return {
341
+ block: true,
342
+ reason: `Path "${real}" is protected; write blocked.`,
343
+ };
344
+ }
345
+
346
+ // ② Outside the project dir → strict/normal confirm; loose/trusted pass (judged on the real path, so symlink escape also matches)
347
+ if (isOutsideCwd(real, realCwd)) {
348
+ if (currentMode === "loose" || currentMode === "trusted") return;
349
+ return askConfirm(
350
+ ctx,
351
+ `⚠️ File path is outside the project directory\n\nPath: ${real}\nProject: ${realCwd}`,
352
+ );
353
+ }
354
+
355
+ // ③ cwd is HOME (write lands under HOME) → strict/normal confirm; loose/trusted pass
356
+ if (realCwd === HOME) {
357
+ if (currentMode === "loose" || currentMode === "trusted") return;
358
+ return askConfirm(
359
+ ctx,
360
+ `⚠️ Write operation in HOME directory\n\nPath: ${real}\nHOME: ${HOME}\n\nConfirm write?`,
361
+ );
362
+ }
363
+
364
+ // ④ In-project → strict prompts for everything; other modes pass
365
+ if (currentMode === "strict") {
366
+ return askConfirm(
367
+ ctx,
368
+ `⚠️ strict mode: in-project write operation\n\nPath: ${real}\n\nConfirm write?`,
369
+ );
370
+ }
371
+
372
+ return; // In-project and safe: allow
373
+ }
374
+
375
+ /** bash guard: scan segments then decide once (prevents "rm -rf safe && sudo reboot" segment bypass) */
376
+ function checkBashCommand(
377
+ input: BashToolInput,
378
+ ctx: ExtensionContext,
379
+ ): GuardVerdict {
380
+ const command = input.command ?? "";
381
+ if (!command.trim()) return;
382
+
383
+ const realCwd = resolveReal(ctx.cwd);
384
+
385
+ // Split by &&, ||, ;, |, newline; check each segment and aggregate results,
386
+ // then decide once — so an early return from the first guarded segment can't skip later ones
387
+ const blockReasons: string[] = [];
388
+ const confirmNeeded: string[] = [];
389
+
390
+ for (const seg of splitSegments(command)) {
391
+ const trimmed = seg.trim();
392
+ if (!trimmed) continue;
393
+
394
+ const verdict = classifySegment(trimmed, realCwd, ctx.hasUI);
395
+ if (verdict.kind === "block") {
396
+ blockReasons.push(verdict.reason);
397
+ } else if (verdict.kind === "confirm") {
398
+ confirmNeeded.push(trimmed);
399
+ }
400
+ }
401
+
402
+ // Aggregate: any hard block → block everything (fail-safe)
403
+ if (blockReasons.length > 0) {
404
+ return {
405
+ block: true,
406
+ reason: `Command blocked:\n${blockReasons.join("\n")}`,
407
+ };
408
+ }
409
+ // Segments needing confirmation → one prompt, confirm together
410
+ if (confirmNeeded.length > 0) {
411
+ return askConfirm(
412
+ ctx,
413
+ `⚠️ Commands requiring confirmation\n\n${confirmNeeded
414
+ .map((s) => `· ${s}`)
415
+ .join("\n")}\n\nConfirm execution?`,
416
+ );
417
+ }
418
+ return; // Safe command: allow
419
+ }
420
+
421
+ /** Verdict for a single segment */
422
+ type SegmentVerdict =
423
+ | { kind: "block"; reason: string }
424
+ | { kind: "confirm" }
425
+ | { kind: "pass" };
426
+
427
+ /** Per-segment check: protected redirect → block; dangerous commands → confirm/block; the rest to sub-judges / wrapper recursion */
428
+ function classifySegment(
429
+ trimmed: string,
430
+ realCwd: string,
431
+ hasUI: boolean,
432
+ depth = 0,
433
+ ): SegmentVerdict {
434
+ // Recursion depth guard (bash -c / eval nested too deep to statically check → conservative confirm)
435
+ if (depth > 4) return { kind: "confirm" };
436
+
437
+ // ① Redirect check:
438
+ // - Write to a protected path (echo x > .env etc.) → block
439
+ // - "> existing file" (truncate, not >> append, not a device) → confirm
440
+ const redirect = extractRedirectTarget(trimmed);
441
+ if (redirect) {
442
+ const real = resolveReal(resolve(realCwd, expandHome(redirect.target)));
443
+ if (matchesProtectedPath(real)) {
444
+ return {
445
+ kind: "block",
446
+ reason: `Redirect writes to protected path: ${trimmed}`,
447
+ };
448
+ }
449
+ if (
450
+ isTruncatingOp(redirect.op) &&
451
+ !DEVICE_TARGETS.has(redirect.target) &&
452
+ existsSync(real)
453
+ ) {
454
+ return { kind: "confirm" };
455
+ }
456
+ }
457
+
458
+ // ② Dangerous commands:
459
+ // - Block group (format/shutdown/bulk-delete/block-device writes) → blocked in every mode
460
+ // - Confirm group (sudo/ssh/chmod 777) → blocked in strict, confirmed otherwise
461
+ const danger = dangerousLevel(trimmed);
462
+ if (danger === "block") {
463
+ return {
464
+ kind: "block",
465
+ reason: `System-destructive command blocked: ${trimmed}`,
466
+ };
467
+ }
468
+ if (danger === "confirm") {
469
+ if (currentMode === "strict") {
470
+ return {
471
+ kind: "block",
472
+ reason: `Dangerous command blocked (strict mode): ${trimmed}`,
473
+ };
474
+ }
475
+ return hasUI
476
+ ? { kind: "confirm" }
477
+ : {
478
+ kind: "block",
479
+ reason: `Dangerous command blocked (no interactive UI): ${trimmed}`,
480
+ };
481
+ }
482
+
483
+ const cmdInfo = parseCommand(trimmed);
484
+ if (!cmdInfo) return { kind: "pass" };
485
+
486
+ // ③ Shell wrapper (bash -c 'code' / eval 'code') → recursively check the inner code
487
+ const wrapperVerdict = judgeShellWrapper(
488
+ trimmed,
489
+ cmdInfo,
490
+ realCwd,
491
+ hasUI,
492
+ depth,
493
+ );
494
+ if (wrapperVerdict.kind !== "pass") return wrapperVerdict;
495
+
496
+ // ④ source / .: runs a script file whose contents can't be statically analyzed → conservative confirm
497
+ if (cmdInfo.command === "source" || cmdInfo.command === ".") {
498
+ return { kind: "confirm" };
499
+ }
500
+
501
+ // ⑤-⑪ Pipeline for target-writing commands (git / dd / download / truncate / in-place edit / delete / overwrite / unzip -o)
502
+ return judgeWriters(trimmed, cmdInfo, realCwd);
503
+ }
504
+
505
+ /** Shell wrapper verdict: recursively run the same checks on bash -c / eval inner code */
506
+ function judgeShellWrapper(
507
+ _trimmed: string,
508
+ cmdInfo: CmdInfo,
509
+ realCwd: string,
510
+ hasUI: boolean,
511
+ depth: number,
512
+ ): SegmentVerdict {
513
+ const inner = unwrapShellWrapper(cmdInfo);
514
+ if (!inner) return { kind: "pass" };
515
+
516
+ const blockReasons: string[] = [];
517
+ const confirmNeeded: string[] = [];
518
+ for (const seg of splitSegments(inner)) {
519
+ const s = seg.trim();
520
+ if (!s) continue;
521
+ const v = classifySegment(s, realCwd, hasUI, depth + 1);
522
+ if (v.kind === "block") blockReasons.push(v.reason);
523
+ else if (v.kind === "confirm") confirmNeeded.push(s);
524
+ }
525
+ if (blockReasons.length > 0) {
526
+ return {
527
+ kind: "block",
528
+ reason: `Inner command blocked:\n${blockReasons.join("\n")}`,
529
+ };
530
+ }
531
+ if (confirmNeeded.length > 0) return { kind: "confirm" };
532
+ return { kind: "pass" };
533
+ }
534
+
535
+ /** Target-writing pipeline: judge each; return on the first non-pass; allow only when all pass */
536
+ function judgeWriters(
537
+ trimmed: string,
538
+ cmdInfo: CmdInfo,
539
+ realCwd: string,
540
+ ): SegmentVerdict {
541
+ const pipeline: Array<(t: string, c: CmdInfo, r: string) => SegmentVerdict> = [
542
+ judgeGit,
543
+ judgeDd,
544
+ judgeDownload,
545
+ judgeTruncate,
546
+ judgeInPlace,
547
+ judgeDelete,
548
+ judgeOverwrite,
549
+ ];
550
+ for (const judge of pipeline) {
551
+ const v = judge(trimmed, cmdInfo, realCwd);
552
+ if (v.kind !== "pass") return v;
553
+ }
554
+ // Forced extraction overwrite (unzip -o): archive contents unknowable → conservative confirm
555
+ if (cmdInfo.command === "unzip" && hasShortFlag(cmdInfo.args, "o")) {
556
+ return { kind: "confirm" };
557
+ }
558
+ return { kind: "pass" };
559
+ }
560
+
561
+ /** git destructive commands: clean -f / reset --hard / checkout -- . / restore . / branch -D / push --force / stash drop */
562
+ function judgeGit(
563
+ _trimmed: string,
564
+ cmdInfo: CmdInfo,
565
+ _realCwd: string,
566
+ ): SegmentVerdict {
567
+ if (cmdInfo.command !== "git") return { kind: "pass" };
568
+
569
+ const args = cmdInfo.args;
570
+ // Skip git global options (-C dir / -c key=val / --git-dir= etc.), find the subcommand
571
+ let i = 0;
572
+ while (i < args.length) {
573
+ const a = args[i];
574
+ if (a === "-C" || a === "-c") {
575
+ i += 2;
576
+ continue;
577
+ }
578
+ if (
579
+ a.startsWith("--git-dir=") ||
580
+ a.startsWith("--work-tree=") ||
581
+ a === "--bare" ||
582
+ a === "--no-pager" ||
583
+ a === "--paginate"
584
+ ) {
585
+ i++;
586
+ continue;
587
+ }
588
+ break;
589
+ }
590
+ const sub = args[i];
591
+
592
+ if (sub === "clean" && hasForceFlag(args)) return { kind: "confirm" }; // -f/-fd/-fdx/--force
593
+ if (sub === "reset" && args.includes("--hard")) return { kind: "confirm" };
594
+ if (sub === "checkout" && (args.includes("--") || args.includes(".")))
595
+ return { kind: "confirm" };
596
+ if (sub === "restore" && (args.includes(".") || args.includes("--source")))
597
+ return { kind: "confirm" };
598
+ if (sub === "branch" && args.some((a) => a === "-D"))
599
+ return { kind: "confirm" };
600
+ if (
601
+ sub === "push" &&
602
+ args.some((a) => a === "-f" || a === "--force" || a === "--force-with-lease")
603
+ )
604
+ return { kind: "confirm" };
605
+ if (sub === "stash" && args.includes("drop")) return { kind: "confirm" };
606
+
607
+ return { kind: "pass" };
608
+ }
609
+
610
+ /** Delete-command verdict (rm, rmdir, shred, ...); non-delete commands → pass */
611
+ function judgeDelete(
612
+ trimmed: string,
613
+ cmdInfo: CmdInfo,
614
+ realCwd: string,
615
+ ): SegmentVerdict {
616
+ if (!isDeleteCommand(cmdInfo.command)) return { kind: "pass" };
617
+
618
+ // Query forms (command -v rm / rm --version etc., no path args) → pass
619
+ if (
620
+ cmdInfo.args.every((a) => a.startsWith("-")) &&
621
+ /(-v|-V|--version|-h|--help)\b/.test(trimmed)
622
+ ) {
623
+ return { kind: "pass" };
624
+ }
625
+
626
+ const pathArgs = extractPathArgs(cmdInfo.args, realCwd);
627
+
628
+ // Protected paths first: blocked in every mode (trusted filters protected before passing outside deletes)
629
+ for (const p of pathArgs) {
630
+ if (matchesProtectedPath(p.path)) {
631
+ return {
632
+ kind: "block",
633
+ reason: `Delete command targets protected path: ${p.path}`,
634
+ };
635
+ }
636
+ }
637
+
638
+ // No concrete path (rm "$HOME/.ssh", rm ./* — variable/wildcard, not statically resolvable) → conservative confirm (all modes)
639
+ if (pathArgs.length === 0) {
640
+ return { kind: "confirm" };
641
+ }
642
+
643
+ const externalPaths = pathArgs.filter((p) => p.isOutside);
644
+ if (externalPaths.length > 0) {
645
+ const list = externalPaths.map((p) => p.path).join(", ");
646
+ // trusted → pass (ordinary files, protected already filtered); loose → confirm; strict/normal → block
647
+ if (currentMode === "trusted") return { kind: "pass" };
648
+ if (currentMode === "loose") return { kind: "confirm" };
649
+ return {
650
+ kind: "block",
651
+ reason: `Delete command targets paths outside the project directory: ${list}`,
652
+ };
653
+ }
654
+
655
+ // In-project delete: strict/normal → confirm; loose/trusted → pass
656
+ if (currentMode === "loose" || currentMode === "trusted") {
657
+ return { kind: "pass" };
658
+ }
659
+ return { kind: "confirm" };
660
+ }
661
+
662
+ /**
663
+ * Overwrite-command verdict (mv/cp/install/tee/ln -f/rsync):
664
+ * - Target hits a protected path → block
665
+ * - Target exists (file, or dir with a basename conflict) → confirm in-project / block outside
666
+ * - Target missing → confirm outside write / pass in-project (pure rename/create)
667
+ * - -n/--no-clobber (explicit no-overwrite), ln without -f, tee -a (append) → pass
668
+ */
669
+ function judgeOverwrite(
670
+ _trimmed: string,
671
+ cmdInfo: CmdInfo,
672
+ realCwd: string,
673
+ ): SegmentVerdict {
674
+ if (!OVERWRITE_COMMANDS.has(cmdInfo.command)) return { kind: "pass" };
675
+
676
+ // ln only overwrites existing targets with -f/--force
677
+ if (cmdInfo.command === "ln" && !hasForceFlag(cmdInfo.args)) {
678
+ return { kind: "pass" };
679
+ }
680
+ // -n/--no-clobber: explicit no-overwrite, safe to pass
681
+ if (cmdInfo.args.includes("-n") || cmdInfo.args.includes("--no-clobber")) {
682
+ return { kind: "pass" };
683
+ }
684
+ // tee -a / --append: append, no overwrite
685
+ if (
686
+ cmdInfo.command === "tee" &&
687
+ (cmdInfo.args.includes("-a") || cmdInfo.args.includes("--append"))
688
+ ) {
689
+ return { kind: "pass" };
690
+ }
691
+ // rsync --delete: removes extra files in the target dir → conservative confirm
692
+ if (cmdInfo.command === "rsync" && cmdInfo.args.includes("--delete")) {
693
+ return { kind: "confirm" };
694
+ }
695
+
696
+ // Resolve target: -t dir src... form vs the regular form (last operand is the target)
697
+ let target: string | null = null;
698
+ let sources: string[] = [];
699
+ const tIdx = cmdInfo.args.indexOf("-t");
700
+ if (tIdx >= 0 && cmdInfo.args[tIdx + 1]) {
701
+ target = cmdInfo.args[tIdx + 1];
702
+ sources = cmdInfo.args.filter((a) => !a.startsWith("-") && a !== target);
703
+ } else {
704
+ const operands = cmdInfo.args.filter((a) => !a.startsWith("-"));
705
+ if (operands.length >= 2) {
706
+ target = operands[operands.length - 1];
707
+ sources = operands.slice(0, -1);
708
+ }
709
+ }
710
+ if (!target || sources.length === 0) return { kind: "pass" };
711
+
712
+ // Variable/wildcard not statically resolvable → conservative confirm
713
+ if (target.startsWith("$") || target.includes("*") || target.includes("?")) {
714
+ return { kind: "confirm" };
715
+ }
716
+
717
+ const real = resolveReal(resolve(realCwd, expandHome(target)));
718
+ // ① Target hits a protected path → block
719
+ if (matchesProtectedPath(real)) {
720
+ return {
721
+ kind: "block",
722
+ reason: `Command may overwrite protected path: ${cmdInfo.command} ${target}`,
723
+ };
724
+ }
725
+
726
+ const outside = isOutsideCwd(real, realCwd);
727
+
728
+ // Outside overwrite of an existing target: normal/strict → block; loose → confirm; trusted → pass
729
+ const outsideOverwriteVerdict = (): SegmentVerdict => {
730
+ if (currentMode === "trusted") return { kind: "pass" };
731
+ if (currentMode === "loose") return { kind: "confirm" };
732
+ return {
733
+ kind: "block",
734
+ reason: `Command will overwrite a target outside the project directory: ${cmdInfo.command} ${target}`,
735
+ };
736
+ };
737
+
738
+ // ② Target is an existing directory: check each source basename for conflicts
739
+ if (existsSync(real) && isDirectory(real)) {
740
+ const conflict = sources.some((s) => {
741
+ // Source not statically resolvable → treat as a conflict
742
+ if (s.startsWith("$") || s.includes("*") || s.includes("?")) return true;
743
+ const srcReal = resolveReal(resolve(realCwd, expandHome(s)));
744
+ return existsSync(join(real, basename(srcReal)));
745
+ });
746
+ if (!conflict) return { kind: "pass" };
747
+ // Overwriting an existing target: outside per mode, in-project confirm (overwrites are never silently passed)
748
+ return outside ? outsideOverwriteVerdict() : { kind: "confirm" };
749
+ }
750
+
751
+ // ③ Target is an existing file: will be overwritten
752
+ if (existsSync(real)) {
753
+ return outside ? outsideOverwriteVerdict() : { kind: "confirm" };
754
+ }
755
+
756
+ // ④ Target missing: outside → normal/strict confirm, loose/trusted pass;
757
+ // in-project → strict confirm, others pass (pure rename/create)
758
+ if (outside) {
759
+ return currentMode === "loose" || currentMode === "trusted"
760
+ ? { kind: "pass" }
761
+ : { kind: "confirm" };
762
+ }
763
+ return currentMode === "strict" ? { kind: "confirm" } : { kind: "pass" };
764
+ }
765
+
766
+ /** Unwrap a shell wrapper: bash/sh/zsh -c 'code', eval 'code' → inner code; else null */
767
+ function unwrapShellWrapper(cmdInfo: CmdInfo): string | null {
768
+ if (SHELL_WRAPPERS.has(cmdInfo.command)) {
769
+ for (let i = 0; i < cmdInfo.args.length; i++) {
770
+ const a = cmdInfo.args[i];
771
+ if (a === "--") break; // everything after is not a flag
772
+ // short flag contains c (-c, -ec combos); long flags don't count
773
+ if (a.startsWith("-") && !a.startsWith("--") && a.includes("c")) {
774
+ const inner = cmdInfo.args.slice(i + 1).join(" ");
775
+ return inner.trim() || null;
776
+ }
777
+ }
778
+ return null;
779
+ }
780
+ if (cmdInfo.command === "eval") {
781
+ const inner = cmdInfo.args.join(" ");
782
+ return inner.trim() || null;
783
+ }
784
+ return null;
785
+ }
786
+
787
+ /** Whether args contain a short flag (supports -i.bak / -pi combos; single-dash only) */
788
+ function hasShortFlag(args: string[], ch: string): boolean {
789
+ return args.some(
790
+ (a) => a.startsWith("-") && !a.startsWith("--") && a.slice(1).includes(ch),
791
+ );
792
+ }
793
+
794
+ /** Whether args contain a long flag (--name or --name=value) */
795
+ function hasLongFlag(args: string[], name: string): boolean {
796
+ return args.some((a) => a === `--${name}` || a.startsWith(`--${name}=`));
797
+ }
798
+
799
+ /** dd verdict: of= pointing at a protected file → block (block-device writes covered by dangerous patterns) */
800
+ function judgeDd(
801
+ trimmed: string,
802
+ cmdInfo: CmdInfo,
803
+ realCwd: string,
804
+ ): SegmentVerdict {
805
+ if (cmdInfo.command !== "dd") return { kind: "pass" };
806
+ for (const a of cmdInfo.args) {
807
+ if (!a.startsWith("of=")) continue;
808
+ const target = a.slice(3);
809
+ if (!target) continue;
810
+ if (target.startsWith("$") || target.includes("*") || target.includes("?")) {
811
+ return { kind: "confirm" };
812
+ }
813
+ const real = resolveReal(resolve(realCwd, expandHome(target)));
814
+ if (matchesProtectedPath(real)) {
815
+ return { kind: "block", reason: `dd writes to protected path: ${trimmed}` };
816
+ }
817
+ }
818
+ return { kind: "pass" };
819
+ }
820
+
821
+ /** curl/wget verdict: output target hits a protected path → block */
822
+ function judgeDownload(
823
+ _trimmed: string,
824
+ cmdInfo: CmdInfo,
825
+ realCwd: string,
826
+ ): SegmentVerdict {
827
+ if (cmdInfo.command !== "curl" && cmdInfo.command !== "wget") {
828
+ return { kind: "pass" };
829
+ }
830
+ const target = downloadTarget(cmdInfo.command, cmdInfo.args);
831
+ if (!target) return { kind: "pass" };
832
+ if (target.startsWith("$") || target.includes("*") || target.includes("?")) {
833
+ return { kind: "confirm" };
834
+ }
835
+ const real = resolveReal(resolve(realCwd, expandHome(target)));
836
+ if (matchesProtectedPath(real)) {
837
+ return {
838
+ kind: "block",
839
+ reason: `Download writes to protected path: ${cmdInfo.command} ${target}`,
840
+ };
841
+ }
842
+ return { kind: "pass" };
843
+ }
844
+
845
+ /** Extract the download output target; null if none explicit */
846
+ function downloadTarget(command: string, args: string[]): string | null {
847
+ return command === "wget"
848
+ ? wgetDownloadTarget(args)
849
+ : curlDownloadTarget(args);
850
+ }
851
+
852
+ /** wget output target (-O / --output / --output-document all take an argument) */
853
+ function wgetDownloadTarget(args: string[]): string | null {
854
+ for (let i = 0; i < args.length; i++) {
855
+ const a = args[i];
856
+ if (a === "-O" || a === "--output" || a === "--output-document") {
857
+ return args[i + 1] ?? null;
858
+ }
859
+ if (a.startsWith("--output=") || a.startsWith("--output-document=")) {
860
+ return a.slice(a.indexOf("=") + 1);
861
+ }
862
+ }
863
+ return null;
864
+ }
865
+
866
+ /** curl output target (-o / --output take an argument; -O has none, uses the URL basename) */
867
+ function curlDownloadTarget(args: string[]): string | null {
868
+ for (let i = 0; i < args.length; i++) {
869
+ const a = args[i];
870
+ if (a === "-o" || a === "--output" || a === "--output-document") {
871
+ return args[i + 1] ?? null;
872
+ }
873
+ if (a.startsWith("--output=") || a.startsWith("--output-document=")) {
874
+ return a.slice(a.indexOf("=") + 1);
875
+ }
876
+ if (a === "-O") {
877
+ for (let j = i + 1; j < args.length; j++) {
878
+ const u = args[j];
879
+ if (u.startsWith("-")) continue;
880
+ const base = u.split("/").pop();
881
+ if (base) return base;
882
+ break;
883
+ }
884
+ }
885
+ }
886
+ return null;
887
+ }
888
+
889
+ /** truncate verdict: target hits a protected path → block; existing non-device target → confirm */
890
+ function judgeTruncate(
891
+ _trimmed: string,
892
+ cmdInfo: CmdInfo,
893
+ realCwd: string,
894
+ ): SegmentVerdict {
895
+ if (cmdInfo.command !== "truncate") return { kind: "pass" };
896
+ // Any target not statically resolvable (variable/wildcard) → conservative confirm
897
+ if (
898
+ cmdInfo.args.some(
899
+ (a) =>
900
+ !a.startsWith("-") &&
901
+ (a.startsWith("$") || a.includes("*") || a.includes("?")),
902
+ )
903
+ ) {
904
+ return { kind: "confirm" };
905
+ }
906
+ for (const t of extractPathArgs(cmdInfo.args, realCwd)) {
907
+ if (matchesProtectedPath(t.path)) {
908
+ return {
909
+ kind: "block",
910
+ reason: `truncate truncates protected path: ${t.raw}`,
911
+ };
912
+ }
913
+ // Existing ordinary file truncated → confirm (prevent accidental overwrite)
914
+ if (!DEVICE_TARGETS.has(t.path) && existsSync(t.path)) {
915
+ return { kind: "confirm" };
916
+ }
917
+ }
918
+ return { kind: "pass" };
919
+ }
920
+
921
+ /** In-place edit verdict (sed -i / perl -i / ruby -i): target hits a protected path → block */
922
+ function judgeInPlace(
923
+ _trimmed: string,
924
+ cmdInfo: CmdInfo,
925
+ realCwd: string,
926
+ ): SegmentVerdict {
927
+ if (!INPLACE_EDITORS.has(cmdInfo.command)) return { kind: "pass" };
928
+ if (
929
+ !hasShortFlag(cmdInfo.args, "i") &&
930
+ !hasLongFlag(cmdInfo.args, "in-place")
931
+ ) {
932
+ return { kind: "pass" };
933
+ }
934
+ // sed syntax: sed -i 'script' file — target file is last (multi-file: only the last is checked; conservative enough)
935
+ const dest = lastDestArg(cmdInfo.args);
936
+ if (!dest) return { kind: "pass" };
937
+ if (dest.startsWith("$") || dest.includes("*") || dest.includes("?")) {
938
+ return { kind: "confirm" };
939
+ }
940
+ const real = resolveReal(resolve(realCwd, expandHome(dest)));
941
+ if (matchesProtectedPath(real)) {
942
+ return {
943
+ kind: "block",
944
+ reason: `In-place edit of protected path: ${cmdInfo.command} ${dest}`,
945
+ };
946
+ }
947
+ return { kind: "pass" };
948
+ }
949
+
950
+ // ─── Path Utils ───────────────────────────────────────────────────────
951
+
952
+ /** Whether an absolute path is outside cwd */
953
+ function isOutsideCwd(absolutePath: string, cwd: string): boolean {
954
+ const normCwd = normalize(cwd);
955
+ const normPath = normalize(absolutePath);
956
+ if (normPath === normCwd) return false;
957
+ const rel = relativePath(normCwd, normPath);
958
+ return rel.startsWith("..") || rel === normPath;
959
+ }
960
+
961
+ /** Protected-path match regardless of in/out project (used by bash redirect/overwrite checks and the write guard) */
962
+ function matchesProtectedPath(absolutePath: string): boolean {
963
+ const segments = normalize(absolutePath).toLowerCase().split(sep);
964
+
965
+ for (const pattern of PROTECTED_PATH_PATTERNS) {
966
+ const pat = pattern.toLowerCase();
967
+ const isDir = pat.endsWith("/");
968
+ const core = isDir ? pat.slice(0, -1) : pat;
969
+
970
+ // Suffix patterns (*.pem, *.key): match any path segment
971
+ if (core.startsWith("*.")) {
972
+ const suffix = core.slice(1);
973
+ if (segments.some((seg) => seg.endsWith(suffix))) return true;
974
+ continue;
975
+ }
976
+
977
+ for (let i = 0; i < segments.length; i++) {
978
+ const seg = segments[i];
979
+ if (seg === core) {
980
+ // Dir patterns (.git/, node_modules/, etc.) match any directory segment;
981
+ // file patterns (.env) only match the last segment
982
+ if (isDir || i === segments.length - 1) return true;
983
+ }
984
+ // File-pattern variants (.env.local / .env.production, last segment)
985
+ if (!isDir && i === segments.length - 1 && seg.startsWith(core + ".")) {
986
+ return true;
987
+ }
988
+ }
989
+ }
990
+ return false;
991
+ }
992
+
993
+ /**
994
+ * Dangerous command classification:
995
+ * - "block" → system-destructive (format/shutdown/bulk-delete/block-device writes), blocked in every mode
996
+ * - "confirm" → privilege/remote/risky (sudo/ssh/chmod 777), blocked in strict, confirmed otherwise
997
+ * - null → not dangerous
998
+ */
999
+ function dangerousLevel(fullCommand: string): "block" | "confirm" | null {
1000
+ for (const pattern of BLOCK_DANGEROUS_PATTERNS) {
1001
+ if (pattern.test(fullCommand)) return "block";
1002
+ }
1003
+ for (const pattern of CONFIRM_DANGEROUS_PATTERNS) {
1004
+ if (pattern.test(fullCommand)) return "confirm";
1005
+ }
1006
+ return null;
1007
+ }
1008
+
1009
+ // ─── Command Parsing ──────────────────────────────────────────────────
1010
+
1011
+ interface CmdInfo {
1012
+ command: string; // base command name (rm, rmdir, etc.)
1013
+ args: string[]; // non-flag args (potential paths)
1014
+ }
1015
+
1016
+ /** Parse a shell command into name and args (strips prefix commands first) */
1017
+ function parseCommand(fullCommand: string): CmdInfo | null {
1018
+ // Strip command-substitution $(...), subshell (...), and group {...} wrappers
1019
+ let cleaned = fullCommand.trim();
1020
+ cleaned = cleaned.replace(/^\$\(\s*/, "").replace(/\s*\)$/, "");
1021
+ cleaned = cleaned.replace(/^\(\s*/, "").replace(/\s*\)$/, "");
1022
+ cleaned = cleaned.replace(/^\{\s*/, "").replace(/\s*;?\s*\}$/, "");
1023
+
1024
+ const tokens = splitShellTokens(cleaned);
1025
+ if (tokens.length === 0) return null;
1026
+
1027
+ // Strip prefix commands (sudo/nohup/timeout/env etc.) along with their flags / numbers / VAR= assignments
1028
+ const stripped = stripPrefixTokens(tokens);
1029
+ if (stripped.length === 0) return null;
1030
+
1031
+ // Drop the backslash prefix (\rm) and path prefix (/bin/rm)
1032
+ const raw = stripped[0].split("/").pop() ?? stripped[0];
1033
+ const base = raw.replace(/^\\(?=[A-Za-z])/, "");
1034
+ return { command: base, args: stripped.slice(1) };
1035
+ }
1036
+
1037
+ /** Strip prefix commands (sudo etc.), skipping their flags / numbers / VAR= assignments */
1038
+ function stripPrefixTokens(tokens: string[]): string[] {
1039
+ const t = [...tokens];
1040
+ while (t.length > 0 && PREFIX_COMMANDS.has(t[0])) {
1041
+ const prefix = t.shift()!;
1042
+ while (
1043
+ t.length > 0 &&
1044
+ (t[0].startsWith("-") ||
1045
+ /^\d+$/.test(t[0]) ||
1046
+ /^[A-Za-z_][A-Za-z0-9_]*=/.test(t[0]))
1047
+ ) {
1048
+ const flag = t.shift()!;
1049
+ if (FLAGS_WITH_ARG.has(flag)) t.shift();
1050
+ }
1051
+ // chroot's first argument is the NEWROOT path; skip it
1052
+ if (prefix === "chroot" && t.length > 0) t.shift();
1053
+ }
1054
+ return t;
1055
+ }
1056
+
1057
+ /** Whether the command is a delete command */
1058
+ function isDeleteCommand(cmd: string): boolean {
1059
+ return DELETE_COMMANDS.has(cmd);
1060
+ }
1061
+
1062
+ /** Whether args carry a force flag (-f / --force, supports -sf / -fdx combos) */
1063
+ function hasForceFlag(args: string[]): boolean {
1064
+ return args.some((a) => {
1065
+ if (!a.startsWith("-")) return false;
1066
+ if (a.startsWith("--")) return a === "--force" || a.startsWith("--force=");
1067
+ return a.slice(1).includes("f");
1068
+ });
1069
+ }
1070
+
1071
+ /** Overwrite command "target" — last non-flag arg; null if none */
1072
+ function lastDestArg(args: string[]): string | null {
1073
+ for (let i = args.length - 1; i >= 0; i--) {
1074
+ const a = args[i];
1075
+ if (a.startsWith("-")) continue;
1076
+ if (a === ">" || a === ">>" || a === "2>" || a === "2>>") continue;
1077
+ return a;
1078
+ }
1079
+ return null;
1080
+ }
1081
+
1082
+ /** Extract path-like tokens from args, resolve to absolute, classify in/out */
1083
+ function extractPathArgs(
1084
+ args: string[],
1085
+ cwd: string,
1086
+ ): Array<{ raw: string; path: string; isOutside: boolean }> {
1087
+ const results: Array<{ raw: string; path: string; isOutside: boolean }> = [];
1088
+
1089
+ for (const arg of args) {
1090
+ // Skip flags
1091
+ if (arg.startsWith("-")) continue;
1092
+ // Skip wildcards/redirects
1093
+ if (
1094
+ arg.includes("*") ||
1095
+ arg.includes("?") ||
1096
+ arg === ">" ||
1097
+ arg === ">>" ||
1098
+ arg === "2>" ||
1099
+ arg === "2>>"
1100
+ )
1101
+ continue;
1102
+ // Variable refs ("$HOME/.ssh") not statically resolvable → skip; falls into the no-path→confirm branch
1103
+ if (arg.startsWith("$")) continue;
1104
+
1105
+ // Expand ~ / ~/xxx to HOME, or it'd be treated as an in-project relative path
1106
+ const expanded = expandHome(arg);
1107
+
1108
+ const resolved = resolve(cwd, expanded);
1109
+ // Resolve symlinks so a delete target can't actually live outside the project
1110
+ const real = resolveReal(resolved);
1111
+ const outside = isOutsideCwd(real, cwd);
1112
+ results.push({ raw: arg, path: real, isOutside: outside });
1113
+ }
1114
+
1115
+ return results;
1116
+ }
1117
+
1118
+ /** Expand ~ / ~/xxx to HOME */
1119
+ function expandHome(p: string): string {
1120
+ if (p === "~") return HOME;
1121
+ if (p.startsWith("~/")) return join(HOME, p.slice(2));
1122
+ return p;
1123
+ }
1124
+
1125
+ /** Redirect target: { op, target }; null if none */
1126
+ interface RedirectTarget {
1127
+ op: string; // redirect operator (>, 2>, &>, >>, 2>>, ...)
1128
+ target: string; // target path
1129
+ }
1130
+
1131
+ /** Extract the redirect write target (> file, 2>>file, &> file, ...); null if none */
1132
+ function extractRedirectTarget(fullCommand: string): RedirectTarget | null {
1133
+ const tokens = splitShellTokens(fullCommand);
1134
+ const REDIR = /^([0-9]*&?>+)(.*)$/;
1135
+ for (let i = 0; i < tokens.length; i++) {
1136
+ const m = REDIR.exec(tokens[i]);
1137
+ if (!m) continue;
1138
+ // Target glued to the same token (echo hi >.env)
1139
+ if (m[2]) {
1140
+ // fd duplication like 2>&1 → skip
1141
+ if (!m[2].startsWith("&")) return { op: m[1], target: m[2] };
1142
+ continue;
1143
+ }
1144
+ // Target in the next token (> /dev/sda)
1145
+ const next = tokens[i + 1];
1146
+ if (next && !next.startsWith("&")) return { op: m[1], target: next };
1147
+ }
1148
+ return null;
1149
+ }
1150
+
1151
+ /** Whether the operator is truncating (single >, not >> append) */
1152
+ function isTruncatingOp(op: string): boolean {
1153
+ return op.endsWith(">") && !op.endsWith(">>");
1154
+ }
1155
+
1156
+ /** Minimal shell tokenizer (handles single/double quotes) */
1157
+ function splitShellTokens(input: string): string[] {
1158
+ const tokens: string[] = [];
1159
+ let current = "";
1160
+ let inSingle = false;
1161
+ let inDouble = false;
1162
+ for (const ch of input) {
1163
+ if (ch === "'" && !inDouble) {
1164
+ inSingle = !inSingle;
1165
+ continue;
1166
+ }
1167
+ if (ch === '"' && !inSingle) {
1168
+ inDouble = !inDouble;
1169
+ continue;
1170
+ }
1171
+ if (/\s/.test(ch) && !inSingle && !inDouble) {
1172
+ if (current) {
1173
+ tokens.push(current);
1174
+ current = "";
1175
+ }
1176
+ continue;
1177
+ }
1178
+ current += ch;
1179
+ }
1180
+ if (current) tokens.push(current);
1181
+ return tokens;
1182
+ }
1183
+
1184
+ /** Split by shell operators (&&, ||, ;, |, newline); never inside quotes */
1185
+ function splitSegments(input: string): string[] {
1186
+ const segments: string[] = [];
1187
+ let current = "";
1188
+ let inSingle = false;
1189
+ let inDouble = false;
1190
+
1191
+ for (let i = 0; i < input.length; i++) {
1192
+ const ch = input[i];
1193
+ if (ch === "'" && !inDouble) {
1194
+ inSingle = !inSingle;
1195
+ current += ch;
1196
+ continue;
1197
+ }
1198
+ if (ch === '"' && !inSingle) {
1199
+ inDouble = !inDouble;
1200
+ current += ch;
1201
+ continue;
1202
+ }
1203
+ if (!inSingle && !inDouble) {
1204
+ const isSep =
1205
+ ch === "|" ||
1206
+ ch === ";" ||
1207
+ ch === "\n" ||
1208
+ (ch === "&" && input[i + 1] === "&");
1209
+ if (isSep) {
1210
+ if (current.trim()) segments.push(current.trim());
1211
+ current = "";
1212
+ if (ch === "&") i++; // skip the second &
1213
+ continue;
1214
+ }
1215
+ }
1216
+ current += ch;
1217
+ }
1218
+ if (current.trim()) segments.push(current.trim());
1219
+ return segments;
1220
+ }
1221
+
1222
+ /** Whether the path is a directory */
1223
+ function isDirectory(p: string): boolean {
1224
+ try {
1225
+ return statSync(p).isDirectory();
1226
+ } catch {
1227
+ return false;
1228
+ }
1229
+ }
1230
+
1231
+ /**
1232
+ * Resolve symlinks to the real path.
1233
+ * For missing paths, walk upward from the nearest existing ancestor, resolve the first
1234
+ * resolvable parent, and append the remainder. Unlike top-down resolution, this correctly
1235
+ * handles mid-path symlinks (e.g. in-project lnk -> external dir), preventing deep missing
1236
+ * paths from being written through a symlink to outside the project; also handles symlink cwd.
1237
+ */
1238
+ function resolveReal(p: string): string {
1239
+ try {
1240
+ return realpathSync(p);
1241
+ } catch {
1242
+ let cur = p;
1243
+ const tail: string[] = [];
1244
+ for (;;) {
1245
+ const parent = dirname(cur);
1246
+ if (parent === cur) break; // reached root; path doesn't exist at all
1247
+ try {
1248
+ const real = realpathSync(parent);
1249
+ return join(real, basename(cur), ...tail);
1250
+ } catch {
1251
+ tail.unshift(basename(cur));
1252
+ cur = parent;
1253
+ }
1254
+ }
1255
+ return normalize(p);
1256
+ }
1257
+ }
1258
+
1259
+ // ─── UI Interaction ───────────────────────────────────────────────────
1260
+
1261
+ /** Warning confirmation before switching to trusted: behavior boundary is very loose; requires explicit user confirmation */
1262
+ async function confirmTrustedSwitch(
1263
+ ctx: ExtensionCommandContext,
1264
+ ): Promise<boolean> {
1265
+ // No UI (headless) cannot confirm → conservatively refuse the switch
1266
+ if (!ctx.hasUI) return false;
1267
+ return ctx.ui.confirm(
1268
+ "⚠️ Switch to trusted mode?",
1269
+ "trusted is the most permissive mode: in-project deletes and outside overwrites/deletes of\nordinary files are no longer prompted. Only protected paths and system-destructive commands\nremain blocked.\n\npi's behavior boundary is very loose in this mode — please confirm the switch.",
1270
+ );
1271
+ }
1272
+
1273
+ async function askConfirm(
1274
+ ctx: ExtensionContext,
1275
+ message: string,
1276
+ ): Promise<ToolCallEventResult | undefined> {
1277
+ if (!ctx.hasUI) {
1278
+ return { block: true, reason: "No interactive UI; blocked" };
1279
+ }
1280
+
1281
+ const choice = await ctx.ui.select(message, ["✅ Allow", "❌ Deny"]);
1282
+
1283
+ if (choice !== "✅ Allow") {
1284
+ return { block: true, reason: "User denied the operation" };
1285
+ }
1286
+ return undefined; // allow
1287
+ }