@xynogen/pix-gate 0.1.5 → 0.1.6
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 +1 -1
- package/src/index.ts +106 -32
- package/src/lib.ts +112 -1
- package/src/prompt.ts +79 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* Intercepts bash `tool_call` events and gates dangerous commands behind a
|
|
5
5
|
* TUI confirmation dialog before they run.
|
|
6
6
|
*
|
|
7
|
-
* Severity tiers:
|
|
8
|
-
* critical —
|
|
9
|
-
* dangerous —
|
|
10
|
-
* risky —
|
|
7
|
+
* Severity tiers (user always has final say via dialog):
|
|
8
|
+
* critical — red, deny-first dialog
|
|
9
|
+
* dangerous — yellow, deny-first dialog
|
|
10
|
+
* risky — allow-first dialog
|
|
11
|
+
* sudo — hard block, must use sudo_run tool instead (no bypass)
|
|
12
|
+
* No-UI fallback: critical/dangerous auto-block (can't show dialog)
|
|
11
13
|
*
|
|
12
14
|
* Config: ~/.pi/agent/pix-gate.json
|
|
13
15
|
* disableDefaults: true — replace built-in rules entirely
|
|
@@ -16,11 +18,93 @@
|
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
+
import {
|
|
22
|
+
buildRules,
|
|
23
|
+
classify,
|
|
24
|
+
classifyPath,
|
|
25
|
+
extractPathsFromBash,
|
|
26
|
+
isSudoCommand,
|
|
27
|
+
loadUserConfig,
|
|
28
|
+
} from "./lib.ts";
|
|
29
|
+
import {
|
|
30
|
+
PATH_SEVERITY_ICON,
|
|
31
|
+
promptGateDecision,
|
|
32
|
+
promptPathDecision,
|
|
33
|
+
SEVERITY_ICON,
|
|
34
|
+
} from "./prompt.ts";
|
|
21
35
|
|
|
22
36
|
export default function (pi: ExtensionAPI): void {
|
|
23
|
-
const { rules, autoApprove } = buildRules(loadUserConfig());
|
|
37
|
+
const { rules, autoApprove, pathRules } = buildRules(loadUserConfig());
|
|
38
|
+
|
|
39
|
+
// ── Path protection ──────────────────────────────────────────────────────
|
|
40
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
41
|
+
const tool = String(event.toolName);
|
|
42
|
+
const input = event.input as Record<string, unknown>;
|
|
43
|
+
let path: string | undefined;
|
|
44
|
+
let op: "read" | "write";
|
|
45
|
+
|
|
46
|
+
if (tool === "read") {
|
|
47
|
+
path = String(input.path ?? "");
|
|
48
|
+
op = "read";
|
|
49
|
+
} else if (tool === "write" || tool === "edit") {
|
|
50
|
+
path = String(input.path ?? "");
|
|
51
|
+
op = "write";
|
|
52
|
+
} else if (tool === "bash") {
|
|
53
|
+
const cmd = String(input.command ?? "");
|
|
54
|
+
const candidates = extractPathsFromBash(cmd);
|
|
55
|
+
for (const p of candidates) {
|
|
56
|
+
const hit = classifyPath(p, "read", pathRules);
|
|
57
|
+
if (!hit) continue;
|
|
58
|
+
if (hit.severity === "info") {
|
|
59
|
+
ctx.ui.notify(
|
|
60
|
+
`${PATH_SEVERITY_ICON.info} ${hit.reason}: ${p}`,
|
|
61
|
+
"info",
|
|
62
|
+
);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!ctx.hasUI)
|
|
66
|
+
return {
|
|
67
|
+
block: true,
|
|
68
|
+
reason: `[PATH:${hit.severity.toUpperCase()}] ${hit.reason} (no UI)`,
|
|
69
|
+
};
|
|
70
|
+
const decision = await promptPathDecision(ctx.ui, hit, "bash read", p);
|
|
71
|
+
if (!decision.approved)
|
|
72
|
+
return {
|
|
73
|
+
block: true,
|
|
74
|
+
reason: `[PATH] ${decision.reason}: ${hit.reason}`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
} else {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!path) return undefined;
|
|
83
|
+
const hit = classifyPath(path, op, pathRules);
|
|
84
|
+
if (!hit) return undefined;
|
|
85
|
+
|
|
86
|
+
if (hit.severity === "info") {
|
|
87
|
+
ctx.ui.notify(
|
|
88
|
+
`${PATH_SEVERITY_ICON.info} ${hit.reason}: ${path}`,
|
|
89
|
+
"info",
|
|
90
|
+
);
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!ctx.hasUI)
|
|
95
|
+
return {
|
|
96
|
+
block: true,
|
|
97
|
+
reason: `[PATH:${hit.severity.toUpperCase()}] ${hit.reason} (no UI)`,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const decision = await promptPathDecision(ctx.ui, hit, op, path);
|
|
101
|
+
if (!decision.approved)
|
|
102
|
+
return {
|
|
103
|
+
block: true,
|
|
104
|
+
reason: `[PATH] ${decision.reason}: ${hit.reason}`,
|
|
105
|
+
};
|
|
106
|
+
return undefined;
|
|
107
|
+
});
|
|
24
108
|
|
|
25
109
|
pi.on("tool_call", async (event, ctx) => {
|
|
26
110
|
if (event.toolName !== "bash") return undefined;
|
|
@@ -33,41 +117,31 @@ export default function (pi: ExtensionAPI): void {
|
|
|
33
117
|
const hit = classify(command, rules);
|
|
34
118
|
if (!hit) return undefined;
|
|
35
119
|
|
|
36
|
-
|
|
120
|
+
const icon = SEVERITY_ICON[hit.severity];
|
|
121
|
+
const label = hit.severity.toUpperCase();
|
|
122
|
+
|
|
123
|
+
// No UI: can't show dialog — auto-block critical/dangerous, pass risky.
|
|
124
|
+
if (!ctx.hasUI) {
|
|
125
|
+
if (hit.severity === "risky") return undefined;
|
|
126
|
+
return {
|
|
127
|
+
block: true,
|
|
128
|
+
reason: `[${label}] ${hit.reason} (no UI, auto-blocked)`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// sudo: hard redirect to sudo_run — no prompt, no bypass.
|
|
37
133
|
if (isSudoCommand(command)) {
|
|
38
134
|
ctx.ui.notify(
|
|
39
|
-
`⚠️ ${ctx.ui.theme.fg("warning", "DANGEROUS")} —
|
|
40
|
-
`${ctx.ui.theme.fg("dim", "Use")} ${ctx.ui.theme.fg("accent", "sudo_run")} ` +
|
|
41
|
-
`${ctx.ui.theme.fg("dim", "tool instead — handles auth securely")}`,
|
|
135
|
+
`⚠️ ${ctx.ui.theme.fg("warning", "DANGEROUS")} — use sudo_run tool instead (handles auth securely)`,
|
|
42
136
|
"warning",
|
|
43
137
|
);
|
|
44
138
|
return {
|
|
45
139
|
block: true,
|
|
46
140
|
reason:
|
|
47
|
-
"[DANGEROUS] privilege escalation — use sudo_run tool
|
|
141
|
+
"[DANGEROUS] privilege escalation — use sudo_run tool, it handles authentication securely.",
|
|
48
142
|
};
|
|
49
143
|
}
|
|
50
144
|
|
|
51
|
-
const icon = SEVERITY_ICON[hit.severity];
|
|
52
|
-
const label = hit.severity.toUpperCase();
|
|
53
|
-
|
|
54
|
-
// Non-interactive: block critical + dangerous outright; allow risky silently.
|
|
55
|
-
if (!ctx.hasUI) {
|
|
56
|
-
if (hit.severity === "critical") {
|
|
57
|
-
return {
|
|
58
|
-
block: true,
|
|
59
|
-
reason: `[${label}] ${hit.reason} (no UI, auto-blocked)`,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
if (hit.severity === "dangerous") {
|
|
63
|
-
return {
|
|
64
|
-
block: true,
|
|
65
|
-
reason: `[${label}] ${hit.reason} (no UI for confirmation)`,
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
return undefined;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
145
|
const decision = await promptGateDecision(ctx.ui, hit, command);
|
|
72
146
|
|
|
73
147
|
if (!decision.approved) {
|
package/src/lib.ts
CHANGED
|
@@ -144,6 +144,7 @@ export function loadUserConfig(): UserConfig {
|
|
|
144
144
|
export function buildRules(cfg: UserConfig): {
|
|
145
145
|
rules: Rule[];
|
|
146
146
|
autoApprove: RegExp[];
|
|
147
|
+
pathRules: PathRule[];
|
|
147
148
|
} {
|
|
148
149
|
const base = cfg.disableDefaults ? [] : DEFAULT_RULES.slice();
|
|
149
150
|
const extra = (cfg.extraRules ?? []).map((r) => ({
|
|
@@ -152,7 +153,9 @@ export function buildRules(cfg: UserConfig): {
|
|
|
152
153
|
reason: r.reason ?? "user-defined rule",
|
|
153
154
|
}));
|
|
154
155
|
const autoApprove = (cfg.autoApprove ?? []).map((s) => new RegExp(s));
|
|
155
|
-
|
|
156
|
+
// ponytail: path rule config extension skipped for now — add extraPathRules/disablePathDefaults to UserConfig when needed
|
|
157
|
+
const pathRules = cfg.disableDefaults ? [] : DEFAULT_PATH_RULES.slice();
|
|
158
|
+
return { rules: [...base, ...extra], autoApprove, pathRules };
|
|
156
159
|
}
|
|
157
160
|
|
|
158
161
|
/**
|
|
@@ -170,6 +173,114 @@ export function classify(command: string, rules: Rule[]): Rule | undefined {
|
|
|
170
173
|
return undefined;
|
|
171
174
|
}
|
|
172
175
|
|
|
176
|
+
// ── Path rules ───────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
export type PathSeverity = "block" | "warn" | "info";
|
|
179
|
+
|
|
180
|
+
export interface PathRule {
|
|
181
|
+
pattern: RegExp;
|
|
182
|
+
severity: PathSeverity;
|
|
183
|
+
reason: string;
|
|
184
|
+
/** Which ops to intercept: "read" | "write" | both (default both) */
|
|
185
|
+
ops?: ("read" | "write")[];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* block — red, deny-first dialog (user can still allow)
|
|
190
|
+
* warn — yellow, allow-first dialog
|
|
191
|
+
* info — blue notify only, always passes through
|
|
192
|
+
*/
|
|
193
|
+
export const DEFAULT_PATH_RULES: PathRule[] = [
|
|
194
|
+
// block — red deny-first
|
|
195
|
+
{
|
|
196
|
+
pattern: /(^|\/)id_(rsa|dsa|ecdsa|ed25519)$/i,
|
|
197
|
+
severity: "block",
|
|
198
|
+
reason: "SSH private key",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
pattern: /\.(pem|key|p12|pfx|jks|keystore)$/i,
|
|
202
|
+
severity: "block",
|
|
203
|
+
reason: "private key / keystore",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
pattern: /(^|\/)\.aws\/credentials$/i,
|
|
207
|
+
severity: "block",
|
|
208
|
+
reason: "AWS credentials",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
pattern: /(^|\/)\.netrc$/i,
|
|
212
|
+
severity: "block",
|
|
213
|
+
reason: "netrc credentials",
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
pattern: /(^|\/)(credentials|service-account)\.(json|ya?ml|toml)$/i,
|
|
217
|
+
severity: "block",
|
|
218
|
+
reason: "credentials file",
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
// warn — yellow allow-first
|
|
222
|
+
{ pattern: /(^|\/)\.env(\.|$)/i, severity: "warn", reason: ".env file" },
|
|
223
|
+
{ pattern: /(^|\/)\.envrc$/i, severity: "warn", reason: "direnv file" },
|
|
224
|
+
{
|
|
225
|
+
pattern: /(^|\/)\.npmrc$/i,
|
|
226
|
+
severity: "warn",
|
|
227
|
+
reason: ".npmrc (may contain auth tokens)",
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
pattern: /(^|\/)\.pypirc$/i,
|
|
231
|
+
severity: "warn",
|
|
232
|
+
reason: ".pypirc (may contain tokens)",
|
|
233
|
+
},
|
|
234
|
+
{ pattern: /\.(crt|cer)$/i, severity: "warn", reason: "certificate file" },
|
|
235
|
+
{
|
|
236
|
+
pattern: /(^|\/)(secrets?)\.(json|ya?ml|toml)$/i,
|
|
237
|
+
severity: "warn",
|
|
238
|
+
reason: "secrets file",
|
|
239
|
+
},
|
|
240
|
+
{ pattern: /(^|\/)\.ssh\//i, severity: "warn", reason: ".ssh directory" },
|
|
241
|
+
|
|
242
|
+
// info — notify only (write-only guard)
|
|
243
|
+
{
|
|
244
|
+
pattern: /(^|\/)\.git\//,
|
|
245
|
+
severity: "info",
|
|
246
|
+
reason: ".git directory",
|
|
247
|
+
ops: ["write"],
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
pattern: /(^|\/)node_modules\//,
|
|
251
|
+
severity: "info",
|
|
252
|
+
reason: "node_modules",
|
|
253
|
+
ops: ["write"],
|
|
254
|
+
},
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
export function classifyPath(
|
|
258
|
+
path: string,
|
|
259
|
+
op: "read" | "write",
|
|
260
|
+
rules: PathRule[],
|
|
261
|
+
): PathRule | undefined {
|
|
262
|
+
const order: PathSeverity[] = ["block", "warn", "info"];
|
|
263
|
+
for (const sev of order) {
|
|
264
|
+
const hit = rules.find((r) => {
|
|
265
|
+
if (r.severity !== sev) return false;
|
|
266
|
+
if (r.ops && !r.ops.includes(op)) return false;
|
|
267
|
+
return r.pattern.test(path);
|
|
268
|
+
});
|
|
269
|
+
if (hit) return hit;
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Extract candidate file paths from a bash command string */
|
|
275
|
+
export function extractPathsFromBash(cmd: string): string[] {
|
|
276
|
+
const out: string[] = [];
|
|
277
|
+
const re =
|
|
278
|
+
/(?:^|[\s=><|;&"'`(])((?:\.\.\/|\/|~\/|\.)[^\s"'`<>|;&)]+|\.env(?:\.[A-Za-z0-9_-]+)?|[A-Za-z0-9_./-]+\.(?:pem|key|p12|pfx|crt|cer|env|envrc|netrc))/g;
|
|
279
|
+
let m: RegExpExecArray | null;
|
|
280
|
+
while ((m = re.exec(cmd)) !== null) out.push(m[1]);
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
173
284
|
/** True when command contains a real sudo invocation (not a path like pix-sudo). */
|
|
174
285
|
export function isSudoCommand(command: string): boolean {
|
|
175
286
|
return /(^|[\s;|&])sudo\b/i.test(command);
|
package/src/prompt.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type OverlayResult,
|
|
11
11
|
showOverlay,
|
|
12
12
|
} from "@xynogen/pix-pretty/gate-overlay";
|
|
13
|
-
import type { Rule } from "./lib.ts";
|
|
13
|
+
import type { PathRule, Rule } from "./lib.ts";
|
|
14
14
|
|
|
15
15
|
export interface GateDecision {
|
|
16
16
|
approved: boolean;
|
|
@@ -38,6 +38,84 @@ export const SEVERITY_ICON: Record<Rule["severity"], string> = {
|
|
|
38
38
|
risky: "❓",
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
const PATH_SEVERITY_COLOR: Record<PathRule["severity"], string> = {
|
|
42
|
+
block: "error",
|
|
43
|
+
warn: "warning",
|
|
44
|
+
info: "accent",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const PATH_SEVERITY_ICON: Record<PathRule["severity"], string> = {
|
|
48
|
+
block: "🔴",
|
|
49
|
+
warn: "🟡",
|
|
50
|
+
info: "🔵",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const PATH_TIMEOUT_MS: Record<PathRule["severity"], number> = {
|
|
54
|
+
block: 15_000,
|
|
55
|
+
warn: 30_000,
|
|
56
|
+
info: 0, // info never shows a dialog
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Show confirm/deny dialog for a path access.
|
|
61
|
+
* block: deny-first. warn: allow-first. info: never called (notify only).
|
|
62
|
+
*/
|
|
63
|
+
export async function promptPathDecision(
|
|
64
|
+
ui: GatePromptUI,
|
|
65
|
+
hit: PathRule,
|
|
66
|
+
op: string,
|
|
67
|
+
path: string,
|
|
68
|
+
): Promise<GateDecision> {
|
|
69
|
+
const icon = PATH_SEVERITY_ICON[hit.severity];
|
|
70
|
+
const label = hit.severity.toUpperCase();
|
|
71
|
+
const accent = PATH_SEVERITY_COLOR[hit.severity];
|
|
72
|
+
|
|
73
|
+
const choices =
|
|
74
|
+
hit.severity === "block"
|
|
75
|
+
? [
|
|
76
|
+
{
|
|
77
|
+
value: "no",
|
|
78
|
+
label: "No, block it",
|
|
79
|
+
description: "Deny this file access",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
value: "yes",
|
|
83
|
+
label: "Yes, I know what I'm doing",
|
|
84
|
+
description: "Allow once",
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
: [
|
|
88
|
+
{
|
|
89
|
+
value: "yes",
|
|
90
|
+
label: "Yes, allow",
|
|
91
|
+
description: "Proceed with file access",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
value: "no",
|
|
95
|
+
label: "No, block it",
|
|
96
|
+
description: "Deny this file access",
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const result: OverlayResult = await showOverlay(
|
|
101
|
+
ui as Parameters<typeof showOverlay>[0],
|
|
102
|
+
{
|
|
103
|
+
mode: "confirm",
|
|
104
|
+
title: `${icon} ${label} — ${hit.reason}`,
|
|
105
|
+
body: [`${op.toUpperCase()} ${path}`],
|
|
106
|
+
accent,
|
|
107
|
+
timeoutMs: PATH_TIMEOUT_MS[hit.severity],
|
|
108
|
+
choices,
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (result.action === "approved")
|
|
113
|
+
return { approved: true, reason: "Approved" };
|
|
114
|
+
if (result.action === "timeout")
|
|
115
|
+
return { approved: false, reason: "Timed out" };
|
|
116
|
+
return { approved: false, reason: "Blocked by user" };
|
|
117
|
+
}
|
|
118
|
+
|
|
41
119
|
/**
|
|
42
120
|
* Show the confirm/deny dialog for a matched command.
|
|
43
121
|
* Critical is deny-first; dangerous/risky are allow-first.
|