@xynogen/pix-gate 0.3.0 → 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 +21 -0
- package/README.md +15 -15
- package/package.json +2 -2
- package/src/index.ts +77 -38
- package/src/lib.ts +266 -38
- package/src/trust.ts +73 -0
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
|
-
##
|
|
73
|
+
## Install
|
|
86
74
|
|
|
87
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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");
|
|
@@ -137,19 +149,44 @@ export default function (pi: ExtensionAPI): void {
|
|
|
137
149
|
risky: 1,
|
|
138
150
|
};
|
|
139
151
|
|
|
152
|
+
// The unified command gate also covers pix-proc's `proc` tool: its `start`
|
|
153
|
+
// action carries a shell command in `event.input.command`, identical to bash.
|
|
154
|
+
// proc's other actions (list/logs/stop/rm) carry no command and pass straight
|
|
155
|
+
// through. Install pix-proc without pix-gate = ungated, same as bash.
|
|
156
|
+
// Pi's built-in `powershell` tool uses the same `input.command` shape.
|
|
157
|
+
const GATED_COMMAND_TOOLS = new Set(["bash", "proc", "powershell"]);
|
|
140
158
|
pi.on("tool_call", async (event, ctx) => {
|
|
141
|
-
|
|
159
|
+
const toolName = String(event.toolName);
|
|
160
|
+
if (!GATED_COMMAND_TOOLS.has(toolName)) return undefined;
|
|
142
161
|
|
|
143
|
-
const command = String(event.input.command ?? "");
|
|
162
|
+
const command = String((event.input as Record<string, unknown>).command ?? "");
|
|
144
163
|
if (!command.trim()) return undefined;
|
|
145
164
|
|
|
146
165
|
// Collect all concerns: path hits + command hit
|
|
147
166
|
const concerns: Concern[] = [];
|
|
148
167
|
|
|
149
|
-
// Path concerns
|
|
168
|
+
// Path concerns. A redirect or `tee` target counts as a write.
|
|
150
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");
|
|
178
|
+
|
|
179
|
+
// Steer a blocked ssh-config read toward the dedicated `ssh info` tool
|
|
180
|
+
// (ssh_run action:"info"), which reads the effective config without touching
|
|
181
|
+
// ~/.ssh directly. Only when ssh_run is installed. Appended to the block
|
|
182
|
+
// reason so the model reads it and stops retrying with awk/grep/raw ssh.
|
|
183
|
+
const readsSshConfig = candidates.some((p) => /(^|\/)\.ssh\/config\b/i.test(p));
|
|
184
|
+
const sshInfoSteer =
|
|
185
|
+
readsSshConfig && isRegistered("ssh_run")
|
|
186
|
+
? ' Use the ssh_run tool with action:"info" to read the effective SSH config instead of reading ~/.ssh/config in bash.'
|
|
187
|
+
: "";
|
|
151
188
|
for (const p of candidates) {
|
|
152
|
-
const hit = classifyPath(p,
|
|
189
|
+
const hit = classifyPath(p, opFor(p), pathRules);
|
|
153
190
|
if (!hit) continue;
|
|
154
191
|
if (hit.severity === "info") {
|
|
155
192
|
ctx.ui.notify(`${PATH_SEVERITY_ICON.info} ${hit.reason}: ${p}`, "info");
|
|
@@ -174,26 +211,28 @@ export default function (pi: ExtensionAPI): void {
|
|
|
174
211
|
});
|
|
175
212
|
}
|
|
176
213
|
|
|
177
|
-
if (concerns.length === 0) return undefined;
|
|
178
|
-
|
|
179
|
-
const highest = concerns.reduce((a, b) => (a.tier > b.tier ? a : b));
|
|
180
|
-
|
|
181
214
|
// Privileged auth redirect — hard block, no prompt, no bypass. Even YOLO
|
|
182
215
|
// cannot run bare sudo/ssh in bash (password/passphrase can't be auto-typed);
|
|
183
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.
|
|
184
218
|
for (const redirect of AUTH_REDIRECTS) {
|
|
185
219
|
if (!redirect.match(command) || !isRegistered(redirect.tool)) continue;
|
|
186
|
-
|
|
220
|
+
const tier = Math.max(SEVERITY_TIER.dangerous, ...concerns.map((c) => c.tier));
|
|
221
|
+
if (unattendedGateDecision(pi.events, tier) === "deny") {
|
|
187
222
|
return { block: true, reason: `[AFK] ${redirect.label} is denied while user is away.` };
|
|
188
223
|
}
|
|
189
224
|
// One surface only: the block reason renders as the tool-error card and
|
|
190
225
|
// reaches the model. A separate notify would duplicate the warning.
|
|
191
226
|
return {
|
|
192
227
|
block: true,
|
|
193
|
-
reason: `DANGEROUS — use the ${redirect.tool} tool instead of ${redirect.label} in
|
|
228
|
+
reason: `DANGEROUS — use the ${redirect.tool} tool instead of ${redirect.label} in ${toolName} (it handles auth securely).`,
|
|
194
229
|
};
|
|
195
230
|
}
|
|
196
231
|
|
|
232
|
+
if (concerns.length === 0) return undefined;
|
|
233
|
+
|
|
234
|
+
const highest = concerns.reduce((a, b) => (a.tier > b.tier ? a : b));
|
|
235
|
+
|
|
197
236
|
// Circuit breaker: catastrophic commands never auto-approve, even under YOLO.
|
|
198
237
|
// They fall through to the interactive dialog (or the no-UI block below).
|
|
199
238
|
const breaker = isCircuitBreaker(command);
|
|
@@ -208,18 +247,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
208
247
|
if (unattended === "deny") {
|
|
209
248
|
return {
|
|
210
249
|
block: true,
|
|
211
|
-
reason: `[AFK][${highest.label}] ${highest.detail}`,
|
|
250
|
+
reason: `[AFK][${highest.label}] ${highest.detail}${sshInfoSteer}`,
|
|
212
251
|
};
|
|
213
252
|
}
|
|
214
253
|
|
|
215
|
-
if (
|
|
254
|
+
if (canAutoApprove(command, autoApprove)) return undefined;
|
|
216
255
|
|
|
217
256
|
// No UI: auto-block block+ severity, pass anything lower.
|
|
218
257
|
if (!ctx.hasUI) {
|
|
219
258
|
if (highest.tier >= SEVERITY_TIER.block) {
|
|
220
259
|
return {
|
|
221
260
|
block: true,
|
|
222
|
-
reason: `[${highest.label}] ${highest.detail} (no UI, auto-blocked)`,
|
|
261
|
+
reason: `[${highest.label}] ${highest.detail} (no UI, auto-blocked)${sshInfoSteer}`,
|
|
223
262
|
};
|
|
224
263
|
}
|
|
225
264
|
return undefined;
|
|
@@ -237,7 +276,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
237
276
|
} else if (concerns.length === 1 && !cmdHit) {
|
|
238
277
|
// Single path hit — reuse path dialog
|
|
239
278
|
const ph = candidates
|
|
240
|
-
.map((p) => ({ p, h: classifyPath(p,
|
|
279
|
+
.map((p) => ({ p, h: classifyPath(p, opFor(p), pathRules) }))
|
|
241
280
|
.find((x) => x.h?.severity !== "info" && x.h);
|
|
242
281
|
if (ph?.h) {
|
|
243
282
|
decision = await withAgentBlock(
|
|
@@ -260,7 +299,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
260
299
|
|
|
261
300
|
if (!decision.approved) {
|
|
262
301
|
ctx.ui.notify(`${highest.icon} ${decision.reason}: ${highest.detail}`, "warning");
|
|
263
|
-
return { block: true, reason: `[${highest.label}] ${decision.reason}` };
|
|
302
|
+
return { block: true, reason: `[${highest.label}] ${decision.reason}${sshInfoSteer}` };
|
|
264
303
|
}
|
|
265
304
|
|
|
266
305
|
const severityColor =
|
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:
|
|
58
|
+
pattern: at(RM_R + SYS_TARGET),
|
|
35
59
|
severity: "critical",
|
|
36
|
-
reason: "rm
|
|
60
|
+
reason: "recursive rm on a system directory",
|
|
37
61
|
},
|
|
38
62
|
{
|
|
39
|
-
pattern:
|
|
63
|
+
pattern: at(String.raw`(?:mkfs(?:\.\w+)?|wipefs|blkdiscard)\b`),
|
|
40
64
|
severity: "critical",
|
|
41
|
-
reason: "
|
|
65
|
+
reason: "filesystem format / wipe",
|
|
42
66
|
},
|
|
43
67
|
{
|
|
44
|
-
pattern:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
107
|
+
reason: "recursive permission / owner change on a system path",
|
|
90
108
|
},
|
|
91
109
|
{
|
|
92
|
-
pattern:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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(
|
|
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=><|;&"'`(])((
|
|
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
|
-
/**
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
+
}
|