@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-gate",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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",
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 — 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
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 { buildRules, classify, isSudoCommand, loadUserConfig } from "./lib.ts";
20
- import { promptGateDecision, SEVERITY_ICON } from "./prompt.ts";
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
- // sudo: fast-block with redirect to sudo_run — no dialog needed.
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")} — privilege escalation blocked\n` +
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 instead, it handles authentication securely.",
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
- return { rules: [...base, ...extra], autoApprove };
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.