@zeldrisho/pi-gate 0.2.0 → 0.3.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,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-09-07
11
+
12
+ ### Added
13
+
14
+ - ship a JSON Schema for `pi-gate.json` and reference it from newly seeded configurations for editor completion and validation
15
+
16
+ ### Changed
17
+
18
+ - wrap visible matched-rule occurrences in confirmation-dialog command text so guarded operations are easier to identify
19
+ - escape terminal and bidirectional control characters and bound long or multiline command text in confirmation dialogs without changing the command executed
20
+ - ignore empty or oversized rule patterns and load at most 1,000 valid rules
21
+ - document and test RPC-host confirmation behavior
22
+
10
23
  ## [0.2.0] - 2026-09-02
11
24
 
12
25
  ### Changed
@@ -31,7 +44,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
31
44
  - support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
32
45
  - block rather than auto-approve in non-UI modes
33
46
 
34
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.2.0...HEAD
47
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.3.0...HEAD
48
+ [0.3.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.2.0...pi-gate-v0.3.0
35
49
  [0.2.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.1...pi-gate-v0.2.0
36
50
  [0.1.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.0...pi-gate-v0.1.1
37
51
  [0.1.0]: https://github.com/zeldrisho/pi-packages/releases/tag/pi-gate-v0.1.0
package/README.md CHANGED
@@ -16,7 +16,9 @@ pi install -l npm:@zeldrisho/pi-gate
16
16
 
17
17
  ## Configure
18
18
 
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.
19
+ On first load, the extension creates `~/.pi/agent/pi-gate.json` with a default 30-second prompt timeout, starter rules, and a `$schema` reference for editor validation. Existing configuration files are never overwritten.
20
+
21
+ The published package includes [`config.schema.json`](config.schema.json). New configurations reference its canonical URL automatically; existing configurations can add the same `$schema` property shown below to enable completion and validation in compatible editors.
20
22
 
21
23
  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:
22
24
 
@@ -26,6 +28,7 @@ Edit `~/.pi/agent/pi-gate.json` to customize the rules. `promptTimeoutMs` contro
26
28
 
27
29
  ```json
28
30
  {
31
+ "$schema": "https://raw.githubusercontent.com/zeldrisho/pi-packages/main/packages/pi-gate/config.schema.json",
29
32
  "promptTimeoutMs": 30000,
30
33
  "operations": {
31
34
  "rm -rf": "prompt",
@@ -37,18 +40,18 @@ Edit `~/.pi/agent/pi-gate.json` to customize the rules. `promptTimeoutMs` contro
37
40
  }
38
41
  ```
39
42
 
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.
43
+ Patterns use simple substring matching. Empty patterns and patterns longer than 1,024 characters are ignored, and at most 1,000 valid rules are loaded. 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.
41
44
 
42
45
  After editing, run `/reload` to apply the new rules in the current session.
43
46
 
44
47
  ## Behavior
45
48
 
46
49
  - Only the built-in `bash` tool is gated. Other tools pass through unchanged.
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.
50
+ - `prompt` rules ask the user with a two-button confirmation (`Allow` / `Deny`). The dialog identifies the matched rule, wraps its visible occurrences in `»…«`, and auto-denies after `promptTimeoutMs` instead of waiting indefinitely. Commands are normalized and terminal control characters are escaped for display; prompts show at most 2,000 command characters and 20 lines, but an allowed command always executes in full and unchanged.
48
51
  - `block` rules never ask and always deny. The warning identifies the matched rule.
49
52
  - `allow` rules are explicit pass-throughs, useful for carving out exceptions.
50
53
  - 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.
54
+ - RPC hosts use their native selection dialog. In non-interactive modes (`-p`, JSON), `prompt` and `block` rules both block and request termination instead of auto-approving.
52
55
 
53
56
  ### What the agent sees
54
57
 
@@ -0,0 +1,36 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/zeldrisho/pi-packages/main/packages/pi-gate/config.schema.json",
4
+ "title": "pi-gate configuration",
5
+ "description": "Rules used by @zeldrisho/pi-gate to allow, block, or confirm bash commands.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["operations"],
9
+ "properties": {
10
+ "$schema": {
11
+ "type": "string",
12
+ "format": "uri-reference",
13
+ "description": "Optional JSON Schema reference for editor validation."
14
+ },
15
+ "promptTimeoutMs": {
16
+ "type": "integer",
17
+ "minimum": 1,
18
+ "maximum": 86400000,
19
+ "default": 30000,
20
+ "description": "Maximum time to wait for an interactive confirmation, in milliseconds."
21
+ },
22
+ "operations": {
23
+ "type": "object",
24
+ "description": "Command substring patterns mapped to actions. When multiple patterns match, the longest wins.",
25
+ "maxProperties": 1000,
26
+ "propertyNames": {
27
+ "minLength": 1,
28
+ "maxLength": 1024
29
+ },
30
+ "additionalProperties": {
31
+ "type": "string",
32
+ "enum": ["prompt", "block", "allow"]
33
+ }
34
+ }
35
+ }
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-gate",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -24,6 +24,7 @@
24
24
  },
25
25
  "files": [
26
26
  "src",
27
+ "config.schema.json",
27
28
  "README.md",
28
29
  "CHANGELOG.md",
29
30
  "LICENSE"
package/src/index.ts CHANGED
@@ -44,9 +44,19 @@ export interface RuleMatch {
44
44
  }
45
45
 
46
46
  const CONFIG_FILE_NAME = "pi-gate.json";
47
+ export const CONFIG_SCHEMA_URL =
48
+ "https://raw.githubusercontent.com/zeldrisho/pi-packages/main/packages/pi-gate/config.schema.json";
47
49
  const ACTIONS: readonly Action[] = ["prompt", "block", "allow"] as const;
48
50
  export const DEFAULT_PROMPT_TIMEOUT_MS = 30_000;
49
- const MAX_PROMPT_TIMEOUT_MS = 86_400_000;
51
+ export const MAX_RULE_COUNT = 1_000;
52
+ export const MAX_RULE_PATTERN_LENGTH = 1_024;
53
+ export const MAX_DISPLAY_COMMAND_CHARACTERS = 2_000;
54
+ export const MAX_DISPLAY_COMMAND_LINES = 20;
55
+ export const MAX_PROMPT_TIMEOUT_MS = 86_400_000;
56
+ const DISPLAY_TRUNCATION_MARKER = "\n … [command display truncated]";
57
+ const BIDI_CONTROL_CODE_POINTS = new Set([
58
+ 0x061c, 0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069,
59
+ ]);
50
60
 
51
61
  const DEFAULT_CONFIG: GateConfig = {
52
62
  promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS,
@@ -115,6 +125,7 @@ function isJsonObject(value: unknown): value is Record<string, unknown> {
115
125
  */
116
126
  export function parseConfig(content: string): GateConfig {
117
127
  const rules: Record<string, Action> = {};
128
+ let acceptedRuleCount = 0;
118
129
  let parsed: unknown;
119
130
  try {
120
131
  parsed = JSON.parse(content);
@@ -138,8 +149,11 @@ export function parseConfig(content: string): GateConfig {
138
149
  return { operations: rules, promptTimeoutMs };
139
150
  }
140
151
  for (const [pattern, action] of Object.entries(operations)) {
152
+ if (acceptedRuleCount >= MAX_RULE_COUNT) break;
153
+ if (pattern.length === 0 || pattern.length > MAX_RULE_PATTERN_LENGTH) continue;
141
154
  if (isAction(action)) {
142
155
  rules[pattern] = action;
156
+ acceptedRuleCount += 1;
143
157
  }
144
158
  }
145
159
  return { operations: rules, promptTimeoutMs };
@@ -175,7 +189,7 @@ export function ensureConfig(): void {
175
189
  } catch {
176
190
  return;
177
191
  }
178
- const content = JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n";
192
+ const content = JSON.stringify({ $schema: CONFIG_SCHEMA_URL, ...DEFAULT_CONFIG }, null, 2) + "\n";
179
193
  try {
180
194
  writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
181
195
  } catch {
@@ -212,14 +226,124 @@ export function resolveAction(command: string, rules: Record<string, Action>): A
212
226
  return resolveRule(command, rules)?.action ?? null;
213
227
  }
214
228
 
229
+ /** Converts terminal controls and bidirectional formatting characters to visible escapes. */
230
+ function escapeUnsafeDisplayCharacter(character: string): string {
231
+ const codePoint = character.codePointAt(0);
232
+ if (codePoint === undefined) return "";
233
+ if (
234
+ codePoint < 0x20 ||
235
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
236
+ BIDI_CONTROL_CODE_POINTS.has(codePoint)
237
+ ) {
238
+ return `\\u{${codePoint.toString(16).padStart(4, "0")}}`;
239
+ }
240
+ return character;
241
+ }
242
+
243
+ interface MatchRange {
244
+ start: number;
245
+ end: number;
246
+ }
247
+
248
+ /** Finds non-overlapping raw match ranges before any display escaping occurs. */
249
+ function findMatchRanges(command: string, pattern?: string): MatchRange[] {
250
+ if (!pattern) return [];
251
+ const ranges: MatchRange[] = [];
252
+ let offset = 0;
253
+ while (offset <= command.length - pattern.length) {
254
+ const start = command.indexOf(pattern, offset);
255
+ if (start === -1) break;
256
+ ranges.push({ start, end: start + pattern.length });
257
+ offset = start + pattern.length;
258
+ }
259
+ return ranges;
260
+ }
261
+
262
+ /** Produces bounded terminal-safe text, optionally marking raw matched spans. */
263
+ function renderCommandForDisplay(command: string, pattern?: string): string {
264
+ const ranges = findMatchRanges(command, pattern);
265
+ let rangeIndex = 0;
266
+ let activeRange: MatchRange | undefined;
267
+ let output = "";
268
+ let lineCount = 1;
269
+ let offset = 0;
270
+
271
+ const truncate = (closeActiveRange = activeRange !== undefined): string =>
272
+ output + (closeActiveRange ? "«" : "") + DISPLAY_TRUNCATION_MARKER;
273
+
274
+ while (offset < command.length) {
275
+ const rawStart = offset;
276
+ let rawEnd: number;
277
+ let rendered: string;
278
+ const codePoint = command.codePointAt(offset);
279
+ if (codePoint === 0x0d) {
280
+ rawEnd = offset + (command.codePointAt(offset + 1) === 0x0a ? 2 : 1);
281
+ rendered = "\n";
282
+ } else {
283
+ const character = String.fromCodePoint(codePoint!);
284
+ rawEnd = offset + character.length;
285
+ rendered = character === "\n" ? character : escapeUnsafeDisplayCharacter(character);
286
+ }
287
+
288
+ const nextRange = ranges[rangeIndex];
289
+ const wasInsideRange = activeRange !== undefined;
290
+ const opensRange =
291
+ activeRange === undefined &&
292
+ nextRange !== undefined &&
293
+ nextRange.start < rawEnd &&
294
+ nextRange.end > rawStart;
295
+ if (opensRange) activeRange = nextRange;
296
+ const closesRange = activeRange !== undefined && activeRange.end <= rawEnd;
297
+ const decorated = `${opensRange ? "»" : ""}${rendered}${closesRange ? "«" : ""}`;
298
+
299
+ if (rendered === "\n") {
300
+ if (lineCount >= MAX_DISPLAY_COMMAND_LINES) return truncate(wasInsideRange);
301
+ lineCount += 1;
302
+ }
303
+ if (output.length + decorated.length > MAX_DISPLAY_COMMAND_CHARACTERS) {
304
+ return truncate(wasInsideRange);
305
+ }
306
+
307
+ output += decorated;
308
+ if (closesRange) {
309
+ activeRange = undefined;
310
+ rangeIndex += 1;
311
+ }
312
+ offset = rawEnd;
313
+ }
314
+ return output;
315
+ }
316
+
215
317
  /**
216
- * Formats a rule match as a human-readable string for display in prompts and notifications.
318
+ * Produces bounded terminal-safe text for a command without changing the command that is executed.
319
+ */
320
+ export function formatCommandForDisplay(command: string): string {
321
+ return renderCommandForDisplay(command);
322
+ }
323
+
324
+ /** Marks raw matched-rule occurrences while producing terminal-safe command text. */
325
+ export function highlightRuleForDisplay(command: string, pattern: string): string {
326
+ return renderCommandForDisplay(command, pattern);
327
+ }
328
+
329
+ /** Indents every line of terminal-safe command text for the confirmation dialog. */
330
+ function formatPromptCommand(command: string, pattern: string): string {
331
+ return highlightRuleForDisplay(command, pattern)
332
+ .split("\n")
333
+ .map((line) => ` ${line}`)
334
+ .join("\n");
335
+ }
336
+
337
+ /**
338
+ * Formats a rule match as a terminal-safe, human-readable string.
217
339
  *
218
340
  * @param match - The rule match to format
219
341
  * @returns A string representation showing the pattern and action
220
342
  */
221
343
  function formatRule(match: RuleMatch): string {
222
- return `${JSON.stringify(match.pattern)}: ${JSON.stringify(match.action)}`;
344
+ return formatCommandForDisplay(
345
+ `${JSON.stringify(match.pattern)}: ${JSON.stringify(match.action)}`,
346
+ );
223
347
  }
224
348
 
225
349
  /** Gate the built-in `bash` tool against the user-provided rules. */
@@ -275,7 +399,7 @@ export default function piGate(pi: ExtensionAPI): void {
275
399
  };
276
400
  }
277
401
  const choice = await ctx.ui.select(
278
- `pi-gate: allow this command?\n\n ${command}\n\nMatched rule: ${rule}`,
402
+ `pi-gate: allow this command?\n\n${formatPromptCommand(command, match.pattern)}\n\nMatched rule: ${rule}\nMatched command text is wrapped in »…«`,
279
403
  ["Allow", "Deny"],
280
404
  { timeout: config.promptTimeoutMs },
281
405
  );