@zeldrisho/pi-gate 0.0.0 → 0.1.1

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,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.1] - 2026-09-01
11
+
12
+ ### Changed
13
+
14
+ - identify the matched rule in confirmation prompts, block notifications, and agent-visible error results
15
+
10
16
  ## [0.1.0] - 2026-08-25
11
17
 
12
18
  ### Added
@@ -17,5 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
23
  - support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
18
24
  - block rather than auto-approve in non-UI modes
19
25
 
20
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.0...HEAD
26
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.1...HEAD
27
+ [0.1.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.0...pi-gate-v0.1.1
21
28
  [0.1.0]: https://github.com/zeldrisho/pi-packages/releases/tag/pi-gate-v0.1.0
package/README.md CHANGED
@@ -48,11 +48,26 @@ After editing, run `/reload` to apply the new rules in the current session.
48
48
  ## Behavior
49
49
 
50
50
  - 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.
51
+ - `prompt` rules ask the user with a two-button confirmation (`Allow` / `Deny`). The dialog identifies the matched rule.
52
+ - `block` rules never ask and always deny. The warning identifies the matched rule.
53
53
  - `allow` rules are explicit pass-throughs, useful for carving out exceptions.
54
54
  - In non-interactive modes (`-p`, JSON), `prompt` and `block` rules both block the command instead of auto-approving.
55
55
 
56
+ ### What the agent sees
57
+
58
+ The confirmation dialog and your choice are not sent directly to the agent.
59
+
60
+ - If you allow a `prompt` rule, the command runs and the agent receives its normal bash result.
61
+ - If you deny or dismiss a `prompt`, the agent receives an error result naming the matched rule and saying the user denied it.
62
+ - If a `block` rule matches, the agent receives an error result naming that rule.
63
+ - If a `prompt` rule matches without an available UI, the agent receives an error explaining that the matched rule required a prompt.
64
+
65
+ For example, a blocked `sudo apt update` call reports:
66
+
67
+ ```text
68
+ pi-gate: command blocked by rule "sudo": "block"
69
+ ```
70
+
56
71
  ## Uninstall
57
72
 
58
73
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-gate",
3
- "version": "0.0.0",
3
+ "version": "0.1.1",
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
@@ -36,6 +36,11 @@ export interface GateConfig {
36
36
  operations: Record<string, Action>;
37
37
  }
38
38
 
39
+ export interface RuleMatch {
40
+ pattern: string;
41
+ action: Action;
42
+ }
43
+
39
44
  const CONFIG_FILE_NAME = "pi-gate.json";
40
45
  const EXAMPLE_FILE_NAME = "pi-gate.json.example";
41
46
  const ACTIONS: readonly Action[] = ["prompt", "block", "allow"] as const;
@@ -48,18 +53,39 @@ const EXAMPLE_CONFIG: GateConfig = {
48
53
  },
49
54
  };
50
55
 
56
+ /**
57
+ * Returns the directory where pi-gate configuration files are stored.
58
+ *
59
+ * @returns The absolute path to the agent configuration directory
60
+ */
51
61
  function agentDir(): string {
52
62
  return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
53
63
  }
54
64
 
65
+ /**
66
+ * Returns the full path to the pi-gate configuration file.
67
+ *
68
+ * @returns The absolute path to pi-gate.json
69
+ */
55
70
  function configPath(): string {
56
71
  return join(agentDir(), CONFIG_FILE_NAME);
57
72
  }
58
73
 
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
+ */
59
79
  function examplePath(): string {
60
80
  return join(agentDir(), EXAMPLE_FILE_NAME);
61
81
  }
62
82
 
83
+ /**
84
+ * Type guard that checks if an unknown value is a valid Action.
85
+ *
86
+ * @param value - The value to check
87
+ * @returns `true` if the value is one of "prompt", "block", or "allow"
88
+ */
63
89
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- type-guard helper intentionally narrows untrusted JSON values at the parse boundary
64
90
  function isAction(value: unknown): value is Action {
65
91
  return (
@@ -71,6 +97,12 @@ function isAction(value: unknown): value is Action {
71
97
  );
72
98
  }
73
99
 
100
+ /**
101
+ * Type guard that checks if an unknown value is a plain JSON object.
102
+ *
103
+ * @param value - The value to check
104
+ * @returns `true` if the value is a non-null object and not an array
105
+ */
74
106
  // oxlint-disable-next-line anti-slop/no-unknown-parameters, anti-slop/no-unsafe-dictionary-type -- type-guard helper intentionally narrows untrusted JSON values at the parse boundary
75
107
  function isJsonObject(value: unknown): value is Record<string, unknown> {
76
108
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -147,13 +179,17 @@ export function ensureExampleConfig(): void {
147
179
  }
148
180
 
149
181
  /**
150
- * Resolves the action that applies to a command against the configured rules.
182
+ * Resolves the rule that applies to a command against the configured rules.
151
183
  *
152
184
  * Returns `null` when no rule matches, in which case the caller should let the
153
185
  * command through. When multiple rules match, the longest pattern wins so
154
186
  * that narrow rules can override broader ones.
187
+ *
188
+ * @param command - The bash command to evaluate against the rules
189
+ * @param rules - A map of pattern strings to actions
190
+ * @returns The matched pattern and action, or `null` if no rule matches
155
191
  */
156
- export function resolveAction(command: string, rules: Record<string, Action>): Action | null {
192
+ export function resolveRule(command: string, rules: Record<string, Action>): RuleMatch | null {
157
193
  let bestPattern: string | null = null;
158
194
  for (const pattern of Object.keys(rules)) {
159
195
  if (!command.includes(pattern)) continue;
@@ -162,7 +198,22 @@ export function resolveAction(command: string, rules: Record<string, Action>): A
162
198
  }
163
199
  }
164
200
  if (bestPattern === null) return null;
165
- return rules[bestPattern];
201
+ return { pattern: bestPattern, action: rules[bestPattern] };
202
+ }
203
+
204
+ /** Returns only the action selected by {@link resolveRule}. */
205
+ export function resolveAction(command: string, rules: Record<string, Action>): Action | null {
206
+ return resolveRule(command, rules)?.action ?? null;
207
+ }
208
+
209
+ /**
210
+ * Formats a rule match as a human-readable string for display in prompts and notifications.
211
+ *
212
+ * @param match - The rule match to format
213
+ * @returns A string representation showing the pattern and action
214
+ */
215
+ function formatRule(match: RuleMatch): string {
216
+ return `${JSON.stringify(match.pattern)}: ${JSON.stringify(match.action)}`;
166
217
  }
167
218
 
168
219
  /** Gate the built-in `bash` tool against the user-provided rules. */
@@ -197,26 +248,31 @@ export default function piGate(pi: ExtensionAPI): void {
197
248
  if (event.toolName !== "bash") return undefined;
198
249
  if (!isToolCallEventType("bash", event)) return undefined;
199
250
  const command = event.input.command;
200
- const action = resolveAction(command, config.operations);
201
- if (action === null || action === "allow") return undefined;
251
+ const match = resolveRule(command, config.operations);
252
+ if (match === null || match.action === "allow") return undefined;
253
+ const rule = formatRule(match);
202
254
 
203
- if (action === "block") {
255
+ if (match.action === "block") {
256
+ const reason = `pi-gate: command blocked by rule ${rule}`;
204
257
  if (ctx.hasUI) {
205
- ctx.ui.notify(`pi-gate: blocked command matching a rule`, "warning");
258
+ ctx.ui.notify(reason, "warning");
206
259
  }
207
- return { block: true, reason: `pi-gate: command matches a "block" rule` };
260
+ return { block: true, reason };
208
261
  }
209
262
 
210
263
  // action === "prompt"
211
264
  if (!ctx.hasUI) {
212
- return { block: true, reason: "pi-gate: prompt required but no UI is available" };
265
+ return {
266
+ block: true,
267
+ reason: `pi-gate: command blocked because rule ${rule} requires a prompt, but no UI is available`,
268
+ };
213
269
  }
214
270
  const choice = await ctx.ui.select(
215
- `pi-gate: allow this command?\n\n ${command}\n\nA rule in pi-gate.json requests confirmation.`,
271
+ `pi-gate: allow this command?\n\n ${command}\n\nMatched rule: ${rule}`,
216
272
  ["Allow", "Deny"],
217
273
  );
218
274
  if (choice !== "Allow") {
219
- return { block: true, reason: "pi-gate: denied by user" };
275
+ return { block: true, reason: `pi-gate: command denied by user after matching rule ${rule}` };
220
276
  }
221
277
  return undefined;
222
278
  });