@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 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.1.0...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
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
- 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`).
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
- - 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-gate",
3
- "version": "0.1.0",
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,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 EXAMPLE_CONFIG: GateConfig = {
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 example configuration next to the real one. Existing example
157
- * files are left alone so the user can rely on them to track the latest
158
- * supported shape.
167
+ * Writes the default configuration on first run. Existing configuration files
168
+ * are never overwritten.
159
169
  */
160
- export function ensureExampleConfig(): void {
161
- const path = examplePath();
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 example = JSON.stringify(EXAMPLE_CONFIG, null, 2) + "\n";
178
+ const content = JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n";
169
179
  try {
170
- writeFileSync(path, example, "utf-8");
180
+ writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
171
181
  } catch {
172
- // 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.
173
184
  }
174
185
  }
175
186
 
176
187
  /**
177
- * Resolves the action that applies to a command against the configured rules.
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 action to take, or `null` if no rule matches
196
+ * @returns The matched pattern and action, or `null` if no rule matches
186
197
  */
187
- export function resolveAction(command: string, rules: Record<string, Action>): Action | null {
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
- ensureExampleConfig();
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: 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.`,
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 action = resolveAction(command, config.operations);
232
- if (action === null || action === "allow") return undefined;
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(`pi-gate: blocked command matching a rule`, "warning");
264
+ ctx.ui.notify(reason, "warning");
237
265
  }
238
- return { block: true, reason: `pi-gate: command matches a "block" rule` };
266
+ return { block: true, reason, terminate: true };
239
267
  }
240
268
 
241
269
  // action === "prompt"
242
270
  if (!ctx.hasUI) {
243
- return { block: true, reason: "pi-gate: prompt required but no UI is available" };
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\nA rule in pi-gate.json requests confirmation.`,
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 { block: true, reason: "pi-gate: denied by user" };
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
  });