@xynogen/pix-gate 0.2.13 → 0.3.2
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 +50 -14
- package/src/lib.ts +10 -0
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);
|
|
@@ -116,10 +137,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
116
137
|
risky: 1,
|
|
117
138
|
};
|
|
118
139
|
|
|
140
|
+
// The unified command gate also covers pix-proc's `proc` tool: its `start`
|
|
141
|
+
// action carries a shell command in `event.input.command`, identical to bash.
|
|
142
|
+
// proc's other actions (list/logs/stop/rm) carry no command and pass straight
|
|
143
|
+
// through. Install pix-proc without pix-gate = ungated, same as bash.
|
|
144
|
+
const GATED_COMMAND_TOOLS = new Set(["bash", "proc"]);
|
|
119
145
|
pi.on("tool_call", async (event, ctx) => {
|
|
120
|
-
if (event.toolName
|
|
146
|
+
if (!GATED_COMMAND_TOOLS.has(String(event.toolName))) return undefined;
|
|
121
147
|
|
|
122
|
-
const command = String(event.input.command ?? "");
|
|
148
|
+
const command = String((event.input as Record<string, unknown>).command ?? "");
|
|
123
149
|
if (!command.trim()) return undefined;
|
|
124
150
|
|
|
125
151
|
// Collect all concerns: path hits + command hit
|
|
@@ -127,6 +153,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
127
153
|
|
|
128
154
|
// Path concerns
|
|
129
155
|
const candidates = extractPathsFromBash(command);
|
|
156
|
+
|
|
157
|
+
// Steer a blocked ssh-config read toward the dedicated `ssh info` tool
|
|
158
|
+
// (ssh_run action:"info"), which reads the effective config without touching
|
|
159
|
+
// ~/.ssh directly. Only when ssh_run is installed. Appended to the block
|
|
160
|
+
// reason so the model reads it and stops retrying with awk/grep/raw ssh.
|
|
161
|
+
const readsSshConfig = candidates.some((p) => /(^|\/)\.ssh\/config\b/i.test(p));
|
|
162
|
+
const sshInfoSteer =
|
|
163
|
+
readsSshConfig && isRegistered("ssh_run")
|
|
164
|
+
? ' Use the ssh_run tool with action:"info" to read the effective SSH config instead of reading ~/.ssh/config in bash.'
|
|
165
|
+
: "";
|
|
130
166
|
for (const p of candidates) {
|
|
131
167
|
const hit = classifyPath(p, "read", pathRules);
|
|
132
168
|
if (!hit) continue;
|
|
@@ -157,19 +193,19 @@ export default function (pi: ExtensionAPI): void {
|
|
|
157
193
|
|
|
158
194
|
const highest = concerns.reduce((a, b) => (a.tier > b.tier ? a : b));
|
|
159
195
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
196
|
+
// Privileged auth redirect — hard block, no prompt, no bypass. Even YOLO
|
|
197
|
+
// cannot run bare sudo/ssh in bash (password/passphrase can't be auto-typed);
|
|
198
|
+
// it must use the dedicated tool. Only fires when that tool is installed.
|
|
199
|
+
for (const redirect of AUTH_REDIRECTS) {
|
|
200
|
+
if (!redirect.match(command) || !isRegistered(redirect.tool)) continue;
|
|
163
201
|
if (unattendedGateDecision(pi.events, highest.tier) === "deny") {
|
|
164
|
-
return { block: true, reason:
|
|
202
|
+
return { block: true, reason: `[AFK] ${redirect.label} is denied while user is away.` };
|
|
165
203
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
"warning",
|
|
169
|
-
);
|
|
204
|
+
// One surface only: the block reason renders as the tool-error card and
|
|
205
|
+
// reaches the model. A separate notify would duplicate the warning.
|
|
170
206
|
return {
|
|
171
207
|
block: true,
|
|
172
|
-
reason:
|
|
208
|
+
reason: `DANGEROUS — use the ${redirect.tool} tool instead of ${redirect.label} in bash (it handles auth securely).`,
|
|
173
209
|
};
|
|
174
210
|
}
|
|
175
211
|
|
|
@@ -187,7 +223,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
187
223
|
if (unattended === "deny") {
|
|
188
224
|
return {
|
|
189
225
|
block: true,
|
|
190
|
-
reason: `[AFK][${highest.label}] ${highest.detail}`,
|
|
226
|
+
reason: `[AFK][${highest.label}] ${highest.detail}${sshInfoSteer}`,
|
|
191
227
|
};
|
|
192
228
|
}
|
|
193
229
|
|
|
@@ -198,7 +234,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
198
234
|
if (highest.tier >= SEVERITY_TIER.block) {
|
|
199
235
|
return {
|
|
200
236
|
block: true,
|
|
201
|
-
reason: `[${highest.label}] ${highest.detail} (no UI, auto-blocked)`,
|
|
237
|
+
reason: `[${highest.label}] ${highest.detail} (no UI, auto-blocked)${sshInfoSteer}`,
|
|
202
238
|
};
|
|
203
239
|
}
|
|
204
240
|
return undefined;
|
|
@@ -239,7 +275,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
239
275
|
|
|
240
276
|
if (!decision.approved) {
|
|
241
277
|
ctx.ui.notify(`${highest.icon} ${decision.reason}: ${highest.detail}`, "warning");
|
|
242
|
-
return { block: true, reason: `[${highest.label}] ${decision.reason}` };
|
|
278
|
+
return { block: true, reason: `[${highest.label}] ${decision.reason}${sshInfoSteer}` };
|
|
243
279
|
}
|
|
244
280
|
|
|
245
281
|
const severityColor =
|
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
|