@xynogen/pix-gate 0.1.4 → 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/README.md +3 -1
- package/package.json +4 -1
- package/src/index.ts +106 -32
- package/src/lib.ts +112 -1
- package/src/prompt.ts +109 -112
package/README.md
CHANGED
|
@@ -26,7 +26,9 @@ classification logic can be reused without the TUI:
|
|
|
26
26
|
- `@xynogen/pix-gate/lib` — pure rules: `DEFAULT_RULES`, `buildRules`,
|
|
27
27
|
`classify`, `loadUserConfig`, `isSudoCommand`. No Pi/TUI dependency.
|
|
28
28
|
- `@xynogen/pix-gate/prompt` — `promptGateDecision()`, the confirm/deny dialog
|
|
29
|
-
(depends on `pi-tui`).
|
|
29
|
+
(depends on `pi-tui`). This is now a thin adapter over the shared
|
|
30
|
+
`@xynogen/pix-pretty/gate-overlay` component, so the gate and `sudo_run`
|
|
31
|
+
dialogs share one implementation.
|
|
30
32
|
|
|
31
33
|
`pix-skills` imports `./lib` to gate skill `` !`cmd` `` directives with the same
|
|
32
34
|
rules as the bash tool (auto-deny on match, no prompt).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-gate",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
@@ -41,6 +41,9 @@
|
|
|
41
41
|
"publishConfig": {
|
|
42
42
|
"access": "public"
|
|
43
43
|
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@xynogen/pix-pretty": "*"
|
|
46
|
+
},
|
|
44
47
|
"peerDependencies": {
|
|
45
48
|
"@earendil-works/pi-coding-agent": "*",
|
|
46
49
|
"@earendil-works/pi-tui": "*"
|
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
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pix-gate —
|
|
2
|
+
* pix-gate — prompt.ts
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* any notifications. This lets the rule engine be reused without the TUI dep.
|
|
4
|
+
* Thin adapter: maps gate severity/rule → showOverlay (pix-pretty).
|
|
5
|
+
* All dialog logic lives in @xynogen/pix-pretty/gate-overlay.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
import {
|
|
10
|
+
type OverlayResult,
|
|
11
|
+
showOverlay,
|
|
12
|
+
} from "@xynogen/pix-pretty/gate-overlay";
|
|
13
|
+
import type { PathRule, Rule } from "./lib.ts";
|
|
13
14
|
|
|
14
15
|
export interface GateDecision {
|
|
15
16
|
approved: boolean;
|
|
@@ -17,7 +18,6 @@ export interface GateDecision {
|
|
|
17
18
|
reason: string;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
/** The UI surface the dialog needs — the extension context's `ui`. */
|
|
21
21
|
export type GatePromptUI = ExtensionContext["ui"];
|
|
22
22
|
|
|
23
23
|
const TIMEOUT_MS: Record<Rule["severity"], number> = {
|
|
@@ -26,34 +26,111 @@ const TIMEOUT_MS: Record<Rule["severity"], number> = {
|
|
|
26
26
|
risky: 60_000,
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
const SEVERITY_COLOR = {
|
|
29
|
+
const SEVERITY_COLOR: Record<Rule["severity"], string> = {
|
|
30
30
|
critical: "error",
|
|
31
31
|
dangerous: "warning",
|
|
32
32
|
risky: "accent",
|
|
33
|
-
}
|
|
33
|
+
};
|
|
34
34
|
|
|
35
|
-
const SEVERITY_ICON: Record<Rule["severity"], string> = {
|
|
35
|
+
export const SEVERITY_ICON: Record<Rule["severity"], string> = {
|
|
36
36
|
critical: "🛑",
|
|
37
37
|
dangerous: "⚠️ ",
|
|
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
|
+
|
|
41
59
|
/**
|
|
42
|
-
* Show
|
|
43
|
-
*
|
|
44
|
-
|
|
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
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Show the confirm/deny dialog for a matched command.
|
|
121
|
+
* Critical is deny-first; dangerous/risky are allow-first.
|
|
45
122
|
*/
|
|
46
123
|
export async function promptGateDecision(
|
|
47
124
|
ui: GatePromptUI,
|
|
48
125
|
hit: Rule,
|
|
49
126
|
command: string,
|
|
50
127
|
): Promise<GateDecision> {
|
|
51
|
-
const timeoutMs = TIMEOUT_MS[hit.severity];
|
|
52
|
-
const severityColor = SEVERITY_COLOR[hit.severity];
|
|
53
128
|
const icon = SEVERITY_ICON[hit.severity];
|
|
54
129
|
const label = hit.severity.toUpperCase();
|
|
130
|
+
const accent = SEVERITY_COLOR[hit.severity];
|
|
55
131
|
|
|
56
|
-
|
|
132
|
+
// critical: deny listed first so it's the default selected item
|
|
133
|
+
const choices =
|
|
57
134
|
hit.severity === "critical"
|
|
58
135
|
? [
|
|
59
136
|
{
|
|
@@ -68,11 +145,7 @@ export async function promptGateDecision(
|
|
|
68
145
|
},
|
|
69
146
|
]
|
|
70
147
|
: [
|
|
71
|
-
{
|
|
72
|
-
value: "yes",
|
|
73
|
-
label: "Yes, allow",
|
|
74
|
-
description: "Run the command",
|
|
75
|
-
},
|
|
148
|
+
{ value: "yes", label: "Yes, allow", description: "Run the command" },
|
|
76
149
|
{
|
|
77
150
|
value: "no",
|
|
78
151
|
label: "No, block it",
|
|
@@ -80,97 +153,21 @@ export async function promptGateDecision(
|
|
|
80
153
|
},
|
|
81
154
|
];
|
|
82
155
|
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
container.addChild(
|
|
95
|
-
new Text(
|
|
96
|
-
`${icon} ` +
|
|
97
|
-
theme.fg(severityColor, theme.bold(label)) +
|
|
98
|
-
theme.fg("muted", ` — ${hit.reason}`),
|
|
99
|
-
1,
|
|
100
|
-
0,
|
|
101
|
-
),
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
container.addChild(new Text(theme.fg("toolOutput", command), 2, 0));
|
|
105
|
-
|
|
106
|
-
const deadlineMs = Date.now() + timeoutMs;
|
|
107
|
-
const countdownText = new Text("", 1, 0);
|
|
108
|
-
const updateCountdown = () => {
|
|
109
|
-
const remaining = Math.max(
|
|
110
|
-
0,
|
|
111
|
-
Math.ceil((deadlineMs - Date.now()) / 1000),
|
|
112
|
-
);
|
|
113
|
-
countdownText.setText(
|
|
114
|
-
theme.fg("dim", "Auto-deny in ") +
|
|
115
|
-
theme.fg(remaining <= 5 ? severityColor : "muted", `${remaining}s`),
|
|
116
|
-
);
|
|
117
|
-
};
|
|
118
|
-
updateCountdown();
|
|
119
|
-
const ticker = setInterval(() => {
|
|
120
|
-
updateCountdown();
|
|
121
|
-
tui.requestRender();
|
|
122
|
-
}, 1000);
|
|
123
|
-
container.addChild(countdownText);
|
|
124
|
-
|
|
125
|
-
const list = new SelectList(choices, choices.length, {
|
|
126
|
-
selectedPrefix: (t) => theme.fg(severityColor, t),
|
|
127
|
-
selectedText: (t) => theme.fg(severityColor, t),
|
|
128
|
-
description: (t) => theme.fg("muted", t),
|
|
129
|
-
scrollInfo: (t) => theme.fg("dim", t),
|
|
130
|
-
noMatch: (t) => theme.fg("warning", t),
|
|
131
|
-
});
|
|
132
|
-
const finish = (v: string | null) => {
|
|
133
|
-
clearInterval(ticker);
|
|
134
|
-
done(v);
|
|
135
|
-
};
|
|
136
|
-
list.onSelect = (item) => finish(item.value);
|
|
137
|
-
list.onCancel = () => finish(null);
|
|
138
|
-
container.addChild(list);
|
|
139
|
-
|
|
140
|
-
container.addChild(
|
|
141
|
-
new Text(
|
|
142
|
-
theme.fg("dim", "↑↓ navigate • enter select • esc cancel"),
|
|
143
|
-
1,
|
|
144
|
-
0,
|
|
145
|
-
),
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
container.addChild(
|
|
149
|
-
new DynamicBorder((s: string) => theme.fg(severityColor, s)),
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
controller.signal.addEventListener("abort", () => finish(null));
|
|
153
|
-
|
|
154
|
-
return {
|
|
155
|
-
render: (w: number) => container.render(w),
|
|
156
|
-
invalidate: () => container.invalidate(),
|
|
157
|
-
handleInput: (data: string) => {
|
|
158
|
-
list.handleInput(data);
|
|
159
|
-
tui.requestRender();
|
|
160
|
-
},
|
|
161
|
-
};
|
|
156
|
+
const result: OverlayResult = await showOverlay(
|
|
157
|
+
ui as Parameters<typeof showOverlay>[0],
|
|
158
|
+
{
|
|
159
|
+
mode: "confirm",
|
|
160
|
+
title: `${icon} ${label} — ${hit.reason}`,
|
|
161
|
+
body: [command],
|
|
162
|
+
accent,
|
|
163
|
+
timeoutMs: TIMEOUT_MS[hit.severity],
|
|
164
|
+
choices,
|
|
162
165
|
},
|
|
163
|
-
{ overlay: true },
|
|
164
166
|
);
|
|
165
167
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return {
|
|
171
|
-
approved: false,
|
|
172
|
-
reason: controller.signal.aborted ? "Timed out" : "Blocked by user",
|
|
173
|
-
};
|
|
168
|
+
if (result.action === "approved")
|
|
169
|
+
return { approved: true, reason: "Approved" };
|
|
170
|
+
if (result.action === "timeout")
|
|
171
|
+
return { approved: false, reason: "Timed out" };
|
|
172
|
+
return { approved: false, reason: "Blocked by user" };
|
|
174
173
|
}
|
|
175
|
-
|
|
176
|
-
export { SEVERITY_ICON };
|