@xynogen/pix-gate 0.3.2 → 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xynogen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -35,18 +35,6 @@ Bare `sudo` in bash is always redirected to `sudo_run`, regardless of mode.
35
35
 
36
36
  **Circuit breaker.** A small set of catastrophic, unrecoverable commands — `rm -rf /` or `~`, `dd`/redirect onto a raw disk, `mkfs` on a device, a fork bomb — can never be auto-approved by any mode, including YOLO. They always fall through to the interactive dialog (or a no-UI block). This mirrors Claude Code's `bypassPermissions` floor, which still prompts on root/home wipes.
37
37
 
38
- ## Install
39
-
40
- ```bash
41
- pi install npm:@xynogen/pix-gate
42
- ```
43
-
44
- > Also included in [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core):
45
- >
46
- > ```bash
47
- > pi install npm:@xynogen/pix-core
48
- > ```
49
-
50
38
  ## Reusable exports
51
39
 
52
40
  The gate is split into a pure rule engine and the interactive prompt, so the
@@ -82,16 +70,28 @@ Gate rules are read from the **`gate` section of `~/.pi/agent/pix.json`** (the u
82
70
 
83
71
  The schema is identical to the old `pix-gate.json` — move your existing config into `pix.json` under the `gate` key.
84
72
 
85
- ## Full distro
73
+ ## Install
86
74
 
87
- Source: [github.com/xynogen/pix-mono](https://github.com/xynogen/pix-mono)
75
+ ```bash
76
+ pi install npm:@xynogen/pix-gate
77
+ ```
78
+
79
+ > Bundled in [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core). Install it alone only if you do not use pix-core.
88
80
 
89
- To install the complete pix suite (all packages + Pi itself):
81
+ ## Full distro
82
+
83
+ This package is part of [Pix](https://github.com/xynogen/pix-mono). The installer sets up Pi and the full distro. See [Install](https://github.com/xynogen/pix-mono#install) for the notes for each OS.
90
84
 
91
85
  ```bash
86
+ # Linux / macOS
92
87
  curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
93
88
  ```
94
89
 
90
+ ```powershell
91
+ # Windows
92
+ irm https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.ps1 | iex
93
+ ```
94
+
95
95
  ## License
96
96
 
97
97
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-gate",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension — permission gate for dangerous bash commands (confirm/block with TUI dialog)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@xynogen/pix-pretty": "^1.11.2",
47
- "@xynogen/pix-runtime": "^0.8.0"
47
+ "@xynogen/pix-runtime": "^0.12.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@earendil-works/pi-coding-agent": "*",
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * pix-gate — Pi extension
3
3
  *
4
- * Intercepts bash `tool_call` events and gates dangerous commands behind a
5
- * TUI confirmation dialog before they run.
4
+ * Intercepts shell `tool_call` events (bash, powershell, proc) and gates
5
+ * dangerous commands behind a TUI confirmation dialog before they run. Also
6
+ * gates secret paths for the read/write/edit/grep/find/ls tools.
6
7
  *
7
8
  * Severity tiers (user always has final say via dialog):
8
9
  * critical — red, deny-first dialog
@@ -14,13 +15,15 @@
14
15
  * Config: ~/.pi/agent/pix.json (the `gate` section)
15
16
  * guardrails: "off" — disable built-in rules entirely
16
17
  * extraRules: [{ pattern, flags?, severity?, reason? }] — append extra rules
17
- * autoApprove: ["regex"] — bypass gate for matching commands
18
+ * autoApprove: ["regex"] — bypass gate for matching simple commands
19
+ * (never for chained commands or the circuit breaker)
18
20
  */
19
21
 
20
22
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
23
  import { withAgentBlock } from "@xynogen/pix-runtime";
22
24
  import {
23
25
  buildRules,
26
+ canAutoApprove,
24
27
  classify,
25
28
  classifyPath,
26
29
  extractPathsFromBash,
@@ -39,8 +42,12 @@ import {
39
42
  promptPathDecision,
40
43
  SEVERITY_ICON,
41
44
  } from "./prompt.ts";
45
+ import { registerProjectTrust } from "./trust.ts";
42
46
 
43
47
  export default function (pi: ExtensionAPI): void {
48
+ // Two-choice project-trust prompt (Trust / Do not trust) replacing Pi's five.
49
+ registerProjectTrust(pi);
50
+
44
51
  const { rules, autoApprove, pathRules } = buildRules(loadUserConfig());
45
52
 
46
53
  // Is a tool registered (active or gated)? ssh_run/sudo_run are opt-in and
@@ -63,30 +70,35 @@ export default function (pi: ExtensionAPI): void {
63
70
  { match: isSshCommand, tool: "ssh_run", label: "ssh" },
64
71
  ] as const;
65
72
 
66
- // ── Path protection (read/write/edit tools only — bash handled below) ───
73
+ // ── Path protection (file tools — shell tools handled below) ───
74
+ // grep/find/ls take a directory, so `~/.ssh` also tests as `~/.ssh/`.
75
+ const PATH_TOOLS: Record<string, "read" | "write"> = {
76
+ read: "read",
77
+ grep: "read",
78
+ find: "read",
79
+ ls: "read",
80
+ write: "write",
81
+ edit: "write",
82
+ };
83
+ const DIR_TOOLS = new Set(["grep", "find", "ls"]);
84
+ const PATH_ORDER = { block: 0, warn: 1, info: 2 } as const;
67
85
  pi.on("tool_call", async (event, ctx) => {
68
86
  const tool = String(event.toolName);
87
+ const op = PATH_TOOLS[tool];
88
+ if (!op) return undefined;
69
89
  const input = event.input as Record<string, unknown>;
70
90
 
71
- // bash is handled by the unified gate below
72
- if (tool === "bash") return undefined;
73
-
74
- let path: string | undefined;
75
- let op: "read" | "write";
76
-
77
- if (tool === "read") {
78
- path = String(input.path ?? "");
79
- op = "read";
80
- } else if (tool === "write" || tool === "edit") {
81
- path = String(input.path ?? "");
82
- op = "write";
83
- } else {
84
- return undefined;
85
- }
86
-
87
- if (!path) return undefined;
88
- const hit = classifyPath(path, op, pathRules);
89
- if (!hit) return undefined;
91
+ // `paths` is the batch form of pix-read / pix-ls.
92
+ const raw = [input.path, input.file_path, ...(Array.isArray(input.paths) ? input.paths : [])];
93
+ const targets = raw.filter((p): p is string => typeof p === "string" && p.length > 0);
94
+ const hits = targets
95
+ .flatMap((p) => (DIR_TOOLS.has(tool) ? [p, `${p.replace(/[\\/]$/, "")}/`] : [p]))
96
+ .map((p) => ({ p, h: classifyPath(p, op, pathRules) }))
97
+ .filter((x): x is { p: string; h: NonNullable<typeof x.h> } => x.h !== undefined)
98
+ .sort((a, b) => PATH_ORDER[a.h.severity] - PATH_ORDER[b.h.severity]);
99
+ const top = hits[0];
100
+ if (!top) return undefined;
101
+ const { p: path, h: hit } = top;
90
102
 
91
103
  if (hit.severity === "info") {
92
104
  ctx.ui.notify(`${PATH_SEVERITY_ICON.info} ${hit.reason}: ${path}`, "info");
@@ -141,9 +153,11 @@ export default function (pi: ExtensionAPI): void {
141
153
  // action carries a shell command in `event.input.command`, identical to bash.
142
154
  // proc's other actions (list/logs/stop/rm) carry no command and pass straight
143
155
  // through. Install pix-proc without pix-gate = ungated, same as bash.
144
- const GATED_COMMAND_TOOLS = new Set(["bash", "proc"]);
156
+ // Pi's built-in `powershell` tool uses the same `input.command` shape.
157
+ const GATED_COMMAND_TOOLS = new Set(["bash", "proc", "powershell"]);
145
158
  pi.on("tool_call", async (event, ctx) => {
146
- if (!GATED_COMMAND_TOOLS.has(String(event.toolName))) return undefined;
159
+ const toolName = String(event.toolName);
160
+ if (!GATED_COMMAND_TOOLS.has(toolName)) return undefined;
147
161
 
148
162
  const command = String((event.input as Record<string, unknown>).command ?? "");
149
163
  if (!command.trim()) return undefined;
@@ -151,8 +165,16 @@ export default function (pi: ExtensionAPI): void {
151
165
  // Collect all concerns: path hits + command hit
152
166
  const concerns: Concern[] = [];
153
167
 
154
- // Path concerns
168
+ // Path concerns. A redirect or `tee` target counts as a write.
155
169
  const candidates = extractPathsFromBash(command);
170
+ const writeTargets = new Set(
171
+ [
172
+ ...command
173
+ .replace(/\\/g, "/")
174
+ .matchAll(/(?:>>?|\btee\s+(?:-\S+\s+)*)\s*["']?([^\s"'`;&|<>)]+)/g),
175
+ ].map((m) => m[1]),
176
+ );
177
+ const opFor = (p: string): "read" | "write" => (writeTargets.has(p) ? "write" : "read");
156
178
 
157
179
  // Steer a blocked ssh-config read toward the dedicated `ssh info` tool
158
180
  // (ssh_run action:"info"), which reads the effective config without touching
@@ -164,7 +186,7 @@ export default function (pi: ExtensionAPI): void {
164
186
  ? ' Use the ssh_run tool with action:"info" to read the effective SSH config instead of reading ~/.ssh/config in bash.'
165
187
  : "";
166
188
  for (const p of candidates) {
167
- const hit = classifyPath(p, "read", pathRules);
189
+ const hit = classifyPath(p, opFor(p), pathRules);
168
190
  if (!hit) continue;
169
191
  if (hit.severity === "info") {
170
192
  ctx.ui.notify(`${PATH_SEVERITY_ICON.info} ${hit.reason}: ${p}`, "info");
@@ -189,26 +211,28 @@ export default function (pi: ExtensionAPI): void {
189
211
  });
190
212
  }
191
213
 
192
- if (concerns.length === 0) return undefined;
193
-
194
- const highest = concerns.reduce((a, b) => (a.tier > b.tier ? a : b));
195
-
196
214
  // Privileged auth redirect — hard block, no prompt, no bypass. Even YOLO
197
215
  // cannot run bare sudo/ssh in bash (password/passphrase can't be auto-typed);
198
216
  // it must use the dedicated tool. Only fires when that tool is installed.
217
+ // Runs before the empty-concerns exit: plain `ssh host` matches no rule.
199
218
  for (const redirect of AUTH_REDIRECTS) {
200
219
  if (!redirect.match(command) || !isRegistered(redirect.tool)) continue;
201
- if (unattendedGateDecision(pi.events, highest.tier) === "deny") {
220
+ const tier = Math.max(SEVERITY_TIER.dangerous, ...concerns.map((c) => c.tier));
221
+ if (unattendedGateDecision(pi.events, tier) === "deny") {
202
222
  return { block: true, reason: `[AFK] ${redirect.label} is denied while user is away.` };
203
223
  }
204
224
  // One surface only: the block reason renders as the tool-error card and
205
225
  // reaches the model. A separate notify would duplicate the warning.
206
226
  return {
207
227
  block: true,
208
- reason: `DANGEROUS — use the ${redirect.tool} tool instead of ${redirect.label} in bash (it handles auth securely).`,
228
+ reason: `DANGEROUS — use the ${redirect.tool} tool instead of ${redirect.label} in ${toolName} (it handles auth securely).`,
209
229
  };
210
230
  }
211
231
 
232
+ if (concerns.length === 0) return undefined;
233
+
234
+ const highest = concerns.reduce((a, b) => (a.tier > b.tier ? a : b));
235
+
212
236
  // Circuit breaker: catastrophic commands never auto-approve, even under YOLO.
213
237
  // They fall through to the interactive dialog (or the no-UI block below).
214
238
  const breaker = isCircuitBreaker(command);
@@ -227,7 +251,7 @@ export default function (pi: ExtensionAPI): void {
227
251
  };
228
252
  }
229
253
 
230
- if (autoApprove.some((re) => re.test(command))) return undefined;
254
+ if (canAutoApprove(command, autoApprove)) return undefined;
231
255
 
232
256
  // No UI: auto-block block+ severity, pass anything lower.
233
257
  if (!ctx.hasUI) {
@@ -252,7 +276,7 @@ export default function (pi: ExtensionAPI): void {
252
276
  } else if (concerns.length === 1 && !cmdHit) {
253
277
  // Single path hit — reuse path dialog
254
278
  const ph = candidates
255
- .map((p) => ({ p, h: classifyPath(p, "read", pathRules) }))
279
+ .map((p) => ({ p, h: classifyPath(p, opFor(p), pathRules) }))
256
280
  .find((x) => x.h?.severity !== "info" && x.h);
257
281
  if (ph?.h) {
258
282
  decision = await withAgentBlock(
package/src/lib.ts CHANGED
@@ -28,30 +28,49 @@ export interface UserConfig {
28
28
  autoApprove?: string[];
29
29
  }
30
30
 
31
+ // Recursive delete of a drive root or the user profile (cmd.exe `rd /s`, PowerShell `-Recurse`).
32
+ const WINDOWS_ROOT_WIPE =
33
+ /\b(?:rd|rmdir|del|erase|Remove-Item|ri|rm)\b(?=[^|;\n]*\s(?:\/s|-r\w*)\b)[^|;\n]*\s["']?(?:[A-Za-z]:|~|\$env:USERPROFILE|%USERPROFILE%|\$HOME)[\\/]?["']?(?=\s|$|[;|&])/i;
34
+
35
+ // Command position: line start, after an operator / subshell / quote, or after a
36
+ // wrapper (`sudo`, `env`, `xargs`, …). Keeps `grep shutdown src/` and `echo mkfs`
37
+ // from matching. `\|` is an escaped pipe inside a pattern, not an operator. A quote
38
+ // starts a command only after `-c` (`sh -c "…"`), not in `git commit -m "reboot"`.
39
+ const AT_CMD = String.raw`(?:^|(?<!\\)[;&|({\x60!]|\$\(|-c\s+["'])\s*(?:(?:sudo|doas|env|nohup|time|exec|nice|xargs|command|builtin|then|do|else)\s+(?:-\S+\s+)*)*`;
40
+ const at = (body: string, flags = "i"): RegExp => new RegExp(AT_CMD + body, flags);
41
+
42
+ // One shell segment (stops at the next operator).
43
+ const SEG = String.raw`[^;&|\n]*`;
44
+ // `rm` with a recursive flag (-r, -R, -rf, -fr, --recursive). Force is irrelevant for a non-tty agent.
45
+ const RM_R = String.raw`rm\b(?=${SEG}\s(?:-[a-z]*r[a-z]*|--recursive)(?=\s|$))`;
46
+ // A target that is `/`, `~`, `$HOME`, or everything under them.
47
+ const ROOT_TARGET = String.raw`${SEG}\s["']?(?:\/\*?|~\/?\*?|\$\{?HOME\}?\/?\*?)["']?(?=\s|$|[;&|])`;
48
+ // A target that is a top-level system directory.
49
+ const SYS_TARGET = String.raw`${SEG}\s["']?\/(?:bin|boot|dev|etc|home|lib|lib64|opt|proc|root|sbin|srv|sys|usr|var)\/?\*?["']?(?=\s|$|[;&|])`;
50
+ const RAW_DISK = String.raw`\/dev\/(?:sd|nvme|disk|hd|vd|xvd|mmcblk)`;
51
+
52
+ const RM_ROOT = at(RM_R + ROOT_TARGET);
53
+
31
54
  export const DEFAULT_RULES: Rule[] = [
32
55
  // CRITICAL — destructive, irreversible, or system-wide
56
+ { pattern: RM_ROOT, severity: "critical", reason: "recursive rm on / or $HOME" },
33
57
  {
34
- pattern: /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\s+\/(\s|$)/i,
58
+ pattern: at(RM_R + SYS_TARGET),
35
59
  severity: "critical",
36
- reason: "rm -rf on /",
60
+ reason: "recursive rm on a system directory",
37
61
  },
38
62
  {
39
- pattern: /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\s+(~|\$HOME)(\s|$|\/\s*$)/i,
63
+ pattern: at(String.raw`(?:mkfs(?:\.\w+)?|wipefs|blkdiscard)\b`),
40
64
  severity: "critical",
41
- reason: "rm -rf on $HOME",
65
+ reason: "filesystem format / wipe",
42
66
  },
43
67
  {
44
- pattern: /\bmkfs(\.\w+)?\b/i,
45
- severity: "critical",
46
- reason: "filesystem formatting",
47
- },
48
- {
49
- pattern: /\bdd\s+.*\bof=\/dev\/(sd[a-z]|nvme|disk)/i,
68
+ pattern: new RegExp(String.raw`\bdd\b[^\n]*\bof=${RAW_DISK}`, "i"),
50
69
  severity: "critical",
51
70
  reason: "dd to raw block device",
52
71
  },
53
72
  {
54
- pattern: />\s*\/dev\/(sd[a-z]|nvme|disk)/i,
73
+ pattern: new RegExp(String.raw`>\s*${RAW_DISK}`, "i"),
55
74
  severity: "critical",
56
75
  reason: "writing to raw block device",
57
76
  },
@@ -61,59 +80,197 @@ export const DEFAULT_RULES: Rule[] = [
61
80
  reason: "fork bomb",
62
81
  },
63
82
  {
64
- pattern: /\bshutdown\b|\breboot\b|\bhalt\b|\bpoweroff\b/i,
83
+ pattern: at(
84
+ String.raw`(?:shutdown|reboot|halt|poweroff|systemctl\s+(?:poweroff|reboot|halt|kexec)|init\s+[06])\b`,
85
+ ),
65
86
  severity: "critical",
66
87
  reason: "system power command",
67
88
  },
68
89
 
69
90
  // DANGEROUS — destructive or privileged but recoverable in scope
91
+ { pattern: at(RM_R), severity: "dangerous", reason: "recursive remove" },
70
92
  {
71
- pattern: /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\b/i,
72
- severity: "dangerous",
73
- reason: "recursive force remove",
74
- },
75
- // Match sudo as a command/operator token, not as a substring of path components like pix-sudo
76
- {
77
- pattern: /(^|[\s;|&])sudo\b/i,
93
+ pattern: at(String.raw`(?:sudo|doas|pkexec|su)\b`),
78
94
  severity: "dangerous",
79
95
  reason: "privilege escalation",
80
96
  },
81
97
  {
82
- pattern: /\b(chmod|chown)\b[^|;&]*\b(777|-R\s+777)/i,
98
+ pattern: at(String.raw`chmod\b${SEG}\s(?:[0-7]?0?777|[ugo]*a[ugo]*\+[rx]*w|o\+[rx]*w|a=rwx)`),
83
99
  severity: "dangerous",
84
100
  reason: "world-writable permissions",
85
101
  },
86
102
  {
87
- pattern: /\bchmod\s+-R\b/i,
103
+ pattern: at(
104
+ String.raw`(?:chmod|chown|chgrp)\s+(?:-\S+\s+)*-[a-z]*R(?:${SYS_TARGET}|${ROOT_TARGET})`,
105
+ ),
88
106
  severity: "dangerous",
89
- reason: "recursive chmod",
107
+ reason: "recursive permission / owner change on a system path",
90
108
  },
91
109
  {
92
- pattern: /\b(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(sh|bash|zsh)\b/i,
110
+ pattern:
111
+ /\b(?:curl|wget)\b[^\n]*\|\s*(?:sudo\s+(?:-\S+\s+)*)?(?:sh|bash|zsh|dash|ksh|fish|python[\d.]*|perl|ruby|node|bun|deno)\b|(?:^|\s)(?:sh|bash|zsh|dash|ksh|eval|source|\.)\s[^\n]*(?:\$\(|<\(|\x60)\s*(?:curl|wget)\b/i,
93
112
  severity: "dangerous",
94
113
  reason: "remote script execution (curl|sh)",
95
114
  },
96
115
  {
97
- pattern: /\bgit\s+(push\s+(-f|--force)|reset\s+--hard|clean\s+-[a-z]*f)/i,
116
+ // Case-sensitive: `branch -D` force-deletes, `branch -d` does not.
117
+ pattern: new RegExp(
118
+ String.raw`\bgit\b${SEG}\s(?:push\b${SEG}\s(?:-f|--force(?:-with-lease)?|--mirror|--delete|-d|\+\S+|:\S+)(?=\s|$)|reset\s+--hard|clean\s+-[a-zA-Z]*f|branch\s+(?:${SEG}\s)?(?:-D|--delete\s+--force)\b|filter-branch|filter-repo|reflog\s+expire|update-ref\s+-d)`,
119
+ ),
98
120
  severity: "dangerous",
99
121
  reason: "destructive git operation",
100
122
  },
101
123
  {
102
- pattern: /\bnpm\s+publish\b/i,
124
+ pattern: new RegExp(
125
+ String.raw`\b(?:(?:npm|pnpm|yarn|bun)\s+(?:publish|unpublish|deprecate)|cargo\s+(?:publish|yank)|twine\s+upload|gem\s+push)\b(?!${SEG}--dry-run)`,
126
+ "i",
127
+ ),
103
128
  severity: "dangerous",
104
129
  reason: "package publish",
105
130
  },
106
131
  {
107
- pattern: /\bdocker\s+(system\s+prune|rm\s+-f|volume\s+rm)/i,
132
+ pattern: new RegExp(
133
+ String.raw`\bdocker\s+(?:system\s+prune|volume\s+(?:rm|prune)|(?:rm|rmi)\s+-[a-z]*f|image\s+prune\s+-a)|\bdocker(?:\s+compose|-compose)\s+down\b${SEG}\s(?:-v|--volumes)\b`,
134
+ "i",
135
+ ),
108
136
  severity: "dangerous",
109
137
  reason: "destructive docker operation",
110
138
  },
139
+ {
140
+ pattern: new RegExp(String.raw`\bfind\b${SEG}\s(?:-delete\b|-exec(?:dir)?\s+rm\b)`, "i"),
141
+ severity: "dangerous",
142
+ reason: "find with delete",
143
+ },
144
+ {
145
+ pattern: at(
146
+ String.raw`(?:crontab\s+-r|kubectl\s+delete|helm\s+(?:uninstall|delete)|terraform\s+destroy|mv\b${SEG}\s\/dev\/null)\b`,
147
+ ),
148
+ severity: "dangerous",
149
+ reason: "destructive infra / scheduler operation",
150
+ },
151
+ {
152
+ pattern: /\b(?:DROP\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE)\b/i,
153
+ severity: "dangerous",
154
+ reason: "destructive SQL",
155
+ },
111
156
  {
112
157
  pattern: /\bkill\s+-9\s+-1\b/i,
113
158
  severity: "dangerous",
114
159
  reason: "kill all processes",
115
160
  },
116
161
 
162
+ // Windows — cmd.exe and PowerShell forms (Pi's built-in `powershell` tool, Git Bash)
163
+ {
164
+ pattern: WINDOWS_ROOT_WIPE,
165
+ severity: "critical",
166
+ reason: "recursive delete of drive root / profile",
167
+ },
168
+ {
169
+ pattern:
170
+ /\b(Format-Volume|Clear-Disk|Initialize-Disk|diskpart)\b|\bformat(\.com)?\s+[A-Za-z]:/i,
171
+ severity: "critical",
172
+ reason: "disk format / wipe (Windows)",
173
+ },
174
+ {
175
+ pattern: /\bvssadmin\b[^|;\n]*\bdelete\b|\bwmic\b[^|;\n]*\bshadowcopy\b[^|;\n]*\bdelete\b/i,
176
+ severity: "critical",
177
+ reason: "delete shadow copies (restore points)",
178
+ },
179
+ {
180
+ pattern: /\b(Stop-Computer|Restart-Computer)\b/i,
181
+ severity: "critical",
182
+ reason: "system power command (PowerShell)",
183
+ },
184
+ // PowerShell accepts abbreviated params: -r/-rec/-Recurse, -fo/-Force
185
+ {
186
+ pattern:
187
+ /\b(Remove-Item|ri|rm|rmdir|rd|del|erase)\b(?=[^|;\n]*\s-r\w*\b)(?=[^|;\n]*\s-fo\w*\b)/i,
188
+ severity: "dangerous",
189
+ reason: "recursive force remove (PowerShell)",
190
+ },
191
+ {
192
+ pattern: /\b(rd|rmdir|del|erase)\b[^|;\n]*\s\/s\b/i,
193
+ severity: "dangerous",
194
+ reason: "recursive remove (cmd.exe /s)",
195
+ },
196
+ {
197
+ pattern:
198
+ /\breg(\.exe)?\s+(delete|add|import|restore)\b|\b(Remove-Item|Remove-ItemProperty|Set-ItemProperty|New-ItemProperty|Set-Item|New-Item)\b[^|;\n]*\bHK(LM|CU|CR|U|CC):/i,
199
+ severity: "dangerous",
200
+ reason: "registry write",
201
+ },
202
+ {
203
+ pattern: /\bbcdedit\b|\bcipher\b[^|;\n]*\/w\b/i,
204
+ severity: "dangerous",
205
+ reason: "boot config / free-space wipe",
206
+ },
207
+ {
208
+ pattern:
209
+ /\bSet-MpPreference\b[^|;\n]*-Disable|\bAdd-MpPreference\b[^|;\n]*-Exclusion|\bnetsh\b[^|;\n]*\b(state\s+off|opmode\s+disable)\b|\bSet-NetFirewallProfile\b[^|;\n]*-Enabled\s+(\$?false|0)\b/i,
210
+ severity: "dangerous",
211
+ reason: "disable Defender / firewall",
212
+ },
213
+ {
214
+ pattern:
215
+ /\bicacls\b[^|;\n]*\/grant\b[^|;\n]*\b(Everyone|\*S-1-1-0)\b|\b(takeown|icacls)\b[^|;\n]*\s\/(r|t)\b/i,
216
+ severity: "dangerous",
217
+ reason: "recursive / world ACL change (Windows)",
218
+ },
219
+ {
220
+ pattern: /\bnet\s+(user|localgroup)\b[^|;\n]*\/(add|delete)\b/i,
221
+ severity: "dangerous",
222
+ reason: "local user / group change",
223
+ },
224
+ {
225
+ pattern: /\bsc(\.exe)?\s+(delete|config)\b|\bRemove-Service\b/i,
226
+ severity: "dangerous",
227
+ reason: "service delete / reconfigure",
228
+ },
229
+ {
230
+ pattern: /\bwevtutil\s+cl\b|\bClear-EventLog\b/i,
231
+ severity: "dangerous",
232
+ reason: "clear event logs",
233
+ },
234
+ {
235
+ // -e/-ec/-en/-enc/-EncodedCommand; not -ExecutionPolicy
236
+ pattern: /\b(powershell|pwsh)(\.exe)?\b[^|;\n]*\s-(e|ec|en|enc\w*)\b/i,
237
+ severity: "dangerous",
238
+ reason: "encoded PowerShell command",
239
+ },
240
+ {
241
+ pattern:
242
+ /\bcertutil\b[^|;\n]*-urlcache|\bbitsadmin\b[^|;\n]*\/transfer|\bmshta\b[^|;\n]*https?:/i,
243
+ severity: "dangerous",
244
+ reason: "download via system binary (LOLBin)",
245
+ },
246
+ {
247
+ pattern:
248
+ /\b(iex|Invoke-Expression)\b[^\n]*\b(iwr|irm|Invoke-WebRequest|Invoke-RestMethod|DownloadString)\b|\b(iwr|irm|Invoke-WebRequest|Invoke-RestMethod)\b[^\n]*\|\s*(iex|Invoke-Expression)\b/i,
249
+ severity: "dangerous",
250
+ reason: "remote script execution (iwr|iex)",
251
+ },
252
+ {
253
+ // runas.exe and Start-Process -Verb RunAs
254
+ pattern: /\brunas\b/i,
255
+ severity: "dangerous",
256
+ reason: "privilege escalation (RunAs)",
257
+ },
258
+ {
259
+ pattern: /\bSet-ExecutionPolicy\b/i,
260
+ severity: "risky",
261
+ reason: "execution policy change",
262
+ },
263
+ {
264
+ pattern: /\btaskkill\b[^|;\n]*\/f\b|\bStop-Process\b[^|;\n]*-fo\w*/i,
265
+ severity: "risky",
266
+ reason: "force-kill process",
267
+ },
268
+ {
269
+ pattern: /\bschtasks\b[^|;\n]*\/create\b|\bRegister-ScheduledTask\b/i,
270
+ severity: "risky",
271
+ reason: "scheduled task (persistence)",
272
+ },
273
+
117
274
  // RISKY — worth a glance but usually fine
118
275
  {
119
276
  pattern: /\bgit\s+checkout\s+(-f|--force)/i,
@@ -121,9 +278,19 @@ export const DEFAULT_RULES: Rule[] = [
121
278
  reason: "force checkout (overwrites local changes)",
122
279
  },
123
280
  {
124
- pattern: /\bgit\s+stash\s+drop\b/i,
281
+ pattern: /\bgit\s+stash\s+(?:drop|clear)\b/i,
125
282
  severity: "risky",
126
- reason: "stash drop",
283
+ reason: "stash drop / clear",
284
+ },
285
+ {
286
+ pattern: /\bgit\s+(?:checkout|restore)\s+(?:--\s+)?\.(?=\s|$|[;&|])/i,
287
+ severity: "risky",
288
+ reason: "discard all working-tree changes",
289
+ },
290
+ {
291
+ pattern: at(String.raw`(?:chmod|chown|chgrp)\s+(?:-\S+\s+)*-[a-z]*R\b`),
292
+ severity: "risky",
293
+ reason: "recursive permission / owner change",
127
294
  },
128
295
  {
129
296
  pattern: />\s*[^|&;]*\.env\b/i,
@@ -205,7 +372,7 @@ export const DEFAULT_PATH_RULES: PathRule[] = [
205
372
  reason: "SSH private key",
206
373
  },
207
374
  {
208
- pattern: /\.(pem|key|p12|pfx|jks|keystore)$/i,
375
+ pattern: /\.(pem|key|p12|pfx|jks|keystore|ppk)$/i,
209
376
  severity: "block",
210
377
  reason: "private key / keystore",
211
378
  },
@@ -219,6 +386,18 @@ export const DEFAULT_PATH_RULES: PathRule[] = [
219
386
  severity: "block",
220
387
  reason: "netrc credentials",
221
388
  },
389
+ {
390
+ pattern: /(^|\/)\.pi\/agent\/auth\.json$/i,
391
+ severity: "block",
392
+ reason: "Pi provider API keys",
393
+ },
394
+ {
395
+ pattern:
396
+ /(^|\/)(\.git-credentials|\.pgpass|\.vault-token|\.kube\/config|\.docker\/config\.json|\.config\/gh\/hosts\.ya?ml)$/i,
397
+ severity: "block",
398
+ reason: "stored credentials / tokens",
399
+ },
400
+ { pattern: /(^|\/)\.gnupg\//i, severity: "block", reason: "GnuPG keyring" },
222
401
  {
223
402
  pattern: /(^|\/)(credentials|service-account)\.(json|ya?ml|toml)$/i,
224
403
  severity: "block",
@@ -232,7 +411,35 @@ export const DEFAULT_PATH_RULES: PathRule[] = [
232
411
  reason: ".env file (live secrets)",
233
412
  },
234
413
 
414
+ // Windows secret stores (paths normalized to `/` in classifyPath)
415
+ {
416
+ pattern: /(^|\/)(System32\/config\/(SAM|SYSTEM|SECURITY)|NTUSER\.DAT)$/i,
417
+ severity: "block",
418
+ reason: "Windows registry hive",
419
+ },
420
+ {
421
+ pattern: /(^|\/)Microsoft\/(Credentials|Protect|Vault)(\/|$)/i,
422
+ severity: "block",
423
+ reason: "Windows credential store / DPAPI keys",
424
+ },
425
+ {
426
+ pattern: /(^|\/)(unattend|autounattend|sysprep)\.xml$/i,
427
+ severity: "block",
428
+ reason: "Windows answer file (plaintext passwords)",
429
+ },
430
+
235
431
  // warn — yellow allow-first
432
+ {
433
+ pattern: /(^|\/)ConsoleHost_history\.txt$/i,
434
+ severity: "warn",
435
+ reason: "PowerShell history (may hold secrets)",
436
+ },
437
+ {
438
+ pattern: /(^|\/)[A-Za-z]:\/Windows\//i,
439
+ severity: "warn",
440
+ reason: "Windows system directory",
441
+ ops: ["write"],
442
+ },
236
443
  { pattern: /(^|\/)\.envrc$/i, severity: "warn", reason: "direnv file" },
237
444
  {
238
445
  pattern: /(^|\/)\.npmrc$/i,
@@ -272,12 +479,14 @@ export function classifyPath(
272
479
  op: "read" | "write",
273
480
  rules: PathRule[],
274
481
  ): PathRule | undefined {
482
+ // Windows paths use `\`. Rules match on `/`.
483
+ const p = path.replace(/\\/g, "/");
275
484
  const order: PathSeverity[] = ["block", "warn", "info"];
276
485
  for (const sev of order) {
277
486
  const hit = rules.find((r) => {
278
487
  if (r.severity !== sev) return false;
279
488
  if (r.ops && !r.ops.includes(op)) return false;
280
- return r.pattern.test(path);
489
+ return r.pattern.test(p);
281
490
  });
282
491
  if (hit) return hit;
283
492
  }
@@ -285,17 +494,24 @@ export function classifyPath(
285
494
  }
286
495
 
287
496
  /** Extract candidate file paths from a bash command string */
288
- export function extractPathsFromBash(cmd: string): string[] {
497
+ export function extractPathsFromBash(command: string): string[] {
498
+ // ponytail: `\` → `/` also rewrites bash escapes (`a\ b`). That only loosens path detection.
499
+ const cmd = command.replace(/\\/g, "/");
289
500
  const out: string[] = [];
290
501
  const re =
291
- /(?:^|[\s=><|;&"'`(])((?:\.\.\/|\.\/|\/|~\/)[^\s"'`<>|;&)]+|(?:\.env(?:\.[A-Za-z0-9_-]+)?|[A-Za-z0-9_./-]+\.(?:pem|key|p12|pfx|crt|cer|env|envrc|netrc))(?![A-Za-z0-9]))/g;
502
+ /(?:^|[\s=><|;&"'`(])((?:\.\.\/|\.\/|\/|~\/|[A-Za-z]:\/|\$env:\w+\/|%\w+%\/|\$\{?\w+\}?\/|\.[A-Za-z0-9_-]+\/)[^\s"'`<>|;&)]+|(?:\.env(?:\.[A-Za-z0-9_-]+)?|[A-Za-z0-9_./-]+\.(?:pem|key|p12|pfx|ppk|crt|cer|env|envrc|netrc)|(?:[A-Za-z0-9_.-]+\/)*(?:credentials|service-account|secrets?)\.(?:json|ya?ml|toml))(?![A-Za-z0-9]))/g;
292
503
  for (const m of cmd.matchAll(re)) out.push(m[1] ?? "");
293
504
  return out;
294
505
  }
295
506
 
296
- /** True when command contains a real sudo invocation (not a path like pix-sudo). */
507
+ /** Blank out quoted strings, so `git commit -m "drop sudo"` holds no `sudo` token. */
508
+ function stripQuoted(command: string): string {
509
+ return command.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, "''");
510
+ }
511
+
512
+ /** True when command contains a real sudo invocation (not a path like pix-sudo, not quoted text). */
297
513
  export function isSudoCommand(command: string): boolean {
298
- return /(^|[\s;|&])sudo\b/i.test(command);
514
+ return /(^|[\s;|&(])sudo\b/i.test(stripQuoted(command));
299
515
  }
300
516
 
301
517
  /**
@@ -305,7 +521,7 @@ export function isSudoCommand(command: string): boolean {
305
521
  * matches, e.g. `sshpass -p x ssh host`).
306
522
  */
307
523
  export function isSshCommand(command: string): boolean {
308
- return /(^|[\s;|&])ssh(\s|$)/i.test(command);
524
+ return /(^|[\s;|&(])ssh(\s|$)/i.test(stripQuoted(command));
309
525
  }
310
526
 
311
527
  // Non-lifting circuit breaker: catastrophic, unrecoverable commands that NO
@@ -313,13 +529,25 @@ export function isSshCommand(command: string): boolean {
313
529
  // floor, which still prompts on root/home wipes. These always fall through to
314
530
  // the interactive dialog (or a no-UI block).
315
531
  const CIRCUIT_BREAKER: RegExp[] = [
316
- /\brm\s+-[a-z]*r[a-z]*f?[a-z]*\s+(?:--\s+)?[~/]\s*(?:$|[\s;|&])/i, // rm -rf / or rm -rf ~
317
- /\bdd\b[^\n]*\bof=\/dev\/(?:sd|nvme|disk|hd|vd)/i, // dd onto a raw disk
532
+ RM_ROOT, // rm -rf / rm -rf ~/* rm -r $HOME
533
+ new RegExp(String.raw`\bdd\b[^\n]*\bof=${RAW_DISK}`, "i"), // dd onto a raw disk
318
534
  /\bmkfs\.\w+\s+\/dev\//i, // format a device
319
535
  /:\s*\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/, // classic fork bomb
320
- />\s*\/dev\/(?:sd|nvme|disk|hd|vd)[a-z0-9]*\b/i, // redirect over a raw disk
536
+ new RegExp(String.raw`>\s*${RAW_DISK}`, "i"), // redirect over a raw disk
537
+ /\b(?:Format-Volume|Clear-Disk|diskpart)\b|\bformat(?:\.com)?\s+[A-Za-z]:/i, // Windows disk format / wipe
538
+ WINDOWS_ROOT_WIPE, // rd /s C:\ or Remove-Item -Recurse $env:USERPROFILE
321
539
  ];
322
540
 
541
+ /**
542
+ * True when a user `autoApprove` pattern may skip the gate. It covers one simple
543
+ * command only: `^git status` must not approve `git status; rm -rf ~`. It never
544
+ * lifts the circuit breaker.
545
+ */
546
+ export function canAutoApprove(command: string, patterns: RegExp[]): boolean {
547
+ if (isCircuitBreaker(command) || /[;&|`\n<>]|\$\(/.test(command)) return false;
548
+ return patterns.some((re) => re.test(command));
549
+ }
550
+
323
551
  /** True when a command is catastrophic enough that no unattended mode may auto-approve it. */
324
552
  export function isCircuitBreaker(command: string): boolean {
325
553
  return CIRCUIT_BREAKER.some((re) => re.test(command));
package/src/trust.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Simplified project-trust prompt.
3
+ *
4
+ * Pi's built-in prompt offers five choices (trust, trust parent, trust for
5
+ * session, do not trust, do not trust for session). This handler replaces it
6
+ * with two remembered choices: Trust / Do not trust. It only decides when Pi
7
+ * would otherwise ask — saved decisions, `defaultProjectTrust` other than
8
+ * "ask", and no-UI runs fall through to Pi unchanged ("undecided").
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import type {
14
+ ExtensionAPI,
15
+ ProjectTrustContext,
16
+ ProjectTrustEvent,
17
+ ProjectTrustEventResult,
18
+ } from "@earendil-works/pi-coding-agent";
19
+ import { ProjectTrustStore } from "@earendil-works/pi-coding-agent";
20
+ import { agentDir } from "@xynogen/pix-runtime/paths";
21
+
22
+ export const TRUST_LABEL = "Trust";
23
+ export const DISTRUST_LABEL = "Do not trust";
24
+
25
+ export interface TrustDeps {
26
+ /** Saved decision for cwd (nearest ancestor), or null when none. */
27
+ savedDecision(cwd: string): boolean | null;
28
+ /** Global `defaultProjectTrust` setting. */
29
+ defaultProjectTrust(): string;
30
+ }
31
+
32
+ function readDefaultProjectTrust(dir: string): string {
33
+ const path = join(dir, "settings.json");
34
+ if (!existsSync(path)) return "ask";
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(path, "utf-8").replace(/^\uFEFF/, ""));
37
+ const value = parsed?.defaultProjectTrust;
38
+ return typeof value === "string" ? value : "ask";
39
+ } catch {
40
+ return "ask";
41
+ }
42
+ }
43
+
44
+ export function defaultTrustDeps(dir: string = agentDir()): TrustDeps {
45
+ return {
46
+ savedDecision: (cwd) => new ProjectTrustStore(dir).get(cwd),
47
+ defaultProjectTrust: () => readDefaultProjectTrust(dir),
48
+ };
49
+ }
50
+
51
+ export async function decideProjectTrust(
52
+ event: ProjectTrustEvent,
53
+ ctx: ProjectTrustContext,
54
+ deps: TrustDeps,
55
+ ): Promise<ProjectTrustEventResult> {
56
+ const undecided: ProjectTrustEventResult = { trusted: "undecided" };
57
+ if (!ctx.hasUI) return undecided;
58
+ if (deps.savedDecision(event.cwd) !== null) return undecided;
59
+ if (deps.defaultProjectTrust() !== "ask") return undecided;
60
+
61
+ const title =
62
+ `Trust project folder?\n${event.cwd}\n\n` +
63
+ "This allows Pi to load .pi settings and resources, install missing project packages, and execute project extensions.";
64
+ const choice = await ctx.ui.select(title, [TRUST_LABEL, DISTRUST_LABEL]);
65
+ if (choice === TRUST_LABEL) return { trusted: "yes", remember: true };
66
+ if (choice === DISTRUST_LABEL) return { trusted: "no", remember: true };
67
+ // Cancelled: untrusted for this run only, nothing saved (matches Pi).
68
+ return { trusted: "no" };
69
+ }
70
+
71
+ export function registerProjectTrust(pi: ExtensionAPI, deps: TrustDeps = defaultTrustDeps()): void {
72
+ pi.on("project_trust", (event, ctx) => decideProjectTrust(event, ctx, deps));
73
+ }