@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 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.1.1...HEAD
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
- The extension ships with **no default rules**. On first load, an example configuration is written to `~/.pi/agent/pi-gate.json.example` and a warning notifies you that no configuration is active. Every bash command is allowed until you create a configuration.
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
- Copy the example and edit it:
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 (use to carve out an exception to a broader rule)
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. When several patterns match the same command, the longest pattern wins, so a narrow `allow` rule can override a broader `block` or `prompt` rule.
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
- - In non-interactive modes (`-p`, JSON), `prompt` and `block` rules both block the command instead of auto-approving.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-gate",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension that blocks or confirms bash commands based on a user-provided JSON configuration",
5
5
  "keywords": [
6
6
  "command-blocking",
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, every
6
- * command is allowed and the extension posts a warning on `session_start`
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 a single section, `operations`, which maps a
12
- * substring pattern to one of three actions:
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, an example configuration is written next to the real one
22
- * at `~/.pi/agent/pi-gate.json.example` to document the format.
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 command.
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 EXAMPLE_CONFIG: GateConfig = {
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 example configuration next to the real one. Existing example
162
- * files are left alone so the user can rely on them to track the latest
163
- * supported shape.
167
+ * Writes the default configuration on first run. Existing configuration files
168
+ * are never overwritten.
164
169
  */
165
- export function ensureExampleConfig(): void {
166
- const path = examplePath();
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 example = JSON.stringify(EXAMPLE_CONFIG, null, 2) + "\n";
178
+ const content = JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n";
174
179
  try {
175
- writeFileSync(path, example, "utf-8");
180
+ writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
176
181
  } catch {
177
- // Best-effort: the warning banner still surfaces the missing config.
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
- ensureExampleConfig();
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: no configuration found at ${path}; all commands are allowed. Copy ${examplePath()} to ${path} to start gating.`,
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 { block: true, reason: `pi-gate: command denied by user after matching rule ${rule}` };
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
  });