@zeldrisho/pi-gate 0.4.0 → 0.5.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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-24
11
+
12
+ ### Changed
13
+
14
+ - Use `gate.json` as the default configuration filename, retaining `pi-gate.json` as a legacy fallback only when `gate.json` is absent; existing files are never automatically renamed or overwritten.
15
+
16
+ ## [0.4.1] - 2026-09-17
17
+
18
+ ### Security
19
+
20
+ - prompt for all commands when an existing configuration cannot be read or parsed instead of allowing every command through unblocked
21
+
10
22
  ## [0.4.0] - 2026-09-12
11
23
 
12
24
  ### Changed
@@ -50,7 +62,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
50
62
  - support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
51
63
  - block rather than auto-approve in non-UI modes
52
64
 
53
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.0...HEAD
65
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.5.0...HEAD
66
+ [0.5.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.1...pi-gate-v0.5.0
67
+ [0.4.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.0...pi-gate-v0.4.1
54
68
  [0.4.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.3.0...pi-gate-v0.4.0
55
69
  [0.3.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.2.0...pi-gate-v0.3.0
56
70
  [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,9 @@ 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
+ The extension reads `~/.pi/agent/gate.json`, falling back to legacy `~/.pi/agent/pi-gate.json` only when `gate.json` is absent. Set `PI_CODING_AGENT_DIR` to use a different configuration directory. If both files exist, only `gate.json` is used—even if it is invalid. To migrate, rename `pi-gate.json` to `gate.json` and run `/reload`; existing files are never automatically renamed, merged, or overwritten.
16
+
17
+ On first load, if neither file exists, the extension creates `gate.json` with starter rules and a 30-second prompt timeout. 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
18
 
17
19
  ```json
18
20
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-gate",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -25,19 +25,17 @@
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": {
34
32
  "access": "public"
35
33
  },
36
34
  "devDependencies": {
37
- "@earendil-works/pi-coding-agent": "^0.84.2",
35
+ "@earendil-works/pi-coding-agent": "^0.85.1",
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.3",
38
+ "vite-plus": "0.3.3"
41
39
  },
42
40
  "peerDependencies": {
43
41
  "@earendil-works/pi-coding-agent": "*"
package/src/config.ts CHANGED
@@ -9,13 +9,28 @@ export interface GateConfig {
9
9
  promptTimeoutMs: number;
10
10
  }
11
11
 
12
- const CONFIG_FILE_NAME = "pi-gate.json";
12
+ export type ConfigLoadStatus = "missing" | "loaded" | "failed";
13
+
14
+ export interface ConfigLoadResult {
15
+ config: GateConfig;
16
+ status: ConfigLoadStatus;
17
+ }
18
+
19
+ const CONFIG_FILE_NAME = "gate.json";
20
+
21
+ const LEGACY_CONFIG_FILE_NAME = "pi-gate.json";
22
+
13
23
  export const CONFIG_SCHEMA_URL =
14
24
  "https://raw.githubusercontent.com/zeldrisho/pi-packages/main/packages/pi-gate/config.schema.json";
25
+
15
26
  const ACTIONS: readonly Action[] = ["prompt", "block", "allow"] as const;
27
+
16
28
  export const DEFAULT_PROMPT_TIMEOUT_MS = 30_000;
29
+
17
30
  export const MAX_RULE_COUNT = 1_000;
31
+
18
32
  export const MAX_RULE_PATTERN_LENGTH = 1_024;
33
+
19
34
  export const MAX_PROMPT_TIMEOUT_MS = 86_400_000;
20
35
 
21
36
  const DEFAULT_CONFIG: GateConfig = {
@@ -41,10 +56,18 @@ function agentDir(): string {
41
56
  /**
42
57
  * Returns the full path to the pi-gate configuration file.
43
58
  *
44
- * @returns The absolute path to pi-gate.json
59
+ * Prefers gate.json, falling back to pi-gate.json only when gate.json is absent.
60
+ *
61
+ * @returns The selected configuration path, or gate.json for first-run creation
45
62
  */
46
63
  export function configPath(): string {
47
- return join(agentDir(), CONFIG_FILE_NAME);
64
+ const path = join(agentDir(), CONFIG_FILE_NAME);
65
+
66
+ if (existsSync(path)) return path;
67
+
68
+ const legacyPath = join(agentDir(), LEGACY_CONFIG_FILE_NAME);
69
+
70
+ return existsSync(legacyPath) ? legacyPath : path;
48
71
  }
49
72
 
50
73
  /**
@@ -87,15 +110,19 @@ export function parseConfig(content: string): GateConfig {
87
110
  const rules: Record<string, Action> = {};
88
111
  let acceptedRuleCount = 0;
89
112
  let parsed: unknown;
113
+
90
114
  try {
91
115
  parsed = JSON.parse(content);
92
116
  } catch {
93
117
  return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
94
118
  }
119
+
95
120
  if (!isJsonObject(parsed)) {
96
121
  return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
97
122
  }
123
+
98
124
  const configuredTimeout = parsed["promptTimeoutMs"];
125
+
99
126
  const promptTimeoutMs =
100
127
  // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validate the optional numeric field at the untrusted JSON boundary before applying range checks
101
128
  typeof configuredTimeout === "number" &&
@@ -104,18 +131,24 @@ export function parseConfig(content: string): GateConfig {
104
131
  configuredTimeout <= MAX_PROMPT_TIMEOUT_MS
105
132
  ? configuredTimeout
106
133
  : DEFAULT_PROMPT_TIMEOUT_MS;
134
+
107
135
  const operations = parsed["operations"];
136
+
108
137
  if (!isJsonObject(operations)) {
109
138
  return { operations: rules, promptTimeoutMs };
110
139
  }
140
+
111
141
  for (const [pattern, action] of Object.entries(operations)) {
112
142
  if (acceptedRuleCount >= MAX_RULE_COUNT) break;
143
+
113
144
  if (pattern.length === 0 || pattern.length > MAX_RULE_PATTERN_LENGTH) continue;
145
+
114
146
  if (isAction(action)) {
115
147
  rules[pattern] = action;
116
148
  acceptedRuleCount += 1;
117
149
  }
118
150
  }
151
+
119
152
  return { operations: rules, promptTimeoutMs };
120
153
  }
121
154
 
@@ -123,18 +156,38 @@ export function parseConfig(content: string): GateConfig {
123
156
  * Loads the configuration from disk. Returns an empty configuration when the
124
157
  * file is missing; returns the parsed configuration otherwise.
125
158
  */
126
- export function loadConfig(): GateConfig {
159
+ export function loadConfigResult(): ConfigLoadResult {
160
+ const emptyConfig = { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
127
161
  const path = configPath();
162
+
128
163
  if (!existsSync(path)) {
129
- return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
164
+ return { config: emptyConfig, status: "missing" };
130
165
  }
166
+
131
167
  let content: string;
168
+
132
169
  try {
133
170
  content = readFileSync(path, "utf-8");
134
171
  } catch {
135
- return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
172
+ return { config: emptyConfig, status: "failed" };
136
173
  }
137
- return parseConfig(content);
174
+
175
+ try {
176
+ const parsed: unknown = JSON.parse(content);
177
+
178
+ if (!isJsonObject(parsed) || !isJsonObject(parsed["operations"])) {
179
+ return { config: parseConfig(content), status: "failed" };
180
+ }
181
+ } catch {
182
+ return { config: emptyConfig, status: "failed" };
183
+ }
184
+
185
+ return { config: parseConfig(content), status: "loaded" };
186
+ }
187
+
188
+ /** Loads the configuration, preserving the historical empty-config fallback. */
189
+ export function loadConfig(): GateConfig {
190
+ return loadConfigResult().config;
138
191
  }
139
192
 
140
193
  /**
@@ -143,13 +196,17 @@ export function loadConfig(): GateConfig {
143
196
  */
144
197
  export function ensureConfig(): void {
145
198
  const path = configPath();
199
+
146
200
  if (existsSync(path)) return;
201
+
147
202
  try {
148
203
  mkdirSync(agentDir(), { recursive: true });
149
204
  } catch {
150
205
  return;
151
206
  }
207
+
152
208
  const content = JSON.stringify({ $schema: CONFIG_SCHEMA_URL, ...DEFAULT_CONFIG }, null, 2) + "\n";
209
+
153
210
  try {
154
211
  writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
155
212
  } 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
@@ -5,7 +5,7 @@
5
5
  * user-provided JSON configuration. Without a configuration file, the
6
6
  * extension creates one with starter rules and a default prompt timeout.
7
7
  *
8
- * Configuration file: `~/.pi/agent/pi-gate.json`
8
+ * Configuration file: `~/.pi/agent/gate.json` (legacy fallback: `pi-gate.json`)
9
9
  *
10
10
  * The configuration has an `operations` section, which maps a substring
11
11
  * pattern to one of three actions, and an optional `promptTimeoutMs` setting:
@@ -18,7 +18,7 @@
18
18
  * narrow `allow` rule can override a broader `prompt` or `block` rule.
19
19
  *
20
20
  * On first run, a configuration with the default prompt timeout and starter
21
- * operation rules is written to `~/.pi/agent/pi-gate.json`.
21
+ * operation rules is written to `~/.pi/agent/gate.json` when neither file exists.
22
22
  *
23
23
  * Non-UI modes (print, JSON) never auto-approve. A `prompt` or `block` rule
24
24
  * in non-UI mode always blocks and requests termination of the agent turn.
@@ -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