@xynogen/pix-gate 0.1.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/package.json +42 -0
- package/src/gate.test.ts +142 -0
- package/src/index.ts +227 -0
- package/src/lib.ts +176 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xynogen/pix-gate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension — permission gate for dangerous bash commands (confirm/block with TUI dialog)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "bun test"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"src/index.ts"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"pi",
|
|
22
|
+
"pi-package",
|
|
23
|
+
"pi-extension",
|
|
24
|
+
"security",
|
|
25
|
+
"permission",
|
|
26
|
+
"gate"
|
|
27
|
+
],
|
|
28
|
+
"author": "xynogen",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/xynogen/pix-mono.git",
|
|
33
|
+
"directory": "packages/pix-gate"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
40
|
+
"@earendil-works/pi-tui": "*"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/gate.test.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildRules, classify, DEFAULT_RULES, isSudoCommand } from "./lib.ts";
|
|
3
|
+
|
|
4
|
+
// ── isSudoCommand ─────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
describe("isSudoCommand", () => {
|
|
7
|
+
test("matches bare sudo", () => {
|
|
8
|
+
expect(isSudoCommand("sudo apt install foo")).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("matches sudo after &&", () => {
|
|
12
|
+
expect(isSudoCommand("cd /tmp && sudo rm -rf x")).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("matches sudo after pipe", () => {
|
|
16
|
+
expect(isSudoCommand("echo y | sudo tee /etc/foo")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("matches sudo after semicolon", () => {
|
|
20
|
+
expect(isSudoCommand("pwd; sudo reboot")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("does NOT match pix-sudo in a path", () => {
|
|
24
|
+
expect(isSudoCommand("cd packages/pix-sudo && npm publish")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("does NOT match pix-sudo-run in a path", () => {
|
|
28
|
+
expect(
|
|
29
|
+
isSudoCommand(
|
|
30
|
+
"grep foo ~/.pi/node_modules/@xynogen/pix-sudo-run/src/lib.ts",
|
|
31
|
+
),
|
|
32
|
+
).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("does NOT match sudoer or pseudo", () => {
|
|
36
|
+
expect(isSudoCommand("cat /etc/sudoers")).toBe(false);
|
|
37
|
+
expect(isSudoCommand("echo pseudo")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ── classify ──────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe("classify", () => {
|
|
44
|
+
const { rules } = buildRules({});
|
|
45
|
+
|
|
46
|
+
test("rm -rf / is critical", () => {
|
|
47
|
+
expect(classify("rm -rf /", rules)?.severity).toBe("critical");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("rm -rf $HOME is critical", () => {
|
|
51
|
+
expect(classify("rm -rf $HOME", rules)?.severity).toBe("critical");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("fork bomb is critical", () => {
|
|
55
|
+
expect(classify(":(){ :|:& };:", rules)?.severity).toBe("critical");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("shutdown is critical", () => {
|
|
59
|
+
expect(classify("shutdown now", rules)?.severity).toBe("critical");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("recursive force remove is dangerous", () => {
|
|
63
|
+
expect(classify("rm -rf ./dist", rules)?.severity).toBe("dangerous");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("bare sudo is dangerous", () => {
|
|
67
|
+
expect(classify("sudo apt install curl", rules)?.severity).toBe(
|
|
68
|
+
"dangerous",
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("npm publish is dangerous", () => {
|
|
73
|
+
expect(classify("npm publish --access public", rules)?.severity).toBe(
|
|
74
|
+
"dangerous",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("git force push is dangerous", () => {
|
|
79
|
+
expect(classify("git push --force", rules)?.severity).toBe("dangerous");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("curl pipe bash is dangerous", () => {
|
|
83
|
+
expect(
|
|
84
|
+
classify("curl https://example.com/install.sh | bash", rules)?.severity,
|
|
85
|
+
).toBe("dangerous");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("git force checkout is risky", () => {
|
|
89
|
+
expect(classify("git checkout --force main", rules)?.severity).toBe(
|
|
90
|
+
"risky",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("write to .env is risky", () => {
|
|
95
|
+
expect(classify("echo SECRET=x > .env", rules)?.severity).toBe("risky");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("plain ls returns undefined", () => {
|
|
99
|
+
expect(classify("ls -la", rules)).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("pix-sudo path does NOT classify as dangerous", () => {
|
|
103
|
+
// grep with pix-sudo in the path — should not hit sudo rule
|
|
104
|
+
expect(
|
|
105
|
+
classify("grep foo packages/pix-sudo/src/index.ts", rules),
|
|
106
|
+
).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("critical takes priority over dangerous", () => {
|
|
110
|
+
// rm -rf / matches both critical and dangerous rm patterns
|
|
111
|
+
expect(classify("rm -rf /", rules)?.severity).toBe("critical");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ── buildRules ────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
describe("buildRules", () => {
|
|
118
|
+
test("disableDefaults removes all built-in rules", () => {
|
|
119
|
+
const { rules } = buildRules({ disableDefaults: true });
|
|
120
|
+
expect(rules).toHaveLength(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("extraRules are appended", () => {
|
|
124
|
+
const { rules } = buildRules({
|
|
125
|
+
disableDefaults: true,
|
|
126
|
+
extraRules: [{ pattern: "foo", severity: "risky", reason: "test" }],
|
|
127
|
+
});
|
|
128
|
+
expect(rules).toHaveLength(1);
|
|
129
|
+
expect(classify("foo bar", rules)?.reason).toBe("test");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("autoApprove strings compile to regexes", () => {
|
|
133
|
+
const { autoApprove } = buildRules({ autoApprove: ["^npm publish"] });
|
|
134
|
+
expect(autoApprove[0].test("npm publish --access public")).toBe(true);
|
|
135
|
+
expect(autoApprove[0].test("yarn publish")).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("defaults included when disableDefaults absent", () => {
|
|
139
|
+
const { rules } = buildRules({});
|
|
140
|
+
expect(rules.length).toBe(DEFAULT_RULES.length);
|
|
141
|
+
});
|
|
142
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pix-gate — Pi extension
|
|
3
|
+
*
|
|
4
|
+
* Intercepts bash `tool_call` events and gates dangerous commands behind a
|
|
5
|
+
* TUI confirmation dialog before they run.
|
|
6
|
+
*
|
|
7
|
+
* Severity tiers:
|
|
8
|
+
* critical — blocked outright in non-interactive mode; hard deny-first in UI
|
|
9
|
+
* dangerous — 30 s auto-deny dialog; sudo fast-blocked with redirect to sudo_run
|
|
10
|
+
* risky — 60 s allow-first dialog; silently passes in non-interactive mode
|
|
11
|
+
*
|
|
12
|
+
* Config: ~/.pi/agent/pix-gate.json
|
|
13
|
+
* disableDefaults: true — replace built-in rules entirely
|
|
14
|
+
* extraRules: [{ pattern, flags?, severity?, reason? }] — append extra rules
|
|
15
|
+
* autoApprove: ["regex"] — bypass gate for matching commands
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Box, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
|
|
21
|
+
import { buildRules, classify, isSudoCommand, loadUserConfig } from "./lib.ts";
|
|
22
|
+
|
|
23
|
+
export default function (pi: ExtensionAPI): void {
|
|
24
|
+
const { rules, autoApprove } = buildRules(loadUserConfig());
|
|
25
|
+
|
|
26
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
27
|
+
if (event.toolName !== "bash") return undefined;
|
|
28
|
+
|
|
29
|
+
const command = String(event.input.command ?? "");
|
|
30
|
+
if (!command.trim()) return undefined;
|
|
31
|
+
|
|
32
|
+
if (autoApprove.some((re) => re.test(command))) return undefined;
|
|
33
|
+
|
|
34
|
+
const hit = classify(command, rules);
|
|
35
|
+
if (!hit) return undefined;
|
|
36
|
+
|
|
37
|
+
// sudo: fast-block with redirect to sudo_run — no dialog needed.
|
|
38
|
+
if (isSudoCommand(command)) {
|
|
39
|
+
ctx.ui.notify(
|
|
40
|
+
`⚠️ ${ctx.ui.theme.fg("warning", "DANGEROUS")} — privilege escalation blocked\n` +
|
|
41
|
+
`${ctx.ui.theme.fg("dim", "Use")} ${ctx.ui.theme.fg("accent", "sudo_run")} ` +
|
|
42
|
+
`${ctx.ui.theme.fg("dim", "tool instead — handles auth securely")}`,
|
|
43
|
+
"warning",
|
|
44
|
+
);
|
|
45
|
+
return {
|
|
46
|
+
block: true,
|
|
47
|
+
reason:
|
|
48
|
+
"[DANGEROUS] privilege escalation — use sudo_run tool instead, it handles authentication securely.",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const icon =
|
|
53
|
+
hit.severity === "critical"
|
|
54
|
+
? "🛑"
|
|
55
|
+
: hit.severity === "dangerous"
|
|
56
|
+
? "⚠️ "
|
|
57
|
+
: "❓";
|
|
58
|
+
const label = hit.severity.toUpperCase();
|
|
59
|
+
|
|
60
|
+
// Non-interactive: block critical + dangerous outright; allow risky silently.
|
|
61
|
+
if (!ctx.hasUI) {
|
|
62
|
+
if (hit.severity === "critical") {
|
|
63
|
+
return {
|
|
64
|
+
block: true,
|
|
65
|
+
reason: `[${label}] ${hit.reason} (no UI, auto-blocked)`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (hit.severity === "dangerous") {
|
|
69
|
+
return {
|
|
70
|
+
block: true,
|
|
71
|
+
reason: `[${label}] ${hit.reason} (no UI for confirmation)`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const timeoutMs =
|
|
78
|
+
hit.severity === "critical"
|
|
79
|
+
? 15_000
|
|
80
|
+
: hit.severity === "dangerous"
|
|
81
|
+
? 30_000
|
|
82
|
+
: 60_000;
|
|
83
|
+
|
|
84
|
+
const severityColor =
|
|
85
|
+
hit.severity === "critical"
|
|
86
|
+
? "error"
|
|
87
|
+
: hit.severity === "dangerous"
|
|
88
|
+
? "warning"
|
|
89
|
+
: "accent";
|
|
90
|
+
|
|
91
|
+
// Critical: deny-first; dangerous/risky: allow-first.
|
|
92
|
+
const choices: SelectItem[] =
|
|
93
|
+
hit.severity === "critical"
|
|
94
|
+
? [
|
|
95
|
+
{
|
|
96
|
+
value: "no",
|
|
97
|
+
label: "No, block it",
|
|
98
|
+
description: "Prevent this command from running",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
value: "yes",
|
|
102
|
+
label: "Yes, I understand the risk",
|
|
103
|
+
description: "Allow once",
|
|
104
|
+
},
|
|
105
|
+
]
|
|
106
|
+
: [
|
|
107
|
+
{
|
|
108
|
+
value: "yes",
|
|
109
|
+
label: "Yes, allow",
|
|
110
|
+
description: "Run the command",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
value: "no",
|
|
114
|
+
label: "No, block it",
|
|
115
|
+
description: "Prevent this command from running",
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
121
|
+
|
|
122
|
+
const choice = await ctx.ui.custom<string | null>(
|
|
123
|
+
(tui, theme, _kb, done) => {
|
|
124
|
+
const container = new Box(0, 0, (s) => theme.bg("customMessageBg", s));
|
|
125
|
+
|
|
126
|
+
container.addChild(
|
|
127
|
+
new DynamicBorder((s: string) => theme.fg(severityColor, s)),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
container.addChild(
|
|
131
|
+
new Text(
|
|
132
|
+
`${icon} ` +
|
|
133
|
+
theme.fg(severityColor, theme.bold(label)) +
|
|
134
|
+
theme.fg("muted", ` — ${hit.reason}`),
|
|
135
|
+
1,
|
|
136
|
+
0,
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
container.addChild(new Text(theme.fg("toolOutput", command), 2, 0));
|
|
141
|
+
|
|
142
|
+
// Live countdown
|
|
143
|
+
const deadlineMs = Date.now() + timeoutMs;
|
|
144
|
+
const countdownText = new Text("", 1, 0);
|
|
145
|
+
const updateCountdown = () => {
|
|
146
|
+
const remaining = Math.max(
|
|
147
|
+
0,
|
|
148
|
+
Math.ceil((deadlineMs - Date.now()) / 1000),
|
|
149
|
+
);
|
|
150
|
+
countdownText.setText(
|
|
151
|
+
theme.fg("dim", "Auto-deny in ") +
|
|
152
|
+
theme.fg(
|
|
153
|
+
remaining <= 5 ? severityColor : "muted",
|
|
154
|
+
`${remaining}s`,
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
};
|
|
158
|
+
updateCountdown();
|
|
159
|
+
const ticker = setInterval(() => {
|
|
160
|
+
updateCountdown();
|
|
161
|
+
tui.requestRender();
|
|
162
|
+
}, 1000);
|
|
163
|
+
container.addChild(countdownText);
|
|
164
|
+
|
|
165
|
+
const list = new SelectList(choices, choices.length, {
|
|
166
|
+
selectedPrefix: (t) => theme.fg(severityColor, t),
|
|
167
|
+
selectedText: (t) => theme.fg(severityColor, t),
|
|
168
|
+
description: (t) => theme.fg("muted", t),
|
|
169
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
170
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
171
|
+
});
|
|
172
|
+
const finish = (v: string | null) => {
|
|
173
|
+
clearInterval(ticker);
|
|
174
|
+
done(v);
|
|
175
|
+
};
|
|
176
|
+
list.onSelect = (item) => finish(item.value);
|
|
177
|
+
list.onCancel = () => finish(null);
|
|
178
|
+
container.addChild(list);
|
|
179
|
+
|
|
180
|
+
container.addChild(
|
|
181
|
+
new Text(
|
|
182
|
+
theme.fg("dim", "↑↓ navigate • enter select • esc cancel"),
|
|
183
|
+
1,
|
|
184
|
+
0,
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
container.addChild(
|
|
189
|
+
new DynamicBorder((s: string) => theme.fg(severityColor, s)),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
controller.signal.addEventListener("abort", () => finish(null));
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
render: (w: number) => container.render(w),
|
|
196
|
+
invalidate: () => container.invalidate(),
|
|
197
|
+
handleInput: (data: string) => {
|
|
198
|
+
list.handleInput(data);
|
|
199
|
+
tui.requestRender();
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
{ overlay: true },
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
clearTimeout(timeoutId);
|
|
207
|
+
|
|
208
|
+
const approved = choice === "yes";
|
|
209
|
+
if (!approved) {
|
|
210
|
+
const reason = controller.signal.aborted
|
|
211
|
+
? "Timed out"
|
|
212
|
+
: "Blocked by user";
|
|
213
|
+
ctx.ui.notify(`${icon} ${reason}: ${hit.reason}`, "warning");
|
|
214
|
+
return { block: true, reason: `[${label}] ${reason}` };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
ctx.ui.notify(
|
|
218
|
+
`${icon} ` +
|
|
219
|
+
ctx.ui.theme.fg(
|
|
220
|
+
severityColor,
|
|
221
|
+
`Approved ${label.toLowerCase()} command`,
|
|
222
|
+
),
|
|
223
|
+
"info",
|
|
224
|
+
);
|
|
225
|
+
return undefined;
|
|
226
|
+
});
|
|
227
|
+
}
|
package/src/lib.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for pix-gate — no Pi API deps, fully unit-testable.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export type Severity = "critical" | "dangerous" | "risky";
|
|
10
|
+
|
|
11
|
+
export interface Rule {
|
|
12
|
+
pattern: RegExp;
|
|
13
|
+
severity: Severity;
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UserConfig {
|
|
18
|
+
extraRules?: {
|
|
19
|
+
pattern: string;
|
|
20
|
+
flags?: string;
|
|
21
|
+
severity?: Severity;
|
|
22
|
+
reason?: string;
|
|
23
|
+
}[];
|
|
24
|
+
disableDefaults?: boolean;
|
|
25
|
+
/** Regex strings — commands matching any are passed through without prompting. */
|
|
26
|
+
autoApprove?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_RULES: Rule[] = [
|
|
30
|
+
// CRITICAL — destructive, irreversible, or system-wide
|
|
31
|
+
{
|
|
32
|
+
pattern: /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\s+\/(\s|$)/i,
|
|
33
|
+
severity: "critical",
|
|
34
|
+
reason: "rm -rf on /",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
pattern:
|
|
38
|
+
/\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\s+(~|\$HOME)(\s|$|\/\s*$)/i,
|
|
39
|
+
severity: "critical",
|
|
40
|
+
reason: "rm -rf on $HOME",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
pattern: /\bmkfs(\.\w+)?\b/i,
|
|
44
|
+
severity: "critical",
|
|
45
|
+
reason: "filesystem formatting",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
pattern: /\bdd\s+.*\bof=\/dev\/(sd[a-z]|nvme|disk)/i,
|
|
49
|
+
severity: "critical",
|
|
50
|
+
reason: "dd to raw block device",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
pattern: />\s*\/dev\/(sd[a-z]|nvme|disk)/i,
|
|
54
|
+
severity: "critical",
|
|
55
|
+
reason: "writing to raw block device",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
pattern: /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
|
|
59
|
+
severity: "critical",
|
|
60
|
+
reason: "fork bomb",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
pattern: /\bshutdown\b|\breboot\b|\bhalt\b|\bpoweroff\b/i,
|
|
64
|
+
severity: "critical",
|
|
65
|
+
reason: "system power command",
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
// DANGEROUS — destructive or privileged but recoverable in scope
|
|
69
|
+
{
|
|
70
|
+
pattern: /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\b/i,
|
|
71
|
+
severity: "dangerous",
|
|
72
|
+
reason: "recursive force remove",
|
|
73
|
+
},
|
|
74
|
+
// Match sudo as a command/operator token, not as a substring of path components like pix-sudo
|
|
75
|
+
{
|
|
76
|
+
pattern: /(^|[\s;|&])sudo\b/i,
|
|
77
|
+
severity: "dangerous",
|
|
78
|
+
reason: "privilege escalation",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
pattern: /\b(chmod|chown)\b[^|;&]*\b(777|-R\s+777)/i,
|
|
82
|
+
severity: "dangerous",
|
|
83
|
+
reason: "world-writable permissions",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
pattern: /\bchmod\s+-R\b/i,
|
|
87
|
+
severity: "dangerous",
|
|
88
|
+
reason: "recursive chmod",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
pattern: /\b(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(sh|bash|zsh)\b/i,
|
|
92
|
+
severity: "dangerous",
|
|
93
|
+
reason: "remote script execution (curl|sh)",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
pattern: /\bgit\s+(push\s+(-f|--force)|reset\s+--hard|clean\s+-[a-z]*f)/i,
|
|
97
|
+
severity: "dangerous",
|
|
98
|
+
reason: "destructive git operation",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
pattern: /\bnpm\s+publish\b/i,
|
|
102
|
+
severity: "dangerous",
|
|
103
|
+
reason: "package publish",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
pattern: /\bdocker\s+(system\s+prune|rm\s+-f|volume\s+rm)/i,
|
|
107
|
+
severity: "dangerous",
|
|
108
|
+
reason: "destructive docker operation",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
pattern: /\bkill\s+-9\s+-1\b/i,
|
|
112
|
+
severity: "dangerous",
|
|
113
|
+
reason: "kill all processes",
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
// RISKY — worth a glance but usually fine
|
|
117
|
+
{
|
|
118
|
+
pattern: /\bgit\s+checkout\s+(-f|--force)/i,
|
|
119
|
+
severity: "risky",
|
|
120
|
+
reason: "force checkout (overwrites local changes)",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
pattern: /\bgit\s+stash\s+drop\b/i,
|
|
124
|
+
severity: "risky",
|
|
125
|
+
reason: "stash drop",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
pattern: />\s*[^|&;]*\.env\b/i,
|
|
129
|
+
severity: "risky",
|
|
130
|
+
reason: "writing to .env",
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
export function loadUserConfig(): UserConfig {
|
|
135
|
+
const path = join(homedir(), ".pi", "agent", "pix-gate.json");
|
|
136
|
+
if (!existsSync(path)) return {};
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(readFileSync(path, "utf-8")) as UserConfig;
|
|
139
|
+
} catch {
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function buildRules(cfg: UserConfig): {
|
|
145
|
+
rules: Rule[];
|
|
146
|
+
autoApprove: RegExp[];
|
|
147
|
+
} {
|
|
148
|
+
const base = cfg.disableDefaults ? [] : DEFAULT_RULES.slice();
|
|
149
|
+
const extra = (cfg.extraRules ?? []).map((r) => ({
|
|
150
|
+
pattern: new RegExp(r.pattern, r.flags ?? "i"),
|
|
151
|
+
severity: (r.severity ?? "dangerous") as Severity,
|
|
152
|
+
reason: r.reason ?? "user-defined rule",
|
|
153
|
+
}));
|
|
154
|
+
const autoApprove = (cfg.autoApprove ?? []).map((s) => new RegExp(s));
|
|
155
|
+
return { rules: [...base, ...extra], autoApprove };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Return the highest-severity rule that matches `command`, or undefined if none.
|
|
160
|
+
* Checks critical → dangerous → risky in order, returning on first match.
|
|
161
|
+
*/
|
|
162
|
+
export function classify(command: string, rules: Rule[]): Rule | undefined {
|
|
163
|
+
const order: Severity[] = ["critical", "dangerous", "risky"];
|
|
164
|
+
for (const sev of order) {
|
|
165
|
+
const hit = rules.find(
|
|
166
|
+
(r) => r.severity === sev && r.pattern.test(command),
|
|
167
|
+
);
|
|
168
|
+
if (hit) return hit;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** True when command contains a real sudo invocation (not a path like pix-sudo). */
|
|
174
|
+
export function isSudoCommand(command: string): boolean {
|
|
175
|
+
return /(^|[\s;|&])sudo\b/i.test(command);
|
|
176
|
+
}
|