@zeldrisho/pi-gate 0.1.0 → 0.2.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/CHANGELOG.md +17 -1
- package/README.md +25 -13
- package/package.json +1 -1
- package/src/index.ts +84 -47
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-09-02
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- request termination of the current agent turn when a command is blocked, denied, dismissed, or times out; mixed parallel batches containing allowed calls may continue
|
|
15
|
+
- auto-deny interactive prompts after a configurable `promptTimeoutMs` (30 seconds by default)
|
|
16
|
+
- create `pi-gate.json` directly on first load with the default timeout and starter prompt/block/allow rules instead of writing an example file
|
|
17
|
+
|
|
18
|
+
## [0.1.1] - 2026-09-01
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- identify the matched rule in confirmation prompts, block notifications, and agent-visible error results
|
|
23
|
+
|
|
10
24
|
## [0.1.0] - 2026-08-25
|
|
11
25
|
|
|
12
26
|
### Added
|
|
@@ -17,5 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
17
31
|
- support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
|
|
18
32
|
- block rather than auto-approve in non-UI modes
|
|
19
33
|
|
|
20
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.
|
|
34
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.2.0...HEAD
|
|
35
|
+
[0.2.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.1...pi-gate-v0.2.0
|
|
36
|
+
[0.1.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.0...pi-gate-v0.1.1
|
|
21
37
|
[0.1.0]: https://github.com/zeldrisho/pi-packages/releases/tag/pi-gate-v0.1.0
|
package/README.md
CHANGED
|
@@ -16,42 +16,54 @@ pi install -l npm:@zeldrisho/pi-gate
|
|
|
16
16
|
|
|
17
17
|
## Configure
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
On first load, the extension creates `~/.pi/agent/pi-gate.json` with a default 30-second prompt timeout and starter rules. Existing configuration files are never overwritten.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
cp ~/.pi/agent/pi-gate.json.example ~/.pi/agent/pi-gate.json
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
Then edit `~/.pi/agent/pi-gate.json` to add your rules. Each rule is a substring pattern and one of three actions:
|
|
21
|
+
Edit `~/.pi/agent/pi-gate.json` to customize the rules. `promptTimeoutMs` controls how long a confirmation remains open (30 seconds by default if omitted, with a maximum of one day). Each rule is a substring pattern and one of three actions:
|
|
28
22
|
|
|
29
23
|
- `prompt` — ask the user to allow or deny the command
|
|
30
24
|
- `block` — deny the command without asking
|
|
31
|
-
- `allow` — explicitly allow
|
|
25
|
+
- `allow` — explicitly allow an exception to a broader matching rule
|
|
32
26
|
|
|
33
27
|
```json
|
|
34
28
|
{
|
|
29
|
+
"promptTimeoutMs": 30000,
|
|
35
30
|
"operations": {
|
|
36
31
|
"rm -rf": "prompt",
|
|
37
32
|
"sudo": "prompt",
|
|
33
|
+
"sudo apt update": "allow",
|
|
38
34
|
"chmod 777": "block",
|
|
39
35
|
"corepack enable": "block"
|
|
40
36
|
}
|
|
41
37
|
}
|
|
42
38
|
```
|
|
43
39
|
|
|
44
|
-
Patterns use simple substring matching.
|
|
40
|
+
Patterns use simple substring matching. Commands with no matching rule are allowed automatically. The explicit `allow` action is only needed to carve out an exception: in the starter configuration, `sudo apt update` is allowed even though the broader `sudo` rule prompts. When several patterns match, the longest pattern wins.
|
|
45
41
|
|
|
46
42
|
After editing, run `/reload` to apply the new rules in the current session.
|
|
47
43
|
|
|
48
44
|
## Behavior
|
|
49
45
|
|
|
50
46
|
- Only the built-in `bash` tool is gated. Other tools pass through unchanged.
|
|
51
|
-
- `prompt` rules ask the user with a two-button confirmation (`Allow` / `Deny`).
|
|
52
|
-
- `block` rules never ask and always deny.
|
|
47
|
+
- `prompt` rules ask the user with a two-button confirmation (`Allow` / `Deny`). The dialog identifies the matched rule and auto-denies after `promptTimeoutMs` instead of waiting indefinitely.
|
|
48
|
+
- `block` rules never ask and always deny. The warning identifies the matched rule.
|
|
53
49
|
- `allow` rules are explicit pass-throughs, useful for carving out exceptions.
|
|
54
|
-
-
|
|
50
|
+
- Blocked, denied, dismissed, and timed-out calls request early termination. Pi ends the turn only when every finalized result in the tool-call batch requests termination; a mixed parallel batch containing allowed calls may continue.
|
|
51
|
+
- In non-interactive modes (`-p`, JSON), `prompt` and `block` rules both block and request termination instead of auto-approving.
|
|
52
|
+
|
|
53
|
+
### What the agent sees
|
|
54
|
+
|
|
55
|
+
The confirmation dialog and your choice are not sent directly to the agent.
|
|
56
|
+
|
|
57
|
+
- If you allow a `prompt` rule, the command runs and the agent receives its normal bash result.
|
|
58
|
+
- If you deny or dismiss a `prompt`, the agent receives an error result naming the matched rule and saying the user denied it.
|
|
59
|
+
- If a `block` rule matches, the agent receives an error result naming that rule.
|
|
60
|
+
- If a `prompt` rule matches without an available UI, the agent receives an error explaining that the matched rule required a prompt.
|
|
61
|
+
|
|
62
|
+
For example, a blocked `sudo apt update` call reports:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
pi-gate: command blocked by rule "sudo": "block"
|
|
66
|
+
```
|
|
55
67
|
|
|
56
68
|
## Uninstall
|
|
57
69
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
* pi-gate Extension
|
|
3
3
|
*
|
|
4
4
|
* Intercepts the built-in `bash` tool and gates each command against a
|
|
5
|
-
* user-provided JSON configuration. Without a configuration file,
|
|
6
|
-
*
|
|
7
|
-
* reminding the user to create one.
|
|
5
|
+
* user-provided JSON configuration. Without a configuration file, the
|
|
6
|
+
* extension creates one with starter rules and a default prompt timeout.
|
|
8
7
|
*
|
|
9
8
|
* Configuration file: `~/.pi/agent/pi-gate.json`
|
|
10
9
|
*
|
|
11
|
-
* The configuration has
|
|
12
|
-
*
|
|
10
|
+
* The configuration has an `operations` section, which maps a substring
|
|
11
|
+
* pattern to one of three actions, and an optional `promptTimeoutMs` setting:
|
|
13
12
|
*
|
|
14
13
|
* - `prompt` - ask the user to allow or deny the command
|
|
15
14
|
* - `block` - deny the command without asking
|
|
@@ -18,11 +17,13 @@
|
|
|
18
17
|
* When multiple patterns match a command, the longest pattern wins, so a
|
|
19
18
|
* narrow `allow` rule can override a broader `prompt` or `block` rule.
|
|
20
19
|
*
|
|
21
|
-
* On first run,
|
|
22
|
-
*
|
|
20
|
+
* On first run, a configuration with the default prompt timeout and starter
|
|
21
|
+
* operation rules is written to `~/.pi/agent/pi-gate.json`.
|
|
23
22
|
*
|
|
24
23
|
* Non-UI modes (print, JSON) never auto-approve. A `prompt` or `block` rule
|
|
25
|
-
* in non-UI mode always blocks the
|
|
24
|
+
* in non-UI mode always blocks and requests termination of the agent turn.
|
|
25
|
+
* Interactive prompts auto-deny after `promptTimeoutMs` rather than waiting
|
|
26
|
+
* indefinitely.
|
|
26
27
|
*/
|
|
27
28
|
|
|
28
29
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -34,17 +35,27 @@ export type Action = "prompt" | "block" | "allow";
|
|
|
34
35
|
|
|
35
36
|
export interface GateConfig {
|
|
36
37
|
operations: Record<string, Action>;
|
|
38
|
+
promptTimeoutMs: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RuleMatch {
|
|
42
|
+
pattern: string;
|
|
43
|
+
action: Action;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
const CONFIG_FILE_NAME = "pi-gate.json";
|
|
40
|
-
const EXAMPLE_FILE_NAME = "pi-gate.json.example";
|
|
41
47
|
const ACTIONS: readonly Action[] = ["prompt", "block", "allow"] as const;
|
|
48
|
+
export const DEFAULT_PROMPT_TIMEOUT_MS = 30_000;
|
|
49
|
+
const MAX_PROMPT_TIMEOUT_MS = 86_400_000;
|
|
42
50
|
|
|
43
|
-
const
|
|
51
|
+
const DEFAULT_CONFIG: GateConfig = {
|
|
52
|
+
promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS,
|
|
44
53
|
operations: {
|
|
45
54
|
"rm -rf": "prompt",
|
|
46
55
|
sudo: "prompt",
|
|
56
|
+
"sudo apt update": "allow",
|
|
47
57
|
"chmod 777": "block",
|
|
58
|
+
"corepack enable": "block",
|
|
48
59
|
},
|
|
49
60
|
};
|
|
50
61
|
|
|
@@ -66,15 +77,6 @@ function configPath(): string {
|
|
|
66
77
|
return join(agentDir(), CONFIG_FILE_NAME);
|
|
67
78
|
}
|
|
68
79
|
|
|
69
|
-
/**
|
|
70
|
-
* Returns the full path to the example pi-gate configuration file.
|
|
71
|
-
*
|
|
72
|
-
* @returns The absolute path to pi-gate.json.example
|
|
73
|
-
*/
|
|
74
|
-
function examplePath(): string {
|
|
75
|
-
return join(agentDir(), EXAMPLE_FILE_NAME);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
80
|
/**
|
|
79
81
|
* Type guard that checks if an unknown value is a valid Action.
|
|
80
82
|
*
|
|
@@ -117,21 +119,30 @@ export function parseConfig(content: string): GateConfig {
|
|
|
117
119
|
try {
|
|
118
120
|
parsed = JSON.parse(content);
|
|
119
121
|
} catch {
|
|
120
|
-
return { operations: rules };
|
|
122
|
+
return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
121
123
|
}
|
|
122
124
|
if (!isJsonObject(parsed)) {
|
|
123
|
-
return { operations: rules };
|
|
125
|
+
return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
124
126
|
}
|
|
127
|
+
const configuredTimeout = parsed["promptTimeoutMs"];
|
|
128
|
+
const promptTimeoutMs =
|
|
129
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validate the optional numeric field at the untrusted JSON boundary before applying range checks
|
|
130
|
+
typeof configuredTimeout === "number" &&
|
|
131
|
+
Number.isInteger(configuredTimeout) &&
|
|
132
|
+
configuredTimeout > 0 &&
|
|
133
|
+
configuredTimeout <= MAX_PROMPT_TIMEOUT_MS
|
|
134
|
+
? configuredTimeout
|
|
135
|
+
: DEFAULT_PROMPT_TIMEOUT_MS;
|
|
125
136
|
const operations = parsed["operations"];
|
|
126
137
|
if (!isJsonObject(operations)) {
|
|
127
|
-
return { operations: rules };
|
|
138
|
+
return { operations: rules, promptTimeoutMs };
|
|
128
139
|
}
|
|
129
140
|
for (const [pattern, action] of Object.entries(operations)) {
|
|
130
141
|
if (isAction(action)) {
|
|
131
142
|
rules[pattern] = action;
|
|
132
143
|
}
|
|
133
144
|
}
|
|
134
|
-
return { operations: rules };
|
|
145
|
+
return { operations: rules, promptTimeoutMs };
|
|
135
146
|
}
|
|
136
147
|
|
|
137
148
|
/**
|
|
@@ -141,40 +152,40 @@ export function parseConfig(content: string): GateConfig {
|
|
|
141
152
|
export function loadConfig(): GateConfig {
|
|
142
153
|
const path = configPath();
|
|
143
154
|
if (!existsSync(path)) {
|
|
144
|
-
return { operations: {} };
|
|
155
|
+
return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
145
156
|
}
|
|
146
157
|
let content: string;
|
|
147
158
|
try {
|
|
148
159
|
content = readFileSync(path, "utf-8");
|
|
149
160
|
} catch {
|
|
150
|
-
return { operations: {} };
|
|
161
|
+
return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
151
162
|
}
|
|
152
163
|
return parseConfig(content);
|
|
153
164
|
}
|
|
154
165
|
|
|
155
166
|
/**
|
|
156
|
-
* Writes the
|
|
157
|
-
*
|
|
158
|
-
* supported shape.
|
|
167
|
+
* Writes the default configuration on first run. Existing configuration files
|
|
168
|
+
* are never overwritten.
|
|
159
169
|
*/
|
|
160
|
-
export function
|
|
161
|
-
const path =
|
|
170
|
+
export function ensureConfig(): void {
|
|
171
|
+
const path = configPath();
|
|
162
172
|
if (existsSync(path)) return;
|
|
163
173
|
try {
|
|
164
174
|
mkdirSync(agentDir(), { recursive: true });
|
|
165
175
|
} catch {
|
|
166
176
|
return;
|
|
167
177
|
}
|
|
168
|
-
const
|
|
178
|
+
const content = JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n";
|
|
169
179
|
try {
|
|
170
|
-
writeFileSync(path,
|
|
180
|
+
writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
|
|
171
181
|
} catch {
|
|
172
|
-
// Best-effort:
|
|
182
|
+
// Best-effort: another process may have created it, or the warning banner
|
|
183
|
+
// will surface that no configuration is active.
|
|
173
184
|
}
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
/**
|
|
177
|
-
* Resolves the
|
|
188
|
+
* Resolves the rule that applies to a command against the configured rules.
|
|
178
189
|
*
|
|
179
190
|
* Returns `null` when no rule matches, in which case the caller should let the
|
|
180
191
|
* command through. When multiple rules match, the longest pattern wins so
|
|
@@ -182,9 +193,9 @@ export function ensureExampleConfig(): void {
|
|
|
182
193
|
*
|
|
183
194
|
* @param command - The bash command to evaluate against the rules
|
|
184
195
|
* @param rules - A map of pattern strings to actions
|
|
185
|
-
* @returns The
|
|
196
|
+
* @returns The matched pattern and action, or `null` if no rule matches
|
|
186
197
|
*/
|
|
187
|
-
export function
|
|
198
|
+
export function resolveRule(command: string, rules: Record<string, Action>): RuleMatch | null {
|
|
188
199
|
let bestPattern: string | null = null;
|
|
189
200
|
for (const pattern of Object.keys(rules)) {
|
|
190
201
|
if (!command.includes(pattern)) continue;
|
|
@@ -193,12 +204,27 @@ export function resolveAction(command: string, rules: Record<string, Action>): A
|
|
|
193
204
|
}
|
|
194
205
|
}
|
|
195
206
|
if (bestPattern === null) return null;
|
|
196
|
-
return rules[bestPattern];
|
|
207
|
+
return { pattern: bestPattern, action: rules[bestPattern] };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Returns only the action selected by {@link resolveRule}. */
|
|
211
|
+
export function resolveAction(command: string, rules: Record<string, Action>): Action | null {
|
|
212
|
+
return resolveRule(command, rules)?.action ?? null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Formats a rule match as a human-readable string for display in prompts and notifications.
|
|
217
|
+
*
|
|
218
|
+
* @param match - The rule match to format
|
|
219
|
+
* @returns A string representation showing the pattern and action
|
|
220
|
+
*/
|
|
221
|
+
function formatRule(match: RuleMatch): string {
|
|
222
|
+
return `${JSON.stringify(match.pattern)}: ${JSON.stringify(match.action)}`;
|
|
197
223
|
}
|
|
198
224
|
|
|
199
225
|
/** Gate the built-in `bash` tool against the user-provided rules. */
|
|
200
226
|
export default function piGate(pi: ExtensionAPI): void {
|
|
201
|
-
|
|
227
|
+
ensureConfig();
|
|
202
228
|
const config = loadConfig();
|
|
203
229
|
|
|
204
230
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -218,7 +244,7 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
218
244
|
}
|
|
219
245
|
} else {
|
|
220
246
|
ctx.ui.notify(
|
|
221
|
-
`pi-gate:
|
|
247
|
+
`pi-gate: could not create configuration at ${path}; all commands are allowed until that file is created.`,
|
|
222
248
|
"warning",
|
|
223
249
|
);
|
|
224
250
|
}
|
|
@@ -228,26 +254,37 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
228
254
|
if (event.toolName !== "bash") return undefined;
|
|
229
255
|
if (!isToolCallEventType("bash", event)) return undefined;
|
|
230
256
|
const command = event.input.command;
|
|
231
|
-
const
|
|
232
|
-
if (
|
|
257
|
+
const match = resolveRule(command, config.operations);
|
|
258
|
+
if (match === null || match.action === "allow") return undefined;
|
|
259
|
+
const rule = formatRule(match);
|
|
233
260
|
|
|
234
|
-
if (action === "block") {
|
|
261
|
+
if (match.action === "block") {
|
|
262
|
+
const reason = `pi-gate: command blocked by rule ${rule}`;
|
|
235
263
|
if (ctx.hasUI) {
|
|
236
|
-
ctx.ui.notify(
|
|
264
|
+
ctx.ui.notify(reason, "warning");
|
|
237
265
|
}
|
|
238
|
-
return { block: true, reason
|
|
266
|
+
return { block: true, reason, terminate: true };
|
|
239
267
|
}
|
|
240
268
|
|
|
241
269
|
// action === "prompt"
|
|
242
270
|
if (!ctx.hasUI) {
|
|
243
|
-
return {
|
|
271
|
+
return {
|
|
272
|
+
block: true,
|
|
273
|
+
reason: `pi-gate: command blocked because rule ${rule} requires a prompt, but no UI is available`,
|
|
274
|
+
terminate: true,
|
|
275
|
+
};
|
|
244
276
|
}
|
|
245
277
|
const choice = await ctx.ui.select(
|
|
246
|
-
`pi-gate: allow this command?\n\n ${command}\n\
|
|
278
|
+
`pi-gate: allow this command?\n\n ${command}\n\nMatched rule: ${rule}`,
|
|
247
279
|
["Allow", "Deny"],
|
|
280
|
+
{ timeout: config.promptTimeoutMs },
|
|
248
281
|
);
|
|
249
282
|
if (choice !== "Allow") {
|
|
250
|
-
return {
|
|
283
|
+
return {
|
|
284
|
+
block: true,
|
|
285
|
+
reason: `pi-gate: command denied, dismissed, or timed out after matching rule ${rule}`,
|
|
286
|
+
terminate: true,
|
|
287
|
+
};
|
|
251
288
|
}
|
|
252
289
|
return undefined;
|
|
253
290
|
});
|