@xynogen/pix-gate 0.2.12 → 0.3.0
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 +1 -1
- package/src/index.ts +30 -9
- package/src/lib.ts +10 -0
- package/src/prompt.ts +11 -4
package/README.md
CHANGED
|
@@ -19,9 +19,11 @@ Intercepts every tool call and classifies it against severity rules before it ru
|
|
|
19
19
|
| Tier | Examples | Behavior |
|
|
20
20
|
|---|---|---|
|
|
21
21
|
| `critical` | force-push to main, recursive delete, `dd` to disk | hard-block (non-interactive) / 15s auto-deny dialog (TUI) |
|
|
22
|
-
| `dangerous` |
|
|
22
|
+
| `dangerous` | destructive git, `chmod 777`, `curl … \| sh` | 30s auto-deny confirmation |
|
|
23
23
|
| `risky` | — | 60s allow-first dialog; silently passes non-interactive |
|
|
24
24
|
|
|
25
|
+
**Privileged auth redirect** — bash `sudo`/`ssh` can't have their password/passphrase auto-typed, so they are hard-blocked and redirected to the dedicated tool (`sudo_run`/`ssh_run`) with no bypass — but *only when that tool is installed* (pix-sudo/pix-ssh are opt-in; without them the bash form is the only path and passes through). This is **guarding** (stop + redirect); the softer **steering** of non-privileged stand-ins (`cat`→`read`, `grep`→`grep`, …) lives in [`pix-nudge`](../pix-nudge).
|
|
26
|
+
|
|
25
27
|
Auto-approve patterns and extra rules go in the `gate` section of `~/.pi/agent/pix.json`; set `guardrails: "off"` to disable built-in rules entirely.
|
|
26
28
|
|
|
27
29
|
**Away notifications** — while a dialog is open, the gate holds the shared **agent-state** coordinator `blocked` (via `withAgentBlock` from [`@xynogen/pix-runtime`](https://www.npmjs.com/package/@xynogen/pix-runtime)), so a herdr pane pings an away user. Pairs with pix-commands unattended modes:
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
classifyPath,
|
|
26
26
|
extractPathsFromBash,
|
|
27
27
|
isCircuitBreaker,
|
|
28
|
+
isSshCommand,
|
|
28
29
|
isSudoCommand,
|
|
29
30
|
loadUserConfig,
|
|
30
31
|
unattendedGateDecision,
|
|
@@ -42,6 +43,26 @@ import {
|
|
|
42
43
|
export default function (pi: ExtensionAPI): void {
|
|
43
44
|
const { rules, autoApprove, pathRules } = buildRules(loadUserConfig());
|
|
44
45
|
|
|
46
|
+
// Is a tool registered (active or gated)? ssh_run/sudo_run are opt-in and
|
|
47
|
+
// absent when pix-ssh/pix-sudo aren't installed — only redirect toward a
|
|
48
|
+
// tool that actually exists, else the bash form is the only path.
|
|
49
|
+
const isRegistered = (name: string): boolean => {
|
|
50
|
+
try {
|
|
51
|
+
return (pi.getAllTools?.() ?? []).some((t) => t.name === name);
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Privileged auth tools that must not run raw in bash (interactive password /
|
|
58
|
+
// passphrase can't be auto-typed there). Each redirects to its dedicated tool
|
|
59
|
+
// ONLY when that tool is installed. pix-nudge handles the non-privileged
|
|
60
|
+
// read/ls/grep/find/edit stand-ins.
|
|
61
|
+
const AUTH_REDIRECTS = [
|
|
62
|
+
{ match: isSudoCommand, tool: "sudo_run", label: "sudo" },
|
|
63
|
+
{ match: isSshCommand, tool: "ssh_run", label: "ssh" },
|
|
64
|
+
] as const;
|
|
65
|
+
|
|
45
66
|
// ── Path protection (read/write/edit tools only — bash handled below) ───
|
|
46
67
|
pi.on("tool_call", async (event, ctx) => {
|
|
47
68
|
const tool = String(event.toolName);
|
|
@@ -157,19 +178,19 @@ export default function (pi: ExtensionAPI): void {
|
|
|
157
178
|
|
|
158
179
|
const highest = concerns.reduce((a, b) => (a.tier > b.tier ? a : b));
|
|
159
180
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
181
|
+
// Privileged auth redirect — hard block, no prompt, no bypass. Even YOLO
|
|
182
|
+
// cannot run bare sudo/ssh in bash (password/passphrase can't be auto-typed);
|
|
183
|
+
// it must use the dedicated tool. Only fires when that tool is installed.
|
|
184
|
+
for (const redirect of AUTH_REDIRECTS) {
|
|
185
|
+
if (!redirect.match(command) || !isRegistered(redirect.tool)) continue;
|
|
163
186
|
if (unattendedGateDecision(pi.events, highest.tier) === "deny") {
|
|
164
|
-
return { block: true, reason:
|
|
187
|
+
return { block: true, reason: `[AFK] ${redirect.label} is denied while user is away.` };
|
|
165
188
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
"warning",
|
|
169
|
-
);
|
|
189
|
+
// One surface only: the block reason renders as the tool-error card and
|
|
190
|
+
// reaches the model. A separate notify would duplicate the warning.
|
|
170
191
|
return {
|
|
171
192
|
block: true,
|
|
172
|
-
reason:
|
|
193
|
+
reason: `DANGEROUS — use the ${redirect.tool} tool instead of ${redirect.label} in bash (it handles auth securely).`,
|
|
173
194
|
};
|
|
174
195
|
}
|
|
175
196
|
|
package/src/lib.ts
CHANGED
|
@@ -298,6 +298,16 @@ export function isSudoCommand(command: string): boolean {
|
|
|
298
298
|
return /(^|[\s;|&])sudo\b/i.test(command);
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/**
|
|
302
|
+
* True when a command invokes the `ssh` remote shell as a token. Requires
|
|
303
|
+
* trailing whitespace/end so it does NOT match `ssh-keygen`, `ssh-add`,
|
|
304
|
+
* `ssh-copy-id`, or `sshpass` (a later real ` ssh ` in the same line still
|
|
305
|
+
* matches, e.g. `sshpass -p x ssh host`).
|
|
306
|
+
*/
|
|
307
|
+
export function isSshCommand(command: string): boolean {
|
|
308
|
+
return /(^|[\s;|&])ssh(\s|$)/i.test(command);
|
|
309
|
+
}
|
|
310
|
+
|
|
301
311
|
// Non-lifting circuit breaker: catastrophic, unrecoverable commands that NO
|
|
302
312
|
// mode (not even YOLO) may auto-approve. Mirrors Claude Code's bypassPermissions
|
|
303
313
|
// floor, which still prompts on root/home wipes. These always fall through to
|
package/src/prompt.ts
CHANGED
|
@@ -26,6 +26,11 @@ export interface Concern {
|
|
|
26
26
|
|
|
27
27
|
export type GatePromptUI = ExtensionContext["ui"];
|
|
28
28
|
|
|
29
|
+
/** Title Case a severity tier for dialog titles: "critical" → "Critical". */
|
|
30
|
+
function titleCase(word: string): string {
|
|
31
|
+
return word ? word[0]!.toUpperCase() + word.slice(1).toLowerCase() : word;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
const TIMEOUT_MS: Record<Rule["severity"], number> = {
|
|
30
35
|
critical: 15_000,
|
|
31
36
|
dangerous: 30_000,
|
|
@@ -73,7 +78,7 @@ export async function promptPathDecision(
|
|
|
73
78
|
path: string,
|
|
74
79
|
): Promise<GateDecision> {
|
|
75
80
|
const icon = PATH_SEVERITY_ICON[hit.severity];
|
|
76
|
-
const label = hit.severity
|
|
81
|
+
const label = titleCase(hit.severity);
|
|
77
82
|
const accent = PATH_SEVERITY_COLOR[hit.severity];
|
|
78
83
|
|
|
79
84
|
const choices =
|
|
@@ -105,7 +110,8 @@ export async function promptPathDecision(
|
|
|
105
110
|
|
|
106
111
|
const result: OverlayResult = await showOverlay(ui as Parameters<typeof showOverlay>[0], {
|
|
107
112
|
mode: "confirm",
|
|
108
|
-
|
|
113
|
+
icon,
|
|
114
|
+
title: `${label} — ${hit.reason}`,
|
|
109
115
|
body: [`${op.toUpperCase()} ${path}`],
|
|
110
116
|
accent,
|
|
111
117
|
timeoutMs: PATH_TIMEOUT_MS[hit.severity],
|
|
@@ -231,7 +237,7 @@ export async function promptGateDecision(
|
|
|
231
237
|
command: string,
|
|
232
238
|
): Promise<GateDecision> {
|
|
233
239
|
const icon = SEVERITY_ICON[hit.severity];
|
|
234
|
-
const label = hit.severity
|
|
240
|
+
const label = titleCase(hit.severity);
|
|
235
241
|
const accent = SEVERITY_COLOR[hit.severity];
|
|
236
242
|
|
|
237
243
|
// critical: deny listed first so it's the default selected item
|
|
@@ -260,7 +266,8 @@ export async function promptGateDecision(
|
|
|
260
266
|
|
|
261
267
|
const result: OverlayResult = await showOverlay(ui as Parameters<typeof showOverlay>[0], {
|
|
262
268
|
mode: "confirm",
|
|
263
|
-
|
|
269
|
+
icon,
|
|
270
|
+
title: `${label} — ${hit.reason}`,
|
|
264
271
|
body: [command],
|
|
265
272
|
accent,
|
|
266
273
|
timeoutMs: TIMEOUT_MS[hit.severity],
|