@zeldrisho/pi-gate 0.7.1 → 0.7.3
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/CHANGELOG.md +16 -1
- package/README.md +1 -3
- package/package.json +4 -2
- package/src/approval-prompt.ts +179 -0
- package/src/index.ts +86 -9
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.3] - 2026-10-28
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Show the approval timeout and configured confirmation keys, and allow horizontal scrolling of long command lines.
|
|
15
|
+
|
|
16
|
+
## [0.7.2] - 2026-10-25
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Show approval prompts in Pi’s normal dialog area, using the terminal background and Pi theme accents.
|
|
21
|
+
- Highlight matched command text and clarify agent guidance for timeouts, denials, and policy blocks.
|
|
22
|
+
|
|
10
23
|
## [0.7.1] - 2026-10-25
|
|
11
24
|
|
|
12
25
|
### Changed
|
|
@@ -105,7 +118,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
105
118
|
- support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
|
|
106
119
|
- block rather than auto-approve in non-UI modes
|
|
107
120
|
|
|
108
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.
|
|
121
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.3...HEAD
|
|
122
|
+
[0.7.3]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.2...pi-gate-v0.7.3
|
|
123
|
+
[0.7.2]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.1...pi-gate-v0.7.2
|
|
109
124
|
[0.7.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.0...pi-gate-v0.7.1
|
|
110
125
|
[0.7.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.6.1...pi-gate-v0.7.0
|
|
111
126
|
[0.6.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.6.0...pi-gate-v0.6.1
|
package/README.md
CHANGED
|
@@ -46,9 +46,7 @@ pi-gate is a guard rail, not a sandbox. Complex shell syntax, pipelines, redirec
|
|
|
46
46
|
|
|
47
47
|
The built-in `bash` tool is gated, and `write`/`edit` calls targeting the gate config require approval. Prompt dialogs offer `Allow` and `Deny`; approvals are never remembered. To stop repeated prompts, add a specific `allow` rule to `gate.json`.
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
Long commands are truncated in approval dialogs, but an approved command executes in full. Denied, dismissed, blocked, and timed-out calls stop the turn; other allowed calls in a parallel batch may still run. In non-interactive modes (`-p`, JSON), commands requiring approval are denied.
|
|
49
|
+
The approval dialog highlights the matched command text. Long commands are truncated in the dialog but execute in full if approved. Denials, blocks, dismissals, and timeouts stop the turn; other allowed calls in a parallel batch may still run. In non-interactive modes (`-p`, JSON), commands requiring approval are denied.
|
|
52
50
|
|
|
53
51
|
In Herdr-managed panes, install Herdr's Pi integration with `herdr integration install pi` to report pending approvals as `blocked`. Herdr is optional.
|
|
54
52
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-gate",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "Pi extension that gates bash commands and protects its configuration from tool edits",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"command-blocking",
|
|
@@ -33,13 +33,15 @@
|
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@earendil-works/pi-coding-agent": "^1.4.0",
|
|
36
|
+
"@earendil-works/pi-tui": "^1.4.0",
|
|
36
37
|
"typebox": "^1.4.2",
|
|
37
38
|
"typescript": "^7.0.2",
|
|
38
39
|
"vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
|
|
39
40
|
"vite-plus": "1.3.0"
|
|
40
41
|
},
|
|
41
42
|
"peerDependencies": {
|
|
42
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
43
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
44
|
+
"@earendil-works/pi-tui": "*"
|
|
43
45
|
},
|
|
44
46
|
"engines": {
|
|
45
47
|
"node": ">=24"
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { keyHint, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
sliceByColumn,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
wrapTextWithAnsi,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
|
|
12
|
+
const ACTIONS = ["Allow", "Deny"] as const;
|
|
13
|
+
|
|
14
|
+
type Action = (typeof ACTIONS)[number];
|
|
15
|
+
|
|
16
|
+
type Foreground = "text" | "muted" | "accent" | "toolOutput" | "border";
|
|
17
|
+
|
|
18
|
+
export interface ApprovalPromptTheme {
|
|
19
|
+
style(text: string, options: { fg?: Foreground; bold?: boolean }): string;
|
|
20
|
+
bold(text: string): string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ApprovalPromptKeybindings {
|
|
24
|
+
matches(
|
|
25
|
+
data: string,
|
|
26
|
+
action: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel",
|
|
27
|
+
): boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ApprovalPromptTui {
|
|
31
|
+
requestRender(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Theme-aware approval dialog used by the interactive TUI. */
|
|
35
|
+
export function createApprovalPrompt(
|
|
36
|
+
tui: ApprovalPromptTui,
|
|
37
|
+
theme: ApprovalPromptTheme,
|
|
38
|
+
keybindings: ApprovalPromptKeybindings,
|
|
39
|
+
done: (result: Action | undefined) => void,
|
|
40
|
+
command: string,
|
|
41
|
+
rule: string,
|
|
42
|
+
deadline?: number,
|
|
43
|
+
): Component {
|
|
44
|
+
let selectedIndex = 0;
|
|
45
|
+
let horizontalOffset = 0;
|
|
46
|
+
let maxHorizontalOffset = 0;
|
|
47
|
+
let pageWidth = 1;
|
|
48
|
+
|
|
49
|
+
const invalidate = () => {};
|
|
50
|
+
|
|
51
|
+
const innerWidth = (width: number) => Math.max(1, width - 4);
|
|
52
|
+
|
|
53
|
+
const panelLine = (
|
|
54
|
+
text: string,
|
|
55
|
+
width: number,
|
|
56
|
+
color: "text" | "muted" | "accent" | "toolOutput" = "text",
|
|
57
|
+
) => {
|
|
58
|
+
const contentWidth = innerWidth(width);
|
|
59
|
+
const content = truncateToWidth(text, contentWidth, "…");
|
|
60
|
+
const padding = " ".repeat(Math.max(0, contentWidth - visibleWidth(content)));
|
|
61
|
+
|
|
62
|
+
const styled = theme.style(`${content}${padding}`, { fg: color });
|
|
63
|
+
|
|
64
|
+
return ` ${styled} `;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const component: Component = {
|
|
68
|
+
render(width: number): string[] {
|
|
69
|
+
const available = innerWidth(width);
|
|
70
|
+
|
|
71
|
+
const longestLine = Math.max(
|
|
72
|
+
0,
|
|
73
|
+
...command.split("\n").map((line) => visibleWidth(line.replace(/[»«]/gu, ""))),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
pageWidth = longestLine > available ? Math.max(1, available - 2) : available;
|
|
77
|
+
maxHorizontalOffset = longestLine > available ? longestLine - pageWidth : 0;
|
|
78
|
+
horizontalOffset = Math.min(horizontalOffset, maxHorizontalOffset);
|
|
79
|
+
const border = theme.style("─".repeat(available + 4), { fg: "border" });
|
|
80
|
+
|
|
81
|
+
const lines = [
|
|
82
|
+
border,
|
|
83
|
+
panelLine(
|
|
84
|
+
theme.bold(
|
|
85
|
+
`${deadline === undefined ? "" : `(${Math.max(0, Math.ceil((deadline - Date.now()) / 1_000))}s) `}pi-gate: allow this command?`,
|
|
86
|
+
),
|
|
87
|
+
width,
|
|
88
|
+
"accent",
|
|
89
|
+
),
|
|
90
|
+
panelLine("Review the requested command or file change:", width, "muted"),
|
|
91
|
+
panelLine("", width),
|
|
92
|
+
...command.split("\n").map((line) => {
|
|
93
|
+
const contentWidth = innerWidth(width);
|
|
94
|
+
const parts = line.split(/([»«])/u);
|
|
95
|
+
let matched = false;
|
|
96
|
+
|
|
97
|
+
const rendered = parts
|
|
98
|
+
.map((part) => {
|
|
99
|
+
if (part === "»") {
|
|
100
|
+
matched = true;
|
|
101
|
+
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (part === "«") {
|
|
106
|
+
matched = false;
|
|
107
|
+
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return theme.style(part, { fg: matched ? "accent" : "toolOutput", bold: matched });
|
|
112
|
+
})
|
|
113
|
+
.join("");
|
|
114
|
+
|
|
115
|
+
const visible = line.replace(/[»«]/gu, "");
|
|
116
|
+
const content = sliceByColumn(rendered, horizontalOffset, pageWidth, true);
|
|
117
|
+
const prefix = horizontalOffset > 0 && visibleWidth(visible) > 0 ? "…" : "";
|
|
118
|
+
const suffix = visibleWidth(visible) > horizontalOffset + pageWidth ? "…" : "";
|
|
119
|
+
|
|
120
|
+
const padding = " ".repeat(
|
|
121
|
+
Math.max(0, contentWidth - visibleWidth(prefix + content + suffix)),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return ` ${theme.style(prefix, { fg: "toolOutput" })}${content}${theme.style(suffix + padding, { fg: "toolOutput" })} `;
|
|
125
|
+
}),
|
|
126
|
+
panelLine("", width),
|
|
127
|
+
panelLine(`Matched rule: ${rule}`, width, "muted"),
|
|
128
|
+
panelLine("", width),
|
|
129
|
+
...ACTIONS.map((action, index) => {
|
|
130
|
+
const selected = index === selectedIndex;
|
|
131
|
+
const label = `${selected ? "→ " : " "}${action}`;
|
|
132
|
+
const row = truncateToWidth(label, available);
|
|
133
|
+
const padded = `${row}${" ".repeat(Math.max(0, available - visibleWidth(row)))}`;
|
|
134
|
+
|
|
135
|
+
return theme.style(` ${padded} `, {
|
|
136
|
+
fg: selected ? "accent" : "text",
|
|
137
|
+
});
|
|
138
|
+
}),
|
|
139
|
+
panelLine("", width),
|
|
140
|
+
...wrapTextWithAnsi(
|
|
141
|
+
`${rawKeyHint("↑↓", "navigate")} ${maxHorizontalOffset > 0 ? `${rawKeyHint("←→", "scroll command")} ` : ""}${keyHint("tui.select.confirm", "select")} ${keyHint("tui.select.cancel", "cancel")}`,
|
|
142
|
+
available,
|
|
143
|
+
).map((line) => ` ${line} `),
|
|
144
|
+
border,
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
148
|
+
},
|
|
149
|
+
handleInput(data: string): void {
|
|
150
|
+
if (matchesKey(data, Key.left)) {
|
|
151
|
+
horizontalOffset = Math.max(0, horizontalOffset - pageWidth);
|
|
152
|
+
} else if (matchesKey(data, Key.right)) {
|
|
153
|
+
horizontalOffset = Math.min(maxHorizontalOffset, horizontalOffset + pageWidth);
|
|
154
|
+
} else if (keybindings.matches(data, "tui.select.up") || data === "k") {
|
|
155
|
+
selectedIndex = Math.max(0, selectedIndex - 1);
|
|
156
|
+
} else if (keybindings.matches(data, "tui.select.down") || data === "j") {
|
|
157
|
+
selectedIndex = Math.min(ACTIONS.length - 1, selectedIndex + 1);
|
|
158
|
+
} else if (keybindings.matches(data, "tui.select.confirm")) {
|
|
159
|
+
done(ACTIONS[selectedIndex]);
|
|
160
|
+
|
|
161
|
+
return;
|
|
162
|
+
} else if (keybindings.matches(data, "tui.select.cancel")) {
|
|
163
|
+
done(undefined);
|
|
164
|
+
|
|
165
|
+
return;
|
|
166
|
+
} else {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
component.invalidate();
|
|
171
|
+
tui.requestRender();
|
|
172
|
+
},
|
|
173
|
+
invalidate,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return component;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export type ApprovalAction = Action;
|
package/src/index.ts
CHANGED
|
@@ -31,8 +31,10 @@
|
|
|
31
31
|
import {
|
|
32
32
|
isToolCallEventType,
|
|
33
33
|
type ExtensionAPI,
|
|
34
|
+
type ExtensionContext,
|
|
34
35
|
type ToolCallEventResult,
|
|
35
36
|
} from "@earendil-works/pi-coding-agent";
|
|
37
|
+
import { createApprovalPrompt, type ApprovalAction } from "./approval-prompt";
|
|
36
38
|
import { configPath, ensureConfig, loadConfigResult } from "./config";
|
|
37
39
|
import {
|
|
38
40
|
bashTouchesConfig,
|
|
@@ -44,6 +46,85 @@ import { formatCommandForDisplay, formatPromptCommand, formatRule } from "./disp
|
|
|
44
46
|
|
|
45
47
|
const HERDR_BLOCKED_LABEL = "pi-gate: approval required";
|
|
46
48
|
|
|
49
|
+
/** Uses a themed terminal component in TUI mode and Pi's standard selector elsewhere. */
|
|
50
|
+
async function selectApproval(
|
|
51
|
+
ctx: ExtensionContext,
|
|
52
|
+
timeoutMs: number,
|
|
53
|
+
signal: AbortSignal,
|
|
54
|
+
command: string,
|
|
55
|
+
rule: string,
|
|
56
|
+
showMatchHint: boolean,
|
|
57
|
+
): Promise<string | undefined> {
|
|
58
|
+
if (ctx.mode !== "tui" || !ctx.ui.custom) {
|
|
59
|
+
return ctx.ui.select(
|
|
60
|
+
`pi-gate: allow this command?\n\n${command}\n\nMatched rule: ${rule}${showMatchHint ? "\nMatched command text is wrapped in »…«" : ""}`,
|
|
61
|
+
["Allow", "Deny"],
|
|
62
|
+
{ timeout: timeoutMs, signal },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let uiDone: ((value: ApprovalAction | undefined) => void) | undefined;
|
|
67
|
+
let settled = false;
|
|
68
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
69
|
+
let countdown: ReturnType<typeof setInterval> | undefined;
|
|
70
|
+
let removeAbortListener: (() => void) | undefined;
|
|
71
|
+
|
|
72
|
+
return new Promise<ApprovalAction | undefined>((resolve, reject) => {
|
|
73
|
+
const finish = (value: ApprovalAction | undefined) => {
|
|
74
|
+
if (settled) return;
|
|
75
|
+
settled = true;
|
|
76
|
+
|
|
77
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
78
|
+
|
|
79
|
+
if (countdown !== undefined) clearInterval(countdown);
|
|
80
|
+
removeAbortListener?.();
|
|
81
|
+
resolve(value);
|
|
82
|
+
uiDone?.(value);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const onAbort = () => finish(undefined);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const deadline = Date.now() + timeoutMs;
|
|
89
|
+
timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
90
|
+
|
|
91
|
+
const uiPromise = ctx.ui.custom<ApprovalAction | undefined>(
|
|
92
|
+
(tui, theme, keybindings, done) => {
|
|
93
|
+
uiDone = done;
|
|
94
|
+
countdown = setInterval(() => tui.requestRender(), 1_000);
|
|
95
|
+
|
|
96
|
+
return createApprovalPrompt(tui, theme, keybindings, done, command, rule, deadline);
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
uiPromise
|
|
101
|
+
.then(finish)
|
|
102
|
+
.catch(reject)
|
|
103
|
+
.finally(() => {
|
|
104
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
105
|
+
|
|
106
|
+
if (countdown !== undefined) clearInterval(countdown);
|
|
107
|
+
removeAbortListener?.();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (signal) {
|
|
111
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
112
|
+
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
|
113
|
+
|
|
114
|
+
if (signal.aborted) onAbort();
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
settled = true;
|
|
118
|
+
|
|
119
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
120
|
+
|
|
121
|
+
if (countdown !== undefined) clearInterval(countdown);
|
|
122
|
+
removeAbortListener?.();
|
|
123
|
+
reject(error);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
47
128
|
const MAX_REASON_RULE_NAME_LENGTH = 27;
|
|
48
129
|
|
|
49
130
|
/** Escapes a rule pattern for terminal display and bounds its length in denial messages. */
|
|
@@ -57,7 +138,7 @@ function reasonRuleName(pattern: string): string {
|
|
|
57
138
|
|
|
58
139
|
/** Formats an approval timeout with the matched rule, elapsed limit in seconds, and retry guidance. */
|
|
59
140
|
function timeoutReason(pattern: string, timeoutMs: number): string {
|
|
60
|
-
return `pi-gate: no response to rule "${reasonRuleName(pattern)}" within ${timeoutMs / 1_000}s; command not run. Ask the user whether to retry.`;
|
|
141
|
+
return `pi-gate: no response to rule "${reasonRuleName(pattern)}" within ${timeoutMs / 1_000}s; command not run. Ask the user whether to retry approval.`;
|
|
61
142
|
}
|
|
62
143
|
|
|
63
144
|
function reportHerdrBlocked(pi: ExtensionAPI, ctx: { mode?: string }, active: boolean): void {
|
|
@@ -218,7 +299,7 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
218
299
|
const rule = configChange ? CONFIG_CHANGE_REASON : formatRule(match);
|
|
219
300
|
|
|
220
301
|
if (match.action === "block") {
|
|
221
|
-
const reason = `pi-gate: blocked by rule "${reasonRuleName(match.pattern)}".
|
|
302
|
+
const reason = `pi-gate: command blocked by rule "${reasonRuleName(match.pattern)}". Ask the user before proceeding.`;
|
|
222
303
|
|
|
223
304
|
if (ctx.hasUI) ctx.ui.notify(reason, "warning");
|
|
224
305
|
|
|
@@ -230,7 +311,7 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
230
311
|
block: true,
|
|
231
312
|
reason: configChange
|
|
232
313
|
? CONFIG_CHANGE_REASON
|
|
233
|
-
: `pi-gate: rule "${reasonRuleName(match.pattern)}" needs approval but no UI is available.
|
|
314
|
+
: `pi-gate: rule "${reasonRuleName(match.pattern)}" needs approval but no UI is available. Ask the user before proceeding.`,
|
|
234
315
|
terminate: true,
|
|
235
316
|
};
|
|
236
317
|
}
|
|
@@ -248,11 +329,7 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
248
329
|
|
|
249
330
|
try {
|
|
250
331
|
const promptStartedAt = Date.now();
|
|
251
|
-
choice = await ctx
|
|
252
|
-
`pi-gate: allow this command?\n\n${promptCommand}\n\nMatched rule: ${rule}${configChange ? "" : "\nMatched command text is wrapped in »…«"}`,
|
|
253
|
-
["Allow", "Deny"],
|
|
254
|
-
{ timeout: timeoutMs, signal },
|
|
255
|
-
);
|
|
332
|
+
choice = await selectApproval(ctx, timeoutMs, signal, promptCommand, rule, !configChange);
|
|
256
333
|
// Pi returns undefined for both dismissal and timeout; elapsed time separates the timeout case.
|
|
257
334
|
timedOut = choice === undefined && Date.now() - promptStartedAt >= timeoutMs;
|
|
258
335
|
} finally {
|
|
@@ -277,7 +354,7 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
277
354
|
? timeoutReason(match.pattern, timeoutMs)
|
|
278
355
|
: configChange
|
|
279
356
|
? CONFIG_CHANGE_REASON
|
|
280
|
-
: `pi-gate: denied
|
|
357
|
+
: `pi-gate: command not run because approval was denied. Ask the user before retrying (rule "${reasonRuleName(match.pattern)}").`,
|
|
281
358
|
terminate: true,
|
|
282
359
|
};
|
|
283
360
|
}
|