@zeldrisho/pi-gate 0.3.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,18 @@ 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
+
16
+ ## [0.4.0] - 2026-09-12
17
+
18
+ ### Changed
19
+
20
+ - report interactive approval prompts to Herdr when running inside a Herdr-managed TUI pane, while remaining a no-op for other users
21
+
10
22
  ## [0.3.0] - 2026-09-07
11
23
 
12
24
  ### Added
@@ -44,7 +56,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
44
56
  - support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
45
57
  - block rather than auto-approve in non-UI modes
46
58
 
47
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.3.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
61
+ [0.4.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.3.0...pi-gate-v0.4.0
48
62
  [0.3.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.2.0...pi-gate-v0.3.0
49
63
  [0.2.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.1...pi-gate-v0.2.0
50
64
  [0.1.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.0...pi-gate-v0.1.1
package/README.md CHANGED
@@ -1,30 +1,18 @@
1
1
  # @zeldrisho/pi-gate
2
2
 
3
- Pi extension that blocks or confirms bash tool calls based on a user-provided JSON configuration.
3
+ Pi extension that blocks or confirms `bash` tool calls using a user-provided JSON configuration.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
8
  pi install npm:@zeldrisho/pi-gate
9
- ```
10
-
11
- Install only for the current project:
12
-
13
- ```bash
9
+ # project-local:
14
10
  pi install -l npm:@zeldrisho/pi-gate
15
11
  ```
16
12
 
17
13
  ## Configure
18
14
 
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.
22
-
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:
24
-
25
- - `prompt` — ask the user to allow or deny the command
26
- - `block` — deny the command without asking
27
- - `allow` — explicitly allow an exception to a broader matching rule
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:
28
16
 
29
17
  ```json
30
18
  {
@@ -34,50 +22,30 @@ Edit `~/.pi/agent/pi-gate.json` to customize the rules. `promptTimeoutMs` contro
34
22
  "rm -rf": "prompt",
35
23
  "sudo": "prompt",
36
24
  "sudo apt update": "allow",
37
- "chmod 777": "block",
38
- "corepack enable": "block"
25
+ "chmod 777": "block"
39
26
  }
40
27
  }
41
28
  ```
42
29
 
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.
30
+ Rules use substring matching and have one of three actions:
44
31
 
45
- After editing, run `/reload` to apply the new rules in the current session.
46
-
47
- ## Behavior
32
+ - `prompt`: ask the user to allow or deny;
33
+ - `block`: deny without asking;
34
+ - `allow`: explicitly permit an exception to a broader match.
48
35
 
49
- - Only the built-in `bash` tool is gated. Other tools pass through unchanged.
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.
51
- - `block` rules never ask and always deny. The warning identifies the matched rule.
52
- - `allow` rules are explicit pass-throughs, useful for carving out exceptions.
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.
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.
36
+ Empty or over-1,024-character patterns are ignored, at most 1,000 valid rules are loaded, and commands with no match are allowed. The longest matching pattern wins. `promptTimeoutMs` defaults to 30 seconds and is capped at one day. Run `/reload` after editing.
55
37
 
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.
38
+ ## Behavior
64
39
 
65
- For example, a blocked `sudo apt update` call reports:
40
+ Only the built-in `bash` tool is gated. Prompt dialogs offer `Allow` and `Deny`, escape terminal controls, and show at most 2,000 command characters and 20 lines; an allowed command executes in full and unchanged. Blocked, denied, dismissed, and timed-out calls request early termination. A parallel batch continues when it contains allowed calls. In non-interactive modes (`-p`, JSON), prompts and blocks deny and request termination.
66
41
 
67
- ```text
68
- pi-gate: command blocked by rule "sudo": "block"
69
- ```
42
+ The agent receives normal bash output after approval, or a bounded error naming the matched rule after denial or blocking. Dialogs and choices are not sent directly to the agent. RPC hosts use their native selection dialog.
70
43
 
71
44
  ## Uninstall
72
45
 
73
46
  ```bash
74
47
  pi remove npm:@zeldrisho/pi-gate
75
- ```
76
-
77
- For a project-local installation:
78
-
79
- ```bash
80
- pi remove -l npm:@zeldrisho/pi-gate
48
+ pi remove -l npm:@zeldrisho/pi-gate # project-local
81
49
  ```
82
50
 
83
51
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-gate",
3
- "version": "0.3.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 ADDED
@@ -0,0 +1,206 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export type Action = "prompt" | "block" | "allow";
6
+
7
+ export interface GateConfig {
8
+ operations: Record<string, Action>;
9
+ promptTimeoutMs: number;
10
+ }
11
+
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 = "pi-gate.json";
20
+
21
+ export const CONFIG_SCHEMA_URL =
22
+ "https://raw.githubusercontent.com/zeldrisho/pi-packages/main/packages/pi-gate/config.schema.json";
23
+
24
+ const ACTIONS: readonly Action[] = ["prompt", "block", "allow"] as const;
25
+
26
+ export const DEFAULT_PROMPT_TIMEOUT_MS = 30_000;
27
+
28
+ export const MAX_RULE_COUNT = 1_000;
29
+
30
+ export const MAX_RULE_PATTERN_LENGTH = 1_024;
31
+
32
+ export const MAX_PROMPT_TIMEOUT_MS = 86_400_000;
33
+
34
+ const DEFAULT_CONFIG: GateConfig = {
35
+ promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS,
36
+ operations: {
37
+ "rm -rf": "prompt",
38
+ sudo: "prompt",
39
+ "sudo apt update": "allow",
40
+ "chmod 777": "block",
41
+ "corepack enable": "block",
42
+ },
43
+ };
44
+
45
+ /**
46
+ * Returns the directory where pi-gate configuration files are stored.
47
+ *
48
+ * @returns The absolute path to the agent configuration directory
49
+ */
50
+ function agentDir(): string {
51
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
52
+ }
53
+
54
+ /**
55
+ * Returns the full path to the pi-gate configuration file.
56
+ *
57
+ * @returns The absolute path to pi-gate.json
58
+ */
59
+ export function configPath(): string {
60
+ return join(agentDir(), CONFIG_FILE_NAME);
61
+ }
62
+
63
+ /**
64
+ * Type guard that checks if an unknown value is a valid Action.
65
+ *
66
+ * @param value - The value to check
67
+ * @returns `true` if the value is one of "prompt", "block", or "allow"
68
+ */
69
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- type-guard helper intentionally narrows untrusted JSON values at the parse boundary
70
+ function isAction(value: unknown): value is Action {
71
+ return (
72
+ typeof value === "string" &&
73
+ // SAFETY: ACTIONS is the source-of-truth tuple of supported action strings; widening
74
+ // it to readonly string[] lets Array.prototype.includes perform a runtime check
75
+ // before the type predicate narrows the value to Action.
76
+ (ACTIONS as readonly string[]).includes(value)
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Type guard that checks if an unknown value is a plain JSON object.
82
+ *
83
+ * @param value - The value to check
84
+ * @returns `true` if the value is a non-null object and not an array
85
+ */
86
+ // 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
87
+ function isJsonObject(value: unknown): value is Record<string, unknown> {
88
+ return typeof value === "object" && value !== null && !Array.isArray(value);
89
+ }
90
+
91
+ /**
92
+ * Parses the user configuration file. Returns an empty configuration (no
93
+ * rules) when the file is missing, unreadable, malformed, or fails
94
+ * validation; an explicit empty `operations` object is also valid.
95
+ *
96
+ * Invalid entries are skipped so a single bad rule does not disable the
97
+ * entire gate; parsing continues for the remaining rules.
98
+ */
99
+ export function parseConfig(content: string): GateConfig {
100
+ const rules: Record<string, Action> = {};
101
+ let acceptedRuleCount = 0;
102
+ let parsed: unknown;
103
+
104
+ try {
105
+ parsed = JSON.parse(content);
106
+ } catch {
107
+ return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
108
+ }
109
+
110
+ if (!isJsonObject(parsed)) {
111
+ return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
112
+ }
113
+
114
+ const configuredTimeout = parsed["promptTimeoutMs"];
115
+
116
+ const promptTimeoutMs =
117
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validate the optional numeric field at the untrusted JSON boundary before applying range checks
118
+ typeof configuredTimeout === "number" &&
119
+ Number.isInteger(configuredTimeout) &&
120
+ configuredTimeout > 0 &&
121
+ configuredTimeout <= MAX_PROMPT_TIMEOUT_MS
122
+ ? configuredTimeout
123
+ : DEFAULT_PROMPT_TIMEOUT_MS;
124
+
125
+ const operations = parsed["operations"];
126
+
127
+ if (!isJsonObject(operations)) {
128
+ return { operations: rules, promptTimeoutMs };
129
+ }
130
+
131
+ for (const [pattern, action] of Object.entries(operations)) {
132
+ if (acceptedRuleCount >= MAX_RULE_COUNT) break;
133
+
134
+ if (pattern.length === 0 || pattern.length > MAX_RULE_PATTERN_LENGTH) continue;
135
+
136
+ if (isAction(action)) {
137
+ rules[pattern] = action;
138
+ acceptedRuleCount += 1;
139
+ }
140
+ }
141
+
142
+ return { operations: rules, promptTimeoutMs };
143
+ }
144
+
145
+ /**
146
+ * Loads the configuration from disk. Returns an empty configuration when the
147
+ * file is missing; returns the parsed configuration otherwise.
148
+ */
149
+ export function loadConfigResult(): ConfigLoadResult {
150
+ const emptyConfig = { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
151
+ const path = configPath();
152
+
153
+ if (!existsSync(path)) {
154
+ return { config: emptyConfig, status: "missing" };
155
+ }
156
+
157
+ let content: string;
158
+
159
+ try {
160
+ content = readFileSync(path, "utf-8");
161
+ } catch {
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" };
173
+ }
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;
181
+ }
182
+
183
+ /**
184
+ * Writes the default configuration on first run. Existing configuration files
185
+ * are never overwritten.
186
+ */
187
+ export function ensureConfig(): void {
188
+ const path = configPath();
189
+
190
+ if (existsSync(path)) return;
191
+
192
+ try {
193
+ mkdirSync(agentDir(), { recursive: true });
194
+ } catch {
195
+ return;
196
+ }
197
+
198
+ const content = JSON.stringify({ $schema: CONFIG_SCHEMA_URL, ...DEFAULT_CONFIG }, null, 2) + "\n";
199
+
200
+ try {
201
+ writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
202
+ } catch {
203
+ // Best-effort: another process may have created it, or the warning banner
204
+ // will surface that no configuration is active.
205
+ }
206
+ }
package/src/display.ts ADDED
@@ -0,0 +1,144 @@
1
+ import type { RuleMatch } from "./rules";
2
+
3
+ export const MAX_DISPLAY_COMMAND_CHARACTERS = 2_000;
4
+
5
+ export const MAX_DISPLAY_COMMAND_LINES = 20;
6
+
7
+ const DISPLAY_TRUNCATION_MARKER = "\n … [command display truncated]";
8
+
9
+ const BIDI_CONTROL_CODE_POINTS = new Set([
10
+ 0x061c, 0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069,
11
+ ]);
12
+
13
+ /** Converts terminal controls and bidirectional formatting characters to visible escapes. */
14
+ function escapeUnsafeDisplayCharacter(character: string): string {
15
+ const codePoint = character.codePointAt(0);
16
+
17
+ if (codePoint === undefined) return "";
18
+
19
+ if (
20
+ codePoint < 0x20 ||
21
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
22
+ BIDI_CONTROL_CODE_POINTS.has(codePoint)
23
+ ) {
24
+ return `\\u{${codePoint.toString(16).padStart(4, "0")}}`;
25
+ }
26
+
27
+ return character;
28
+ }
29
+
30
+ interface MatchRange {
31
+ start: number;
32
+ end: number;
33
+ }
34
+
35
+ /** Finds non-overlapping raw match ranges before any display escaping occurs. */
36
+ function findMatchRanges(command: string, pattern?: string): MatchRange[] {
37
+ if (!pattern) return [];
38
+ const ranges: MatchRange[] = [];
39
+ let offset = 0;
40
+
41
+ while (offset <= command.length - pattern.length) {
42
+ const start = command.indexOf(pattern, offset);
43
+
44
+ if (start === -1) break;
45
+ ranges.push({ start, end: start + pattern.length });
46
+ offset = start + pattern.length;
47
+ }
48
+
49
+ return ranges;
50
+ }
51
+
52
+ /** Produces bounded terminal-safe text, optionally marking raw matched spans. */
53
+ function renderCommandForDisplay(command: string, pattern?: string): string {
54
+ const ranges = findMatchRanges(command, pattern);
55
+ let rangeIndex = 0;
56
+ let activeRange: MatchRange | undefined;
57
+ let output = "";
58
+ let lineCount = 1;
59
+ let offset = 0;
60
+
61
+ const truncate = (closeActiveRange = activeRange !== undefined): string =>
62
+ output + (closeActiveRange ? "«" : "") + DISPLAY_TRUNCATION_MARKER;
63
+
64
+ while (offset < command.length) {
65
+ const rawStart = offset;
66
+ let rawEnd: number;
67
+ let rendered: string;
68
+ const codePoint = command.codePointAt(offset);
69
+
70
+ if (codePoint === 0x0d) {
71
+ rawEnd = offset + (command.codePointAt(offset + 1) === 0x0a ? 2 : 1);
72
+ rendered = "\n";
73
+ } else {
74
+ const character = String.fromCodePoint(codePoint!);
75
+ rawEnd = offset + character.length;
76
+ rendered = character === "\n" ? character : escapeUnsafeDisplayCharacter(character);
77
+ }
78
+
79
+ const nextRange = ranges[rangeIndex];
80
+ const wasInsideRange = activeRange !== undefined;
81
+
82
+ const opensRange =
83
+ activeRange === undefined &&
84
+ nextRange !== undefined &&
85
+ nextRange.start < rawEnd &&
86
+ nextRange.end > rawStart;
87
+
88
+ if (opensRange) activeRange = nextRange;
89
+ const closesRange = activeRange !== undefined && activeRange.end <= rawEnd;
90
+ const decorated = `${opensRange ? "»" : ""}${rendered}${closesRange ? "«" : ""}`;
91
+
92
+ if (rendered === "\n") {
93
+ if (lineCount >= MAX_DISPLAY_COMMAND_LINES) return truncate(wasInsideRange);
94
+ lineCount += 1;
95
+ }
96
+
97
+ if (output.length + decorated.length > MAX_DISPLAY_COMMAND_CHARACTERS) {
98
+ return truncate(wasInsideRange);
99
+ }
100
+
101
+ output += decorated;
102
+
103
+ if (closesRange) {
104
+ activeRange = undefined;
105
+ rangeIndex += 1;
106
+ }
107
+
108
+ offset = rawEnd;
109
+ }
110
+
111
+ return output;
112
+ }
113
+
114
+ /**
115
+ * Produces bounded terminal-safe text for a command without changing the command that is executed.
116
+ */
117
+ export function formatCommandForDisplay(command: string): string {
118
+ return renderCommandForDisplay(command);
119
+ }
120
+
121
+ /** Marks raw matched-rule occurrences while producing terminal-safe command text. */
122
+ export function highlightRuleForDisplay(command: string, pattern: string): string {
123
+ return renderCommandForDisplay(command, pattern);
124
+ }
125
+
126
+ /** Indents every line of terminal-safe command text for the confirmation dialog. */
127
+ export function formatPromptCommand(command: string, pattern: string): string {
128
+ return highlightRuleForDisplay(command, pattern)
129
+ .split("\n")
130
+ .map((line) => ` ${line}`)
131
+ .join("\n");
132
+ }
133
+
134
+ /**
135
+ * Formats a rule match as a terminal-safe, human-readable string.
136
+ *
137
+ * @param match - The rule match to format
138
+ * @returns A string representation showing the pattern and action
139
+ */
140
+ export function formatRule(match: RuleMatch): string {
141
+ return formatCommandForDisplay(
142
+ `${JSON.stringify(match.pattern)}: ${JSON.stringify(match.action)}`,
143
+ );
144
+ }
package/src/index.ts CHANGED
@@ -26,335 +26,74 @@
26
26
  * indefinitely.
27
27
  */
28
28
 
29
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
30
- import { homedir } from "node:os";
31
- import { join } from "node:path";
32
29
  import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
+ import { configPath, ensureConfig, loadConfigResult } from "./config";
31
+ import { resolveRule } from "./rules";
32
+ import { formatPromptCommand, formatRule } from "./display";
33
33
 
34
- export type Action = "prompt" | "block" | "allow";
34
+ const HERDR_BLOCKED_LABEL = "pi-gate: approval required";
35
35
 
36
- export interface GateConfig {
37
- operations: Record<string, Action>;
38
- promptTimeoutMs: number;
39
- }
40
-
41
- export interface RuleMatch {
42
- pattern: string;
43
- action: Action;
44
- }
45
-
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";
49
- const ACTIONS: readonly Action[] = ["prompt", "block", "allow"] as const;
50
- export const DEFAULT_PROMPT_TIMEOUT_MS = 30_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
- ]);
60
-
61
- const DEFAULT_CONFIG: GateConfig = {
62
- promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS,
63
- operations: {
64
- "rm -rf": "prompt",
65
- sudo: "prompt",
66
- "sudo apt update": "allow",
67
- "chmod 777": "block",
68
- "corepack enable": "block",
69
- },
70
- };
71
-
72
- /**
73
- * Returns the directory where pi-gate configuration files are stored.
74
- *
75
- * @returns The absolute path to the agent configuration directory
76
- */
77
- function agentDir(): string {
78
- return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
79
- }
80
-
81
- /**
82
- * Returns the full path to the pi-gate configuration file.
83
- *
84
- * @returns The absolute path to pi-gate.json
85
- */
86
- function configPath(): string {
87
- return join(agentDir(), CONFIG_FILE_NAME);
88
- }
89
-
90
- /**
91
- * Type guard that checks if an unknown value is a valid Action.
92
- *
93
- * @param value - The value to check
94
- * @returns `true` if the value is one of "prompt", "block", or "allow"
95
- */
96
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- type-guard helper intentionally narrows untrusted JSON values at the parse boundary
97
- function isAction(value: unknown): value is Action {
98
- return (
99
- typeof value === "string" &&
100
- // SAFETY: ACTIONS is the source-of-truth tuple of supported action strings; widening
101
- // it to readonly string[] lets Array.prototype.includes perform a runtime check
102
- // before the type predicate narrows the value to Action.
103
- (ACTIONS as readonly string[]).includes(value)
104
- );
105
- }
106
-
107
- /**
108
- * Type guard that checks if an unknown value is a plain JSON object.
109
- *
110
- * @param value - The value to check
111
- * @returns `true` if the value is a non-null object and not an array
112
- */
113
- // 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
114
- function isJsonObject(value: unknown): value is Record<string, unknown> {
115
- return typeof value === "object" && value !== null && !Array.isArray(value);
116
- }
117
-
118
- /**
119
- * Parses the user configuration file. Returns an empty configuration (no
120
- * rules) when the file is missing, unreadable, malformed, or fails
121
- * validation; an explicit empty `operations` object is also valid.
122
- *
123
- * Invalid entries are skipped so a single bad rule does not disable the
124
- * entire gate; parsing continues for the remaining rules.
125
- */
126
- export function parseConfig(content: string): GateConfig {
127
- const rules: Record<string, Action> = {};
128
- let acceptedRuleCount = 0;
129
- let parsed: unknown;
130
- try {
131
- parsed = JSON.parse(content);
132
- } catch {
133
- return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
134
- }
135
- if (!isJsonObject(parsed)) {
136
- return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
137
- }
138
- const configuredTimeout = parsed["promptTimeoutMs"];
139
- const promptTimeoutMs =
140
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validate the optional numeric field at the untrusted JSON boundary before applying range checks
141
- typeof configuredTimeout === "number" &&
142
- Number.isInteger(configuredTimeout) &&
143
- configuredTimeout > 0 &&
144
- configuredTimeout <= MAX_PROMPT_TIMEOUT_MS
145
- ? configuredTimeout
146
- : DEFAULT_PROMPT_TIMEOUT_MS;
147
- const operations = parsed["operations"];
148
- if (!isJsonObject(operations)) {
149
- return { operations: rules, promptTimeoutMs };
150
- }
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;
154
- if (isAction(action)) {
155
- rules[pattern] = action;
156
- acceptedRuleCount += 1;
157
- }
158
- }
159
- return { operations: rules, promptTimeoutMs };
160
- }
161
-
162
- /**
163
- * Loads the configuration from disk. Returns an empty configuration when the
164
- * file is missing; returns the parsed configuration otherwise.
165
- */
166
- export function loadConfig(): GateConfig {
167
- const path = configPath();
168
- if (!existsSync(path)) {
169
- return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
170
- }
171
- let content: string;
172
- try {
173
- content = readFileSync(path, "utf-8");
174
- } catch {
175
- return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
176
- }
177
- return parseConfig(content);
178
- }
179
-
180
- /**
181
- * Writes the default configuration on first run. Existing configuration files
182
- * are never overwritten.
183
- */
184
- export function ensureConfig(): void {
185
- const path = configPath();
186
- if (existsSync(path)) return;
187
- try {
188
- mkdirSync(agentDir(), { recursive: true });
189
- } catch {
190
- return;
191
- }
192
- const content = JSON.stringify({ $schema: CONFIG_SCHEMA_URL, ...DEFAULT_CONFIG }, null, 2) + "\n";
193
- try {
194
- writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
195
- } catch {
196
- // Best-effort: another process may have created it, or the warning banner
197
- // will surface that no configuration is active.
198
- }
199
- }
200
-
201
- /**
202
- * Resolves the rule that applies to a command against the configured rules.
203
- *
204
- * Returns `null` when no rule matches, in which case the caller should let the
205
- * command through. When multiple rules match, the longest pattern wins so
206
- * that narrow rules can override broader ones.
207
- *
208
- * @param command - The bash command to evaluate against the rules
209
- * @param rules - A map of pattern strings to actions
210
- * @returns The matched pattern and action, or `null` if no rule matches
211
- */
212
- export function resolveRule(command: string, rules: Record<string, Action>): RuleMatch | null {
213
- let bestPattern: string | null = null;
214
- for (const pattern of Object.keys(rules)) {
215
- if (!command.includes(pattern)) continue;
216
- if (bestPattern === null || pattern.length > bestPattern.length) {
217
- bestPattern = pattern;
218
- }
219
- }
220
- if (bestPattern === null) return null;
221
- return { pattern: bestPattern, action: rules[bestPattern] };
222
- }
223
-
224
- /** Returns only the action selected by {@link resolveRule}. */
225
- export function resolveAction(command: string, rules: Record<string, Action>): Action | null {
226
- return resolveRule(command, rules)?.action ?? null;
227
- }
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 "";
36
+ function reportHerdrBlocked(pi: ExtensionAPI, ctx: { mode?: string }, active: boolean): void {
233
37
  if (
234
- codePoint < 0x20 ||
235
- (codePoint >= 0x7f && codePoint <= 0x9f) ||
236
- BIDI_CONTROL_CODE_POINTS.has(codePoint)
38
+ ctx.mode !== "tui" ||
39
+ process.env.HERDR_ENV !== "1" ||
40
+ !process.env.HERDR_SOCKET_PATH ||
41
+ !process.env.HERDR_PANE_ID
237
42
  ) {
238
- return `\\u{${codePoint.toString(16).padStart(4, "0")}}`;
43
+ return;
239
44
  }
240
- return character;
241
- }
242
45
 
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;
46
+ // The Herdr integration consumes this event and reports it through its
47
+ // existing socket connection. Keep this optional integration best-effort so
48
+ // a missing or incompatible Herdr extension cannot affect command gating.
49
+ try {
50
+ pi.events.emit("herdr:blocked", {
51
+ active,
52
+ label: HERDR_BLOCKED_LABEL,
53
+ });
54
+ } catch {
55
+ // Herdr is an optional host integration.
258
56
  }
259
- return ranges;
260
57
  }
261
58
 
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;
59
+ export type { Action, GateConfig } from "./config";
270
60
 
271
- const truncate = (closeActiveRange = activeRange !== undefined): string =>
272
- output + (closeActiveRange ? "«" : "") + DISPLAY_TRUNCATION_MARKER;
61
+ export {
62
+ CONFIG_SCHEMA_URL,
63
+ DEFAULT_PROMPT_TIMEOUT_MS,
64
+ MAX_PROMPT_TIMEOUT_MS,
65
+ MAX_RULE_COUNT,
66
+ MAX_RULE_PATTERN_LENGTH,
67
+ ensureConfig,
68
+ loadConfig,
69
+ loadConfigResult,
70
+ parseConfig,
71
+ } from "./config";
273
72
 
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
- }
73
+ export type { ConfigLoadResult, ConfigLoadStatus } from "./config";
287
74
 
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 ? "«" : ""}`;
75
+ export type { RuleMatch } from "./rules";
298
76
 
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
- }
77
+ export { resolveAction, resolveRule } from "./rules";
306
78
 
307
- output += decorated;
308
- if (closesRange) {
309
- activeRange = undefined;
310
- rangeIndex += 1;
311
- }
312
- offset = rawEnd;
313
- }
314
- return output;
315
- }
316
-
317
- /**
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.
339
- *
340
- * @param match - The rule match to format
341
- * @returns A string representation showing the pattern and action
342
- */
343
- function formatRule(match: RuleMatch): string {
344
- return formatCommandForDisplay(
345
- `${JSON.stringify(match.pattern)}: ${JSON.stringify(match.action)}`,
346
- );
347
- }
79
+ export {
80
+ MAX_DISPLAY_COMMAND_CHARACTERS,
81
+ MAX_DISPLAY_COMMAND_LINES,
82
+ formatCommandForDisplay,
83
+ highlightRuleForDisplay,
84
+ } from "./display";
348
85
 
349
86
  /** Gate the built-in `bash` tool against the user-provided rules. */
350
87
  export default function piGate(pi: ExtensionAPI): void {
351
88
  ensureConfig();
352
- const config = loadConfig();
89
+ let loadResult = loadConfigResult();
90
+ const { config } = loadResult;
353
91
 
354
92
  pi.on("session_start", (_event, ctx) => {
355
93
  const path = configPath();
356
94
  const ruleCount = Object.keys(config.operations).length;
357
- if (existsSync(path)) {
95
+
96
+ if (loadResult.status === "loaded") {
358
97
  if (ruleCount === 0) {
359
98
  ctx.ui.notify(
360
99
  `pi-gate: ${path} is empty; no commands are gated. Add rules to the "operations" object to start gating.`,
@@ -366,9 +105,14 @@ export default function piGate(pi: ExtensionAPI): void {
366
105
  "info",
367
106
  );
368
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
+ );
369
113
  } else {
370
114
  ctx.ui.notify(
371
- `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.`,
372
116
  "warning",
373
117
  );
374
118
  }
@@ -376,17 +120,29 @@ export default function piGate(pi: ExtensionAPI): void {
376
120
 
377
121
  pi.on("tool_call", async (event, ctx) => {
378
122
  if (event.toolName !== "bash") return undefined;
123
+
379
124
  if (!isToolCallEventType("bash", event)) return undefined;
380
125
  const command = event.input.command;
381
- 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
+
382
136
  if (match === null || match.action === "allow") return undefined;
383
137
  const rule = formatRule(match);
384
138
 
385
139
  if (match.action === "block") {
386
140
  const reason = `pi-gate: command blocked by rule ${rule}`;
141
+
387
142
  if (ctx.hasUI) {
388
143
  ctx.ui.notify(reason, "warning");
389
144
  }
145
+
390
146
  return { block: true, reason, terminate: true };
391
147
  }
392
148
 
@@ -398,11 +154,20 @@ export default function piGate(pi: ExtensionAPI): void {
398
154
  terminate: true,
399
155
  };
400
156
  }
401
- const choice = await ctx.ui.select(
402
- `pi-gate: allow this command?\n\n${formatPromptCommand(command, match.pattern)}\n\nMatched rule: ${rule}\nMatched command text is wrapped in »…«`,
403
- ["Allow", "Deny"],
404
- { timeout: config.promptTimeoutMs },
405
- );
157
+
158
+ reportHerdrBlocked(pi, ctx, true);
159
+ let choice: string | undefined;
160
+
161
+ try {
162
+ choice = await ctx.ui.select(
163
+ `pi-gate: allow this command?\n\n${formatPromptCommand(command, match.pattern)}\n\nMatched rule: ${rule}\nMatched command text is wrapped in »…«`,
164
+ ["Allow", "Deny"],
165
+ { timeout: loadResult.config.promptTimeoutMs },
166
+ );
167
+ } finally {
168
+ reportHerdrBlocked(pi, ctx, false);
169
+ }
170
+
406
171
  if (choice !== "Allow") {
407
172
  return {
408
173
  block: true,
@@ -410,6 +175,7 @@ export default function piGate(pi: ExtensionAPI): void {
410
175
  terminate: true,
411
176
  };
412
177
  }
178
+
413
179
  return undefined;
414
180
  });
415
181
  }
package/src/rules.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { Action } from "./config";
2
+
3
+ export interface RuleMatch {
4
+ pattern: string;
5
+ action: Action;
6
+ }
7
+
8
+ /**
9
+ * Resolves the rule that applies to a command against the configured rules.
10
+ *
11
+ * Returns `null` when no rule matches, in which case the caller should let the
12
+ * command through. When multiple rules match, the longest pattern wins so
13
+ * that narrow rules can override broader ones.
14
+ *
15
+ * @param command - The bash command to evaluate against the rules
16
+ * @param rules - A map of pattern strings to actions
17
+ * @returns The matched pattern and action, or `null` if no rule matches
18
+ */
19
+ export function resolveRule(command: string, rules: Record<string, Action>): RuleMatch | null {
20
+ let bestPattern: string | null = null;
21
+
22
+ for (const pattern of Object.keys(rules)) {
23
+ if (!command.includes(pattern)) continue;
24
+
25
+ if (bestPattern === null || pattern.length > bestPattern.length) {
26
+ bestPattern = pattern;
27
+ }
28
+ }
29
+
30
+ if (bestPattern === null) return null;
31
+
32
+ return { pattern: bestPattern, action: rules[bestPattern] };
33
+ }
34
+
35
+ /** Returns only the action selected by {@link resolveRule}. */
36
+ export function resolveAction(command: string, rules: Record<string, Action>): Action | null {
37
+ return resolveRule(command, rules)?.action ?? null;
38
+ }