@zeldrisho/pi-gate 0.1.1 → 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 +10 -1
- package/README.md +9 -12
- package/package.json +1 -1
- package/src/index.ts +49 -37
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@ 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
|
+
|
|
10
18
|
## [0.1.1] - 2026-09-01
|
|
11
19
|
|
|
12
20
|
### Changed
|
|
@@ -23,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
23
31
|
- support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
|
|
24
32
|
- block rather than auto-approve in non-UI modes
|
|
25
33
|
|
|
26
|
-
[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
|
|
27
36
|
[0.1.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.0...pi-gate-v0.1.1
|
|
28
37
|
[0.1.0]: https://github.com/zeldrisho/pi-packages/releases/tag/pi-gate-v0.1.0
|
package/README.md
CHANGED
|
@@ -16,42 +16,39 @@ 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`). The dialog identifies the matched rule.
|
|
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.
|
|
52
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.
|
|
55
52
|
|
|
56
53
|
### What the agent sees
|
|
57
54
|
|
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,6 +35,7 @@ export type Action = "prompt" | "block" | "allow";
|
|
|
34
35
|
|
|
35
36
|
export interface GateConfig {
|
|
36
37
|
operations: Record<string, Action>;
|
|
38
|
+
promptTimeoutMs: number;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export interface RuleMatch {
|
|
@@ -42,14 +44,18 @@ export interface RuleMatch {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
const CONFIG_FILE_NAME = "pi-gate.json";
|
|
45
|
-
const EXAMPLE_FILE_NAME = "pi-gate.json.example";
|
|
46
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;
|
|
47
50
|
|
|
48
|
-
const
|
|
51
|
+
const DEFAULT_CONFIG: GateConfig = {
|
|
52
|
+
promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS,
|
|
49
53
|
operations: {
|
|
50
54
|
"rm -rf": "prompt",
|
|
51
55
|
sudo: "prompt",
|
|
56
|
+
"sudo apt update": "allow",
|
|
52
57
|
"chmod 777": "block",
|
|
58
|
+
"corepack enable": "block",
|
|
53
59
|
},
|
|
54
60
|
};
|
|
55
61
|
|
|
@@ -71,15 +77,6 @@ function configPath(): string {
|
|
|
71
77
|
return join(agentDir(), CONFIG_FILE_NAME);
|
|
72
78
|
}
|
|
73
79
|
|
|
74
|
-
/**
|
|
75
|
-
* Returns the full path to the example pi-gate configuration file.
|
|
76
|
-
*
|
|
77
|
-
* @returns The absolute path to pi-gate.json.example
|
|
78
|
-
*/
|
|
79
|
-
function examplePath(): string {
|
|
80
|
-
return join(agentDir(), EXAMPLE_FILE_NAME);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
80
|
/**
|
|
84
81
|
* Type guard that checks if an unknown value is a valid Action.
|
|
85
82
|
*
|
|
@@ -122,21 +119,30 @@ export function parseConfig(content: string): GateConfig {
|
|
|
122
119
|
try {
|
|
123
120
|
parsed = JSON.parse(content);
|
|
124
121
|
} catch {
|
|
125
|
-
return { operations: rules };
|
|
122
|
+
return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
126
123
|
}
|
|
127
124
|
if (!isJsonObject(parsed)) {
|
|
128
|
-
return { operations: rules };
|
|
125
|
+
return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
129
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;
|
|
130
136
|
const operations = parsed["operations"];
|
|
131
137
|
if (!isJsonObject(operations)) {
|
|
132
|
-
return { operations: rules };
|
|
138
|
+
return { operations: rules, promptTimeoutMs };
|
|
133
139
|
}
|
|
134
140
|
for (const [pattern, action] of Object.entries(operations)) {
|
|
135
141
|
if (isAction(action)) {
|
|
136
142
|
rules[pattern] = action;
|
|
137
143
|
}
|
|
138
144
|
}
|
|
139
|
-
return { operations: rules };
|
|
145
|
+
return { operations: rules, promptTimeoutMs };
|
|
140
146
|
}
|
|
141
147
|
|
|
142
148
|
/**
|
|
@@ -146,35 +152,35 @@ export function parseConfig(content: string): GateConfig {
|
|
|
146
152
|
export function loadConfig(): GateConfig {
|
|
147
153
|
const path = configPath();
|
|
148
154
|
if (!existsSync(path)) {
|
|
149
|
-
return { operations: {} };
|
|
155
|
+
return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
150
156
|
}
|
|
151
157
|
let content: string;
|
|
152
158
|
try {
|
|
153
159
|
content = readFileSync(path, "utf-8");
|
|
154
160
|
} catch {
|
|
155
|
-
return { operations: {} };
|
|
161
|
+
return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
156
162
|
}
|
|
157
163
|
return parseConfig(content);
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
/**
|
|
161
|
-
* Writes the
|
|
162
|
-
*
|
|
163
|
-
* supported shape.
|
|
167
|
+
* Writes the default configuration on first run. Existing configuration files
|
|
168
|
+
* are never overwritten.
|
|
164
169
|
*/
|
|
165
|
-
export function
|
|
166
|
-
const path =
|
|
170
|
+
export function ensureConfig(): void {
|
|
171
|
+
const path = configPath();
|
|
167
172
|
if (existsSync(path)) return;
|
|
168
173
|
try {
|
|
169
174
|
mkdirSync(agentDir(), { recursive: true });
|
|
170
175
|
} catch {
|
|
171
176
|
return;
|
|
172
177
|
}
|
|
173
|
-
const
|
|
178
|
+
const content = JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n";
|
|
174
179
|
try {
|
|
175
|
-
writeFileSync(path,
|
|
180
|
+
writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
|
|
176
181
|
} catch {
|
|
177
|
-
// Best-effort:
|
|
182
|
+
// Best-effort: another process may have created it, or the warning banner
|
|
183
|
+
// will surface that no configuration is active.
|
|
178
184
|
}
|
|
179
185
|
}
|
|
180
186
|
|
|
@@ -218,7 +224,7 @@ function formatRule(match: RuleMatch): string {
|
|
|
218
224
|
|
|
219
225
|
/** Gate the built-in `bash` tool against the user-provided rules. */
|
|
220
226
|
export default function piGate(pi: ExtensionAPI): void {
|
|
221
|
-
|
|
227
|
+
ensureConfig();
|
|
222
228
|
const config = loadConfig();
|
|
223
229
|
|
|
224
230
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -238,7 +244,7 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
238
244
|
}
|
|
239
245
|
} else {
|
|
240
246
|
ctx.ui.notify(
|
|
241
|
-
`pi-gate:
|
|
247
|
+
`pi-gate: could not create configuration at ${path}; all commands are allowed until that file is created.`,
|
|
242
248
|
"warning",
|
|
243
249
|
);
|
|
244
250
|
}
|
|
@@ -257,7 +263,7 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
257
263
|
if (ctx.hasUI) {
|
|
258
264
|
ctx.ui.notify(reason, "warning");
|
|
259
265
|
}
|
|
260
|
-
return { block: true, reason };
|
|
266
|
+
return { block: true, reason, terminate: true };
|
|
261
267
|
}
|
|
262
268
|
|
|
263
269
|
// action === "prompt"
|
|
@@ -265,14 +271,20 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
265
271
|
return {
|
|
266
272
|
block: true,
|
|
267
273
|
reason: `pi-gate: command blocked because rule ${rule} requires a prompt, but no UI is available`,
|
|
274
|
+
terminate: true,
|
|
268
275
|
};
|
|
269
276
|
}
|
|
270
277
|
const choice = await ctx.ui.select(
|
|
271
278
|
`pi-gate: allow this command?\n\n ${command}\n\nMatched rule: ${rule}`,
|
|
272
279
|
["Allow", "Deny"],
|
|
280
|
+
{ timeout: config.promptTimeoutMs },
|
|
273
281
|
);
|
|
274
282
|
if (choice !== "Allow") {
|
|
275
|
-
return {
|
|
283
|
+
return {
|
|
284
|
+
block: true,
|
|
285
|
+
reason: `pi-gate: command denied, dismissed, or timed out after matching rule ${rule}`,
|
|
286
|
+
terminate: true,
|
|
287
|
+
};
|
|
276
288
|
}
|
|
277
289
|
return undefined;
|
|
278
290
|
});
|