@xynogen/pix-gate 0.1.3 → 0.1.5
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 +9 -1
- package/package.json +4 -1
- package/src/prompt.ts +30 -111
package/README.md
CHANGED
|
@@ -12,6 +12,12 @@ Intercepts every `bash` tool call and classifies the command against a set of se
|
|
|
12
12
|
pi install npm:@xynogen/pix-gate
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
> Also included in [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core):
|
|
16
|
+
>
|
|
17
|
+
> ```bash
|
|
18
|
+
> pi install npm:@xynogen/pix-core
|
|
19
|
+
> ```
|
|
20
|
+
|
|
15
21
|
## Reusable exports
|
|
16
22
|
|
|
17
23
|
The gate is split into a pure rule engine and the interactive prompt, so the
|
|
@@ -20,7 +26,9 @@ classification logic can be reused without the TUI:
|
|
|
20
26
|
- `@xynogen/pix-gate/lib` — pure rules: `DEFAULT_RULES`, `buildRules`,
|
|
21
27
|
`classify`, `loadUserConfig`, `isSudoCommand`. No Pi/TUI dependency.
|
|
22
28
|
- `@xynogen/pix-gate/prompt` — `promptGateDecision()`, the confirm/deny dialog
|
|
23
|
-
(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.
|
|
24
32
|
|
|
25
33
|
`pix-skills` imports `./lib` to gate skill `` !`cmd` `` directives with the same
|
|
26
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.5",
|
|
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/prompt.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
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
|
-
|
|
9
|
+
import {
|
|
10
|
+
type OverlayResult,
|
|
11
|
+
showOverlay,
|
|
12
|
+
} from "@xynogen/pix-pretty/gate-overlay";
|
|
12
13
|
import type { Rule } from "./lib.ts";
|
|
13
14
|
|
|
14
15
|
export interface GateDecision {
|
|
@@ -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,33 @@ 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
41
|
/**
|
|
42
|
-
* Show the confirm/deny dialog for a matched command
|
|
43
|
-
* Critical
|
|
44
|
-
* Times out to a denial after a severity-scaled deadline.
|
|
42
|
+
* Show the confirm/deny dialog for a matched command.
|
|
43
|
+
* Critical is deny-first; dangerous/risky are allow-first.
|
|
45
44
|
*/
|
|
46
45
|
export async function promptGateDecision(
|
|
47
46
|
ui: GatePromptUI,
|
|
48
47
|
hit: Rule,
|
|
49
48
|
command: string,
|
|
50
49
|
): Promise<GateDecision> {
|
|
51
|
-
const timeoutMs = TIMEOUT_MS[hit.severity];
|
|
52
|
-
const severityColor = SEVERITY_COLOR[hit.severity];
|
|
53
50
|
const icon = SEVERITY_ICON[hit.severity];
|
|
54
51
|
const label = hit.severity.toUpperCase();
|
|
52
|
+
const accent = SEVERITY_COLOR[hit.severity];
|
|
55
53
|
|
|
56
|
-
|
|
54
|
+
// critical: deny listed first so it's the default selected item
|
|
55
|
+
const choices =
|
|
57
56
|
hit.severity === "critical"
|
|
58
57
|
? [
|
|
59
58
|
{
|
|
@@ -68,11 +67,7 @@ export async function promptGateDecision(
|
|
|
68
67
|
},
|
|
69
68
|
]
|
|
70
69
|
: [
|
|
71
|
-
{
|
|
72
|
-
value: "yes",
|
|
73
|
-
label: "Yes, allow",
|
|
74
|
-
description: "Run the command",
|
|
75
|
-
},
|
|
70
|
+
{ value: "yes", label: "Yes, allow", description: "Run the command" },
|
|
76
71
|
{
|
|
77
72
|
value: "no",
|
|
78
73
|
label: "No, block it",
|
|
@@ -80,97 +75,21 @@ export async function promptGateDecision(
|
|
|
80
75
|
},
|
|
81
76
|
];
|
|
82
77
|
|
|
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
|
-
};
|
|
78
|
+
const result: OverlayResult = await showOverlay(
|
|
79
|
+
ui as Parameters<typeof showOverlay>[0],
|
|
80
|
+
{
|
|
81
|
+
mode: "confirm",
|
|
82
|
+
title: `${icon} ${label} — ${hit.reason}`,
|
|
83
|
+
body: [command],
|
|
84
|
+
accent,
|
|
85
|
+
timeoutMs: TIMEOUT_MS[hit.severity],
|
|
86
|
+
choices,
|
|
162
87
|
},
|
|
163
|
-
{ overlay: true },
|
|
164
88
|
);
|
|
165
89
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return {
|
|
171
|
-
approved: false,
|
|
172
|
-
reason: controller.signal.aborted ? "Timed out" : "Blocked by user",
|
|
173
|
-
};
|
|
90
|
+
if (result.action === "approved")
|
|
91
|
+
return { approved: true, reason: "Approved" };
|
|
92
|
+
if (result.action === "timeout")
|
|
93
|
+
return { approved: false, reason: "Timed out" };
|
|
94
|
+
return { approved: false, reason: "Blocked by user" };
|
|
174
95
|
}
|
|
175
|
-
|
|
176
|
-
export { SEVERITY_ICON };
|