@zeldrisho/pi-gate 0.2.0 → 0.4.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,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-09-12
11
+
12
+ ### Changed
13
+
14
+ - report interactive approval prompts to Herdr when running inside a Herdr-managed TUI pane, while remaining a no-op for other users
15
+
16
+ ## [0.3.0] - 2026-09-07
17
+
18
+ ### Added
19
+
20
+ - ship a JSON Schema for `pi-gate.json` and reference it from newly seeded configurations for editor completion and validation
21
+
22
+ ### Changed
23
+
24
+ - wrap visible matched-rule occurrences in confirmation-dialog command text so guarded operations are easier to identify
25
+ - escape terminal and bidirectional control characters and bound long or multiline command text in confirmation dialogs without changing the command executed
26
+ - ignore empty or oversized rule patterns and load at most 1,000 valid rules
27
+ - document and test RPC-host confirmation behavior
28
+
10
29
  ## [0.2.0] - 2026-09-02
11
30
 
12
31
  ### Changed
@@ -31,7 +50,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
31
50
  - support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
32
51
  - block rather than auto-approve in non-UI modes
33
52
 
34
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.2.0...HEAD
53
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.0...HEAD
54
+ [0.4.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.3.0...pi-gate-v0.4.0
55
+ [0.3.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.2.0...pi-gate-v0.3.0
35
56
  [0.2.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.1...pi-gate-v0.2.0
36
57
  [0.1.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.0...pi-gate-v0.1.1
37
58
  [0.1.0]: https://github.com/zeldrisho/pi-packages/releases/tag/pi-gate-v0.1.0
package/README.md CHANGED
@@ -1,80 +1,51 @@
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 and starter rules. Existing configuration files are never overwritten.
20
-
21
- 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
-
23
- - `prompt` — ask the user to allow or deny the command
24
- - `block` — deny the command without asking
25
- - `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. The published package includes [`config.schema.json`](config.schema.json); use this schema URL for editor completion:
26
16
 
27
17
  ```json
28
18
  {
19
+ "$schema": "https://raw.githubusercontent.com/zeldrisho/pi-packages/main/packages/pi-gate/config.schema.json",
29
20
  "promptTimeoutMs": 30000,
30
21
  "operations": {
31
22
  "rm -rf": "prompt",
32
23
  "sudo": "prompt",
33
24
  "sudo apt update": "allow",
34
- "chmod 777": "block",
35
- "corepack enable": "block"
25
+ "chmod 777": "block"
36
26
  }
37
27
  }
38
28
  ```
39
29
 
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.
41
-
42
- After editing, run `/reload` to apply the new rules in the current session.
43
-
44
- ## Behavior
45
-
46
- - 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.
48
- - `block` rules never ask and always deny. The warning identifies the matched rule.
49
- - `allow` rules are explicit pass-throughs, useful for carving out exceptions.
50
- - 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.
30
+ Rules use substring matching and have one of three actions:
52
31
 
53
- ### What the agent sees
32
+ - `prompt`: ask the user to allow or deny;
33
+ - `block`: deny without asking;
34
+ - `allow`: explicitly permit an exception to a broader match.
54
35
 
55
- The confirmation dialog and your choice are not sent directly to the agent.
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.
56
37
 
57
- - If you allow a `prompt` rule, the command runs and the agent receives its normal bash result.
58
- - If you deny or dismiss a `prompt`, the agent receives an error result naming the matched rule and saying the user denied it.
59
- - If a `block` rule matches, the agent receives an error result naming that rule.
60
- - If a `prompt` rule matches without an available UI, the agent receives an error explaining that the matched rule required a prompt.
38
+ ## Behavior
61
39
 
62
- 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.
63
41
 
64
- ```text
65
- pi-gate: command blocked by rule "sudo": "block"
66
- ```
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.
67
43
 
68
44
  ## Uninstall
69
45
 
70
46
  ```bash
71
47
  pi remove npm:@zeldrisho/pi-gate
72
- ```
73
-
74
- For a project-local installation:
75
-
76
- ```bash
77
- pi remove -l npm:@zeldrisho/pi-gate
48
+ pi remove -l npm:@zeldrisho/pi-gate # project-local
78
49
  ```
79
50
 
80
51
  ## License
@@ -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.4.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/config.ts ADDED
@@ -0,0 +1,159 @@
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
+ const CONFIG_FILE_NAME = "pi-gate.json";
13
+ export const CONFIG_SCHEMA_URL =
14
+ "https://raw.githubusercontent.com/zeldrisho/pi-packages/main/packages/pi-gate/config.schema.json";
15
+ const ACTIONS: readonly Action[] = ["prompt", "block", "allow"] as const;
16
+ export const DEFAULT_PROMPT_TIMEOUT_MS = 30_000;
17
+ export const MAX_RULE_COUNT = 1_000;
18
+ export const MAX_RULE_PATTERN_LENGTH = 1_024;
19
+ export const MAX_PROMPT_TIMEOUT_MS = 86_400_000;
20
+
21
+ const DEFAULT_CONFIG: GateConfig = {
22
+ promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS,
23
+ operations: {
24
+ "rm -rf": "prompt",
25
+ sudo: "prompt",
26
+ "sudo apt update": "allow",
27
+ "chmod 777": "block",
28
+ "corepack enable": "block",
29
+ },
30
+ };
31
+
32
+ /**
33
+ * Returns the directory where pi-gate configuration files are stored.
34
+ *
35
+ * @returns The absolute path to the agent configuration directory
36
+ */
37
+ function agentDir(): string {
38
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
39
+ }
40
+
41
+ /**
42
+ * Returns the full path to the pi-gate configuration file.
43
+ *
44
+ * @returns The absolute path to pi-gate.json
45
+ */
46
+ export function configPath(): string {
47
+ return join(agentDir(), CONFIG_FILE_NAME);
48
+ }
49
+
50
+ /**
51
+ * Type guard that checks if an unknown value is a valid Action.
52
+ *
53
+ * @param value - The value to check
54
+ * @returns `true` if the value is one of "prompt", "block", or "allow"
55
+ */
56
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- type-guard helper intentionally narrows untrusted JSON values at the parse boundary
57
+ function isAction(value: unknown): value is Action {
58
+ return (
59
+ typeof value === "string" &&
60
+ // SAFETY: ACTIONS is the source-of-truth tuple of supported action strings; widening
61
+ // it to readonly string[] lets Array.prototype.includes perform a runtime check
62
+ // before the type predicate narrows the value to Action.
63
+ (ACTIONS as readonly string[]).includes(value)
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Type guard that checks if an unknown value is a plain JSON object.
69
+ *
70
+ * @param value - The value to check
71
+ * @returns `true` if the value is a non-null object and not an array
72
+ */
73
+ // 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
74
+ function isJsonObject(value: unknown): value is Record<string, unknown> {
75
+ return typeof value === "object" && value !== null && !Array.isArray(value);
76
+ }
77
+
78
+ /**
79
+ * Parses the user configuration file. Returns an empty configuration (no
80
+ * rules) when the file is missing, unreadable, malformed, or fails
81
+ * validation; an explicit empty `operations` object is also valid.
82
+ *
83
+ * Invalid entries are skipped so a single bad rule does not disable the
84
+ * entire gate; parsing continues for the remaining rules.
85
+ */
86
+ export function parseConfig(content: string): GateConfig {
87
+ const rules: Record<string, Action> = {};
88
+ let acceptedRuleCount = 0;
89
+ let parsed: unknown;
90
+ try {
91
+ parsed = JSON.parse(content);
92
+ } catch {
93
+ return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
94
+ }
95
+ if (!isJsonObject(parsed)) {
96
+ return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
97
+ }
98
+ const configuredTimeout = parsed["promptTimeoutMs"];
99
+ const promptTimeoutMs =
100
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validate the optional numeric field at the untrusted JSON boundary before applying range checks
101
+ typeof configuredTimeout === "number" &&
102
+ Number.isInteger(configuredTimeout) &&
103
+ configuredTimeout > 0 &&
104
+ configuredTimeout <= MAX_PROMPT_TIMEOUT_MS
105
+ ? configuredTimeout
106
+ : DEFAULT_PROMPT_TIMEOUT_MS;
107
+ const operations = parsed["operations"];
108
+ if (!isJsonObject(operations)) {
109
+ return { operations: rules, promptTimeoutMs };
110
+ }
111
+ for (const [pattern, action] of Object.entries(operations)) {
112
+ if (acceptedRuleCount >= MAX_RULE_COUNT) break;
113
+ if (pattern.length === 0 || pattern.length > MAX_RULE_PATTERN_LENGTH) continue;
114
+ if (isAction(action)) {
115
+ rules[pattern] = action;
116
+ acceptedRuleCount += 1;
117
+ }
118
+ }
119
+ return { operations: rules, promptTimeoutMs };
120
+ }
121
+
122
+ /**
123
+ * Loads the configuration from disk. Returns an empty configuration when the
124
+ * file is missing; returns the parsed configuration otherwise.
125
+ */
126
+ export function loadConfig(): GateConfig {
127
+ const path = configPath();
128
+ if (!existsSync(path)) {
129
+ return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
130
+ }
131
+ let content: string;
132
+ try {
133
+ content = readFileSync(path, "utf-8");
134
+ } catch {
135
+ return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
136
+ }
137
+ return parseConfig(content);
138
+ }
139
+
140
+ /**
141
+ * Writes the default configuration on first run. Existing configuration files
142
+ * are never overwritten.
143
+ */
144
+ export function ensureConfig(): void {
145
+ const path = configPath();
146
+ if (existsSync(path)) return;
147
+ try {
148
+ mkdirSync(agentDir(), { recursive: true });
149
+ } catch {
150
+ return;
151
+ }
152
+ const content = JSON.stringify({ $schema: CONFIG_SCHEMA_URL, ...DEFAULT_CONFIG }, null, 2) + "\n";
153
+ try {
154
+ writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
155
+ } catch {
156
+ // Best-effort: another process may have created it, or the warning banner
157
+ // will surface that no configuration is active.
158
+ }
159
+ }
package/src/display.ts ADDED
@@ -0,0 +1,128 @@
1
+ import type { RuleMatch } from "./rules";
2
+
3
+ export const MAX_DISPLAY_COMMAND_CHARACTERS = 2_000;
4
+ export const MAX_DISPLAY_COMMAND_LINES = 20;
5
+ const DISPLAY_TRUNCATION_MARKER = "\n … [command display truncated]";
6
+ const BIDI_CONTROL_CODE_POINTS = new Set([
7
+ 0x061c, 0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069,
8
+ ]);
9
+
10
+ /** Converts terminal controls and bidirectional formatting characters to visible escapes. */
11
+ function escapeUnsafeDisplayCharacter(character: string): string {
12
+ const codePoint = character.codePointAt(0);
13
+ if (codePoint === undefined) return "";
14
+ if (
15
+ codePoint < 0x20 ||
16
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
17
+ BIDI_CONTROL_CODE_POINTS.has(codePoint)
18
+ ) {
19
+ return `\\u{${codePoint.toString(16).padStart(4, "0")}}`;
20
+ }
21
+ return character;
22
+ }
23
+
24
+ interface MatchRange {
25
+ start: number;
26
+ end: number;
27
+ }
28
+
29
+ /** Finds non-overlapping raw match ranges before any display escaping occurs. */
30
+ function findMatchRanges(command: string, pattern?: string): MatchRange[] {
31
+ if (!pattern) return [];
32
+ const ranges: MatchRange[] = [];
33
+ let offset = 0;
34
+ while (offset <= command.length - pattern.length) {
35
+ const start = command.indexOf(pattern, offset);
36
+ if (start === -1) break;
37
+ ranges.push({ start, end: start + pattern.length });
38
+ offset = start + pattern.length;
39
+ }
40
+ return ranges;
41
+ }
42
+
43
+ /** Produces bounded terminal-safe text, optionally marking raw matched spans. */
44
+ function renderCommandForDisplay(command: string, pattern?: string): string {
45
+ const ranges = findMatchRanges(command, pattern);
46
+ let rangeIndex = 0;
47
+ let activeRange: MatchRange | undefined;
48
+ let output = "";
49
+ let lineCount = 1;
50
+ let offset = 0;
51
+
52
+ const truncate = (closeActiveRange = activeRange !== undefined): string =>
53
+ output + (closeActiveRange ? "«" : "") + DISPLAY_TRUNCATION_MARKER;
54
+
55
+ while (offset < command.length) {
56
+ const rawStart = offset;
57
+ let rawEnd: number;
58
+ let rendered: string;
59
+ const codePoint = command.codePointAt(offset);
60
+ if (codePoint === 0x0d) {
61
+ rawEnd = offset + (command.codePointAt(offset + 1) === 0x0a ? 2 : 1);
62
+ rendered = "\n";
63
+ } else {
64
+ const character = String.fromCodePoint(codePoint!);
65
+ rawEnd = offset + character.length;
66
+ rendered = character === "\n" ? character : escapeUnsafeDisplayCharacter(character);
67
+ }
68
+
69
+ const nextRange = ranges[rangeIndex];
70
+ const wasInsideRange = activeRange !== undefined;
71
+ const opensRange =
72
+ activeRange === undefined &&
73
+ nextRange !== undefined &&
74
+ nextRange.start < rawEnd &&
75
+ nextRange.end > rawStart;
76
+ if (opensRange) activeRange = nextRange;
77
+ const closesRange = activeRange !== undefined && activeRange.end <= rawEnd;
78
+ const decorated = `${opensRange ? "»" : ""}${rendered}${closesRange ? "«" : ""}`;
79
+
80
+ if (rendered === "\n") {
81
+ if (lineCount >= MAX_DISPLAY_COMMAND_LINES) return truncate(wasInsideRange);
82
+ lineCount += 1;
83
+ }
84
+ if (output.length + decorated.length > MAX_DISPLAY_COMMAND_CHARACTERS) {
85
+ return truncate(wasInsideRange);
86
+ }
87
+
88
+ output += decorated;
89
+ if (closesRange) {
90
+ activeRange = undefined;
91
+ rangeIndex += 1;
92
+ }
93
+ offset = rawEnd;
94
+ }
95
+ return output;
96
+ }
97
+
98
+ /**
99
+ * Produces bounded terminal-safe text for a command without changing the command that is executed.
100
+ */
101
+ export function formatCommandForDisplay(command: string): string {
102
+ return renderCommandForDisplay(command);
103
+ }
104
+
105
+ /** Marks raw matched-rule occurrences while producing terminal-safe command text. */
106
+ export function highlightRuleForDisplay(command: string, pattern: string): string {
107
+ return renderCommandForDisplay(command, pattern);
108
+ }
109
+
110
+ /** Indents every line of terminal-safe command text for the confirmation dialog. */
111
+ export function formatPromptCommand(command: string, pattern: string): string {
112
+ return highlightRuleForDisplay(command, pattern)
113
+ .split("\n")
114
+ .map((line) => ` ${line}`)
115
+ .join("\n");
116
+ }
117
+
118
+ /**
119
+ * Formats a rule match as a terminal-safe, human-readable string.
120
+ *
121
+ * @param match - The rule match to format
122
+ * @returns A string representation showing the pattern and action
123
+ */
124
+ export function formatRule(match: RuleMatch): string {
125
+ return formatCommandForDisplay(
126
+ `${JSON.stringify(match.pattern)}: ${JSON.stringify(match.action)}`,
127
+ );
128
+ }
package/src/index.ts CHANGED
@@ -26,201 +26,56 @@
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";
29
+ import { existsSync } from "node:fs";
32
30
  import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
33
-
34
- export type Action = "prompt" | "block" | "allow";
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
- const ACTIONS: readonly Action[] = ["prompt", "block", "allow"] as const;
48
- export const DEFAULT_PROMPT_TIMEOUT_MS = 30_000;
49
- const MAX_PROMPT_TIMEOUT_MS = 86_400_000;
50
-
51
- const DEFAULT_CONFIG: GateConfig = {
52
- promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS,
53
- operations: {
54
- "rm -rf": "prompt",
55
- sudo: "prompt",
56
- "sudo apt update": "allow",
57
- "chmod 777": "block",
58
- "corepack enable": "block",
59
- },
60
- };
61
-
62
- /**
63
- * Returns the directory where pi-gate configuration files are stored.
64
- *
65
- * @returns The absolute path to the agent configuration directory
66
- */
67
- function agentDir(): string {
68
- return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
69
- }
70
-
71
- /**
72
- * Returns the full path to the pi-gate configuration file.
73
- *
74
- * @returns The absolute path to pi-gate.json
75
- */
76
- function configPath(): string {
77
- return join(agentDir(), CONFIG_FILE_NAME);
78
- }
79
-
80
- /**
81
- * Type guard that checks if an unknown value is a valid Action.
82
- *
83
- * @param value - The value to check
84
- * @returns `true` if the value is one of "prompt", "block", or "allow"
85
- */
86
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- type-guard helper intentionally narrows untrusted JSON values at the parse boundary
87
- function isAction(value: unknown): value is Action {
88
- return (
89
- typeof value === "string" &&
90
- // SAFETY: ACTIONS is the source-of-truth tuple of supported action strings; widening
91
- // it to readonly string[] lets Array.prototype.includes perform a runtime check
92
- // before the type predicate narrows the value to Action.
93
- (ACTIONS as readonly string[]).includes(value)
94
- );
95
- }
96
-
97
- /**
98
- * Type guard that checks if an unknown value is a plain JSON object.
99
- *
100
- * @param value - The value to check
101
- * @returns `true` if the value is a non-null object and not an array
102
- */
103
- // 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
104
- function isJsonObject(value: unknown): value is Record<string, unknown> {
105
- return typeof value === "object" && value !== null && !Array.isArray(value);
106
- }
107
-
108
- /**
109
- * Parses the user configuration file. Returns an empty configuration (no
110
- * rules) when the file is missing, unreadable, malformed, or fails
111
- * validation; an explicit empty `operations` object is also valid.
112
- *
113
- * Invalid entries are skipped so a single bad rule does not disable the
114
- * entire gate; parsing continues for the remaining rules.
115
- */
116
- export function parseConfig(content: string): GateConfig {
117
- const rules: Record<string, Action> = {};
118
- let parsed: unknown;
119
- try {
120
- parsed = JSON.parse(content);
121
- } catch {
122
- return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
123
- }
124
- if (!isJsonObject(parsed)) {
125
- return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
126
- }
127
- const configuredTimeout = parsed["promptTimeoutMs"];
128
- const promptTimeoutMs =
129
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validate the optional numeric field at the untrusted JSON boundary before applying range checks
130
- typeof configuredTimeout === "number" &&
131
- Number.isInteger(configuredTimeout) &&
132
- configuredTimeout > 0 &&
133
- configuredTimeout <= MAX_PROMPT_TIMEOUT_MS
134
- ? configuredTimeout
135
- : DEFAULT_PROMPT_TIMEOUT_MS;
136
- const operations = parsed["operations"];
137
- if (!isJsonObject(operations)) {
138
- return { operations: rules, promptTimeoutMs };
139
- }
140
- for (const [pattern, action] of Object.entries(operations)) {
141
- if (isAction(action)) {
142
- rules[pattern] = action;
143
- }
144
- }
145
- return { operations: rules, promptTimeoutMs };
146
- }
147
-
148
- /**
149
- * Loads the configuration from disk. Returns an empty configuration when the
150
- * file is missing; returns the parsed configuration otherwise.
151
- */
152
- export function loadConfig(): GateConfig {
153
- const path = configPath();
154
- if (!existsSync(path)) {
155
- return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
156
- }
157
- let content: string;
158
- try {
159
- content = readFileSync(path, "utf-8");
160
- } catch {
161
- return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
162
- }
163
- return parseConfig(content);
164
- }
165
-
166
- /**
167
- * Writes the default configuration on first run. Existing configuration files
168
- * are never overwritten.
169
- */
170
- export function ensureConfig(): void {
171
- const path = configPath();
172
- if (existsSync(path)) return;
173
- try {
174
- mkdirSync(agentDir(), { recursive: true });
175
- } catch {
31
+ import { configPath, ensureConfig, loadConfig } from "./config";
32
+ import { resolveRule } from "./rules";
33
+ import { formatPromptCommand, formatRule } from "./display";
34
+
35
+ const HERDR_BLOCKED_LABEL = "pi-gate: approval required";
36
+
37
+ function reportHerdrBlocked(pi: ExtensionAPI, ctx: { mode?: string }, active: boolean): void {
38
+ if (
39
+ ctx.mode !== "tui" ||
40
+ process.env.HERDR_ENV !== "1" ||
41
+ !process.env.HERDR_SOCKET_PATH ||
42
+ !process.env.HERDR_PANE_ID
43
+ ) {
176
44
  return;
177
45
  }
178
- const content = JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n";
46
+
47
+ // The Herdr integration consumes this event and reports it through its
48
+ // existing socket connection. Keep this optional integration best-effort so
49
+ // a missing or incompatible Herdr extension cannot affect command gating.
179
50
  try {
180
- writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
51
+ pi.events.emit("herdr:blocked", {
52
+ active,
53
+ label: HERDR_BLOCKED_LABEL,
54
+ });
181
55
  } catch {
182
- // Best-effort: another process may have created it, or the warning banner
183
- // will surface that no configuration is active.
56
+ // Herdr is an optional host integration.
184
57
  }
185
58
  }
186
59
 
187
- /**
188
- * Resolves the rule that applies to a command against the configured rules.
189
- *
190
- * Returns `null` when no rule matches, in which case the caller should let the
191
- * command through. When multiple rules match, the longest pattern wins so
192
- * that narrow rules can override broader ones.
193
- *
194
- * @param command - The bash command to evaluate against the rules
195
- * @param rules - A map of pattern strings to actions
196
- * @returns The matched pattern and action, or `null` if no rule matches
197
- */
198
- export function resolveRule(command: string, rules: Record<string, Action>): RuleMatch | null {
199
- let bestPattern: string | null = null;
200
- for (const pattern of Object.keys(rules)) {
201
- if (!command.includes(pattern)) continue;
202
- if (bestPattern === null || pattern.length > bestPattern.length) {
203
- bestPattern = pattern;
204
- }
205
- }
206
- if (bestPattern === null) return null;
207
- return { pattern: bestPattern, action: rules[bestPattern] };
208
- }
209
-
210
- /** Returns only the action selected by {@link resolveRule}. */
211
- export function resolveAction(command: string, rules: Record<string, Action>): Action | null {
212
- return resolveRule(command, rules)?.action ?? null;
213
- }
214
-
215
- /**
216
- * Formats a rule match as a human-readable string for display in prompts and notifications.
217
- *
218
- * @param match - The rule match to format
219
- * @returns A string representation showing the pattern and action
220
- */
221
- function formatRule(match: RuleMatch): string {
222
- return `${JSON.stringify(match.pattern)}: ${JSON.stringify(match.action)}`;
223
- }
60
+ export type { Action, GateConfig } from "./config";
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
+ parseConfig,
70
+ } from "./config";
71
+ export type { RuleMatch } from "./rules";
72
+ export { resolveAction, resolveRule } from "./rules";
73
+ export {
74
+ MAX_DISPLAY_COMMAND_CHARACTERS,
75
+ MAX_DISPLAY_COMMAND_LINES,
76
+ formatCommandForDisplay,
77
+ highlightRuleForDisplay,
78
+ } from "./display";
224
79
 
225
80
  /** Gate the built-in `bash` tool against the user-provided rules. */
226
81
  export default function piGate(pi: ExtensionAPI): void {
@@ -274,11 +129,17 @@ export default function piGate(pi: ExtensionAPI): void {
274
129
  terminate: true,
275
130
  };
276
131
  }
277
- const choice = await ctx.ui.select(
278
- `pi-gate: allow this command?\n\n ${command}\n\nMatched rule: ${rule}`,
279
- ["Allow", "Deny"],
280
- { timeout: config.promptTimeoutMs },
281
- );
132
+ reportHerdrBlocked(pi, ctx, true);
133
+ let choice: string | undefined;
134
+ try {
135
+ choice = await ctx.ui.select(
136
+ `pi-gate: allow this command?\n\n${formatPromptCommand(command, match.pattern)}\n\nMatched rule: ${rule}\nMatched command text is wrapped in »…«`,
137
+ ["Allow", "Deny"],
138
+ { timeout: config.promptTimeoutMs },
139
+ );
140
+ } finally {
141
+ reportHerdrBlocked(pi, ctx, false);
142
+ }
282
143
  if (choice !== "Allow") {
283
144
  return {
284
145
  block: true,
package/src/rules.ts ADDED
@@ -0,0 +1,34 @@
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
+ for (const pattern of Object.keys(rules)) {
22
+ if (!command.includes(pattern)) continue;
23
+ if (bestPattern === null || pattern.length > bestPattern.length) {
24
+ bestPattern = pattern;
25
+ }
26
+ }
27
+ if (bestPattern === null) return null;
28
+ return { pattern: bestPattern, action: rules[bestPattern] };
29
+ }
30
+
31
+ /** Returns only the action selected by {@link resolveRule}. */
32
+ export function resolveAction(command: string, rules: Record<string, Action>): Action | null {
33
+ return resolveRule(command, rules)?.action ?? null;
34
+ }