@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 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.4",
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 — 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
@@ -1,15 +1,16 @@
1
1
  /**
2
- * pix-gate — Part 2: the prompt.
2
+ * pix-gate — prompt.ts
3
3
  *
4
- * The interactive confirm/deny dialog, decoupled from the rule engine (lib.ts).
5
- * Returns a pure decision; the caller maps it to a tool-call result and emits
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 { DynamicBorder } from "@earendil-works/pi-coding-agent";
11
- import { Box, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
12
- import type { Rule } from "./lib.ts";
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
- } as const satisfies Record<Rule["severity"], string>;
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 the confirm/deny dialog for a matched command and resolve the decision.
43
- * Critical defaults to deny-first; dangerous/risky default to allow-first.
44
- * Times out to a denial after a severity-scaled deadline.
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
- const choices: SelectItem[] =
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 controller = new AbortController();
84
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
85
-
86
- const choice = await ui.custom<string | null>(
87
- (tui, theme, _kb, done) => {
88
- const container = new Box(0, 0, (s) => theme.bg("customMessageBg", s));
89
-
90
- container.addChild(
91
- new DynamicBorder((s: string) => theme.fg(severityColor, s)),
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
- clearTimeout(timeoutId);
167
-
168
- const approved = choice === "yes";
169
- if (approved) return { approved: true, reason: "Approved" };
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 };