@zeldrisho/pi-gate 0.4.0 → 0.4.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.4.1] - 2026-09-17
11
+
12
+ ### Security
13
+
14
+ - prompt for all commands when an existing configuration cannot be read or parsed instead of allowing every command through unblocked
15
+
10
16
  ## [0.4.0] - 2026-09-12
11
17
 
12
18
  ### Changed
@@ -50,7 +56,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
50
56
  - support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
51
57
  - block rather than auto-approve in non-UI modes
52
58
 
53
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.0...HEAD
59
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.1...HEAD
60
+ [0.4.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.0...pi-gate-v0.4.1
54
61
  [0.4.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.3.0...pi-gate-v0.4.0
55
62
  [0.3.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.2.0...pi-gate-v0.3.0
56
63
  [0.2.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.1...pi-gate-v0.2.0
package/README.md CHANGED
@@ -12,7 +12,7 @@ pi install -l npm:@zeldrisho/pi-gate
12
12
 
13
13
  ## Configure
14
14
 
15
- On first load, the extension creates `~/.pi/agent/pi-gate.json` with starter rules and a 30-second prompt timeout. Existing files are never overwritten. The published package includes [`config.schema.json`](config.schema.json); use this schema URL for editor completion:
15
+ On first load, the extension creates `~/.pi/agent/pi-gate.json` with starter rules and a 30-second prompt timeout. Existing files are never overwritten. If no configuration can be created yet, pi-gate warns at session start and allows commands until the file is created. If an existing configuration cannot be read or parsed, pi-gate warns and prompts for every command until it is fixed. The published package includes [`config.schema.json`](config.schema.json); use this schema URL for editor completion:
16
16
 
17
17
  ```json
18
18
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-gate",
3
- "version": "0.4.0",
3
+ "version": "0.4.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",
@@ -25,9 +25,7 @@
25
25
  "files": [
26
26
  "src",
27
27
  "config.schema.json",
28
- "README.md",
29
- "CHANGELOG.md",
30
- "LICENSE"
28
+ "CHANGELOG.md"
31
29
  ],
32
30
  "type": "module",
33
31
  "publishConfig": {
@@ -36,8 +34,8 @@
36
34
  "devDependencies": {
37
35
  "@earendil-works/pi-coding-agent": "^0.84.2",
38
36
  "typescript": "^7.0.2",
39
- "vite": "npm:@voidzero-dev/vite-plus-core@0.3.0",
40
- "vite-plus": "0.3.0"
37
+ "vite": "npm:@voidzero-dev/vite-plus-core@0.3.2",
38
+ "vite-plus": "0.3.2"
41
39
  },
42
40
  "peerDependencies": {
43
41
  "@earendil-works/pi-coding-agent": "*"
package/src/config.ts CHANGED
@@ -9,13 +9,26 @@ export interface GateConfig {
9
9
  promptTimeoutMs: number;
10
10
  }
11
11
 
12
+ export type ConfigLoadStatus = "missing" | "loaded" | "failed";
13
+
14
+ export interface ConfigLoadResult {
15
+ config: GateConfig;
16
+ status: ConfigLoadStatus;
17
+ }
18
+
12
19
  const CONFIG_FILE_NAME = "pi-gate.json";
20
+
13
21
  export const CONFIG_SCHEMA_URL =
14
22
  "https://raw.githubusercontent.com/zeldrisho/pi-packages/main/packages/pi-gate/config.schema.json";
23
+
15
24
  const ACTIONS: readonly Action[] = ["prompt", "block", "allow"] as const;
25
+
16
26
  export const DEFAULT_PROMPT_TIMEOUT_MS = 30_000;
27
+
17
28
  export const MAX_RULE_COUNT = 1_000;
29
+
18
30
  export const MAX_RULE_PATTERN_LENGTH = 1_024;
31
+
19
32
  export const MAX_PROMPT_TIMEOUT_MS = 86_400_000;
20
33
 
21
34
  const DEFAULT_CONFIG: GateConfig = {
@@ -87,15 +100,19 @@ export function parseConfig(content: string): GateConfig {
87
100
  const rules: Record<string, Action> = {};
88
101
  let acceptedRuleCount = 0;
89
102
  let parsed: unknown;
103
+
90
104
  try {
91
105
  parsed = JSON.parse(content);
92
106
  } catch {
93
107
  return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
94
108
  }
109
+
95
110
  if (!isJsonObject(parsed)) {
96
111
  return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
97
112
  }
113
+
98
114
  const configuredTimeout = parsed["promptTimeoutMs"];
115
+
99
116
  const promptTimeoutMs =
100
117
  // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validate the optional numeric field at the untrusted JSON boundary before applying range checks
101
118
  typeof configuredTimeout === "number" &&
@@ -104,18 +121,24 @@ export function parseConfig(content: string): GateConfig {
104
121
  configuredTimeout <= MAX_PROMPT_TIMEOUT_MS
105
122
  ? configuredTimeout
106
123
  : DEFAULT_PROMPT_TIMEOUT_MS;
124
+
107
125
  const operations = parsed["operations"];
126
+
108
127
  if (!isJsonObject(operations)) {
109
128
  return { operations: rules, promptTimeoutMs };
110
129
  }
130
+
111
131
  for (const [pattern, action] of Object.entries(operations)) {
112
132
  if (acceptedRuleCount >= MAX_RULE_COUNT) break;
133
+
113
134
  if (pattern.length === 0 || pattern.length > MAX_RULE_PATTERN_LENGTH) continue;
135
+
114
136
  if (isAction(action)) {
115
137
  rules[pattern] = action;
116
138
  acceptedRuleCount += 1;
117
139
  }
118
140
  }
141
+
119
142
  return { operations: rules, promptTimeoutMs };
120
143
  }
121
144
 
@@ -123,18 +146,38 @@ export function parseConfig(content: string): GateConfig {
123
146
  * Loads the configuration from disk. Returns an empty configuration when the
124
147
  * file is missing; returns the parsed configuration otherwise.
125
148
  */
126
- export function loadConfig(): GateConfig {
149
+ export function loadConfigResult(): ConfigLoadResult {
150
+ const emptyConfig = { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
127
151
  const path = configPath();
152
+
128
153
  if (!existsSync(path)) {
129
- return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
154
+ return { config: emptyConfig, status: "missing" };
130
155
  }
156
+
131
157
  let content: string;
158
+
132
159
  try {
133
160
  content = readFileSync(path, "utf-8");
134
161
  } catch {
135
- return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
162
+ return { config: emptyConfig, status: "failed" };
163
+ }
164
+
165
+ try {
166
+ const parsed: unknown = JSON.parse(content);
167
+
168
+ if (!isJsonObject(parsed) || !isJsonObject(parsed["operations"])) {
169
+ return { config: parseConfig(content), status: "failed" };
170
+ }
171
+ } catch {
172
+ return { config: emptyConfig, status: "failed" };
136
173
  }
137
- return parseConfig(content);
174
+
175
+ return { config: parseConfig(content), status: "loaded" };
176
+ }
177
+
178
+ /** Loads the configuration, preserving the historical empty-config fallback. */
179
+ export function loadConfig(): GateConfig {
180
+ return loadConfigResult().config;
138
181
  }
139
182
 
140
183
  /**
@@ -143,13 +186,17 @@ export function loadConfig(): GateConfig {
143
186
  */
144
187
  export function ensureConfig(): void {
145
188
  const path = configPath();
189
+
146
190
  if (existsSync(path)) return;
191
+
147
192
  try {
148
193
  mkdirSync(agentDir(), { recursive: true });
149
194
  } catch {
150
195
  return;
151
196
  }
197
+
152
198
  const content = JSON.stringify({ $schema: CONFIG_SCHEMA_URL, ...DEFAULT_CONFIG }, null, 2) + "\n";
199
+
153
200
  try {
154
201
  writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
155
202
  } catch {
package/src/display.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { RuleMatch } from "./rules";
2
2
 
3
3
  export const MAX_DISPLAY_COMMAND_CHARACTERS = 2_000;
4
+
4
5
  export const MAX_DISPLAY_COMMAND_LINES = 20;
6
+
5
7
  const DISPLAY_TRUNCATION_MARKER = "\n … [command display truncated]";
8
+
6
9
  const BIDI_CONTROL_CODE_POINTS = new Set([
7
10
  0x061c, 0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069,
8
11
  ]);
@@ -10,7 +13,9 @@ const BIDI_CONTROL_CODE_POINTS = new Set([
10
13
  /** Converts terminal controls and bidirectional formatting characters to visible escapes. */
11
14
  function escapeUnsafeDisplayCharacter(character: string): string {
12
15
  const codePoint = character.codePointAt(0);
16
+
13
17
  if (codePoint === undefined) return "";
18
+
14
19
  if (
15
20
  codePoint < 0x20 ||
16
21
  (codePoint >= 0x7f && codePoint <= 0x9f) ||
@@ -18,6 +23,7 @@ function escapeUnsafeDisplayCharacter(character: string): string {
18
23
  ) {
19
24
  return `\\u{${codePoint.toString(16).padStart(4, "0")}}`;
20
25
  }
26
+
21
27
  return character;
22
28
  }
23
29
 
@@ -31,12 +37,15 @@ function findMatchRanges(command: string, pattern?: string): MatchRange[] {
31
37
  if (!pattern) return [];
32
38
  const ranges: MatchRange[] = [];
33
39
  let offset = 0;
40
+
34
41
  while (offset <= command.length - pattern.length) {
35
42
  const start = command.indexOf(pattern, offset);
43
+
36
44
  if (start === -1) break;
37
45
  ranges.push({ start, end: start + pattern.length });
38
46
  offset = start + pattern.length;
39
47
  }
48
+
40
49
  return ranges;
41
50
  }
42
51
 
@@ -57,6 +66,7 @@ function renderCommandForDisplay(command: string, pattern?: string): string {
57
66
  let rawEnd: number;
58
67
  let rendered: string;
59
68
  const codePoint = command.codePointAt(offset);
69
+
60
70
  if (codePoint === 0x0d) {
61
71
  rawEnd = offset + (command.codePointAt(offset + 1) === 0x0a ? 2 : 1);
62
72
  rendered = "\n";
@@ -68,11 +78,13 @@ function renderCommandForDisplay(command: string, pattern?: string): string {
68
78
 
69
79
  const nextRange = ranges[rangeIndex];
70
80
  const wasInsideRange = activeRange !== undefined;
81
+
71
82
  const opensRange =
72
83
  activeRange === undefined &&
73
84
  nextRange !== undefined &&
74
85
  nextRange.start < rawEnd &&
75
86
  nextRange.end > rawStart;
87
+
76
88
  if (opensRange) activeRange = nextRange;
77
89
  const closesRange = activeRange !== undefined && activeRange.end <= rawEnd;
78
90
  const decorated = `${opensRange ? "»" : ""}${rendered}${closesRange ? "«" : ""}`;
@@ -81,17 +93,21 @@ function renderCommandForDisplay(command: string, pattern?: string): string {
81
93
  if (lineCount >= MAX_DISPLAY_COMMAND_LINES) return truncate(wasInsideRange);
82
94
  lineCount += 1;
83
95
  }
96
+
84
97
  if (output.length + decorated.length > MAX_DISPLAY_COMMAND_CHARACTERS) {
85
98
  return truncate(wasInsideRange);
86
99
  }
87
100
 
88
101
  output += decorated;
102
+
89
103
  if (closesRange) {
90
104
  activeRange = undefined;
91
105
  rangeIndex += 1;
92
106
  }
107
+
93
108
  offset = rawEnd;
94
109
  }
110
+
95
111
  return output;
96
112
  }
97
113
 
package/src/index.ts CHANGED
@@ -26,9 +26,8 @@
26
26
  * indefinitely.
27
27
  */
28
28
 
29
- import { existsSync } from "node:fs";
30
29
  import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
- import { configPath, ensureConfig, loadConfig } from "./config";
30
+ import { configPath, ensureConfig, loadConfigResult } from "./config";
32
31
  import { resolveRule } from "./rules";
33
32
  import { formatPromptCommand, formatRule } from "./display";
34
33
 
@@ -58,6 +57,7 @@ function reportHerdrBlocked(pi: ExtensionAPI, ctx: { mode?: string }, active: bo
58
57
  }
59
58
 
60
59
  export type { Action, GateConfig } from "./config";
60
+
61
61
  export {
62
62
  CONFIG_SCHEMA_URL,
63
63
  DEFAULT_PROMPT_TIMEOUT_MS,
@@ -66,10 +66,16 @@ export {
66
66
  MAX_RULE_PATTERN_LENGTH,
67
67
  ensureConfig,
68
68
  loadConfig,
69
+ loadConfigResult,
69
70
  parseConfig,
70
71
  } from "./config";
72
+
73
+ export type { ConfigLoadResult, ConfigLoadStatus } from "./config";
74
+
71
75
  export type { RuleMatch } from "./rules";
76
+
72
77
  export { resolveAction, resolveRule } from "./rules";
78
+
73
79
  export {
74
80
  MAX_DISPLAY_COMMAND_CHARACTERS,
75
81
  MAX_DISPLAY_COMMAND_LINES,
@@ -80,12 +86,14 @@ export {
80
86
  /** Gate the built-in `bash` tool against the user-provided rules. */
81
87
  export default function piGate(pi: ExtensionAPI): void {
82
88
  ensureConfig();
83
- const config = loadConfig();
89
+ let loadResult = loadConfigResult();
90
+ const { config } = loadResult;
84
91
 
85
92
  pi.on("session_start", (_event, ctx) => {
86
93
  const path = configPath();
87
94
  const ruleCount = Object.keys(config.operations).length;
88
- if (existsSync(path)) {
95
+
96
+ if (loadResult.status === "loaded") {
89
97
  if (ruleCount === 0) {
90
98
  ctx.ui.notify(
91
99
  `pi-gate: ${path} is empty; no commands are gated. Add rules to the "operations" object to start gating.`,
@@ -97,9 +105,14 @@ export default function piGate(pi: ExtensionAPI): void {
97
105
  "info",
98
106
  );
99
107
  }
108
+ } else if (loadResult.status === "failed") {
109
+ ctx.ui.notify(
110
+ "pi-gate: configuration exists but could not be loaded; prompting for all commands until it is fixed.",
111
+ "warning",
112
+ );
100
113
  } else {
101
114
  ctx.ui.notify(
102
- `pi-gate: could not create configuration at ${path}; all commands are allowed until that file is created.`,
115
+ `pi-gate: no configuration found at ${configPath()}; all commands are allowed until it is created.`,
103
116
  "warning",
104
117
  );
105
118
  }
@@ -107,17 +120,29 @@ export default function piGate(pi: ExtensionAPI): void {
107
120
 
108
121
  pi.on("tool_call", async (event, ctx) => {
109
122
  if (event.toolName !== "bash") return undefined;
123
+
110
124
  if (!isToolCallEventType("bash", event)) return undefined;
111
125
  const command = event.input.command;
112
- const match = resolveRule(command, config.operations);
126
+
127
+ if (loadResult.status === "failed") {
128
+ loadResult = loadConfigResult();
129
+ }
130
+
131
+ const match =
132
+ loadResult.status === "failed"
133
+ ? { pattern: "configuration unavailable", action: "prompt" as const }
134
+ : resolveRule(command, loadResult.config.operations);
135
+
113
136
  if (match === null || match.action === "allow") return undefined;
114
137
  const rule = formatRule(match);
115
138
 
116
139
  if (match.action === "block") {
117
140
  const reason = `pi-gate: command blocked by rule ${rule}`;
141
+
118
142
  if (ctx.hasUI) {
119
143
  ctx.ui.notify(reason, "warning");
120
144
  }
145
+
121
146
  return { block: true, reason, terminate: true };
122
147
  }
123
148
 
@@ -129,17 +154,20 @@ export default function piGate(pi: ExtensionAPI): void {
129
154
  terminate: true,
130
155
  };
131
156
  }
157
+
132
158
  reportHerdrBlocked(pi, ctx, true);
133
159
  let choice: string | undefined;
160
+
134
161
  try {
135
162
  choice = await ctx.ui.select(
136
163
  `pi-gate: allow this command?\n\n${formatPromptCommand(command, match.pattern)}\n\nMatched rule: ${rule}\nMatched command text is wrapped in »…«`,
137
164
  ["Allow", "Deny"],
138
- { timeout: config.promptTimeoutMs },
165
+ { timeout: loadResult.config.promptTimeoutMs },
139
166
  );
140
167
  } finally {
141
168
  reportHerdrBlocked(pi, ctx, false);
142
169
  }
170
+
143
171
  if (choice !== "Allow") {
144
172
  return {
145
173
  block: true,
@@ -147,6 +175,7 @@ export default function piGate(pi: ExtensionAPI): void {
147
175
  terminate: true,
148
176
  };
149
177
  }
178
+
150
179
  return undefined;
151
180
  });
152
181
  }
package/src/rules.ts CHANGED
@@ -18,13 +18,17 @@ export interface RuleMatch {
18
18
  */
19
19
  export function resolveRule(command: string, rules: Record<string, Action>): RuleMatch | null {
20
20
  let bestPattern: string | null = null;
21
+
21
22
  for (const pattern of Object.keys(rules)) {
22
23
  if (!command.includes(pattern)) continue;
24
+
23
25
  if (bestPattern === null || pattern.length > bestPattern.length) {
24
26
  bestPattern = pattern;
25
27
  }
26
28
  }
29
+
27
30
  if (bestPattern === null) return null;
31
+
28
32
  return { pattern: bestPattern, action: rules[bestPattern] };
29
33
  }
30
34