@zeldrisho/pi-gate 0.4.1 → 0.6.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,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-10-01
11
+
12
+ ### Changed
13
+
14
+ - Match bash config protection against resolved config paths instead of bare filenames, and shorten denial reasons while retaining the matched rule.
15
+ - Give prompt timeouts a distinct denial reason that includes the configured timeout and asks whether to retry.
16
+ - Normalize rule matching and require allow spans to contain the matches they suppress; compound commands previously allowed by a longer allow rule can now prompt or block.
17
+ - Prompt before bash commands and write/edit calls that target gate configuration files.
18
+ - Show the number of hidden characters when command text is truncated in a prompt.
19
+
20
+ ### Security
21
+
22
+ - Escape terminal controls and bound config paths shown in approval prompts.
23
+
24
+ ## [0.5.0] - 2026-09-24
25
+
26
+ ### Changed
27
+
28
+ - 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.
29
+
10
30
  ## [0.4.1] - 2026-09-17
11
31
 
12
32
  ### Security
@@ -56,7 +76,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
56
76
  - support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
57
77
  - block rather than auto-approve in non-UI modes
58
78
 
59
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.1...HEAD
79
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.6.0...HEAD
80
+ [0.6.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.5.0...pi-gate-v0.6.0
81
+ [0.5.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.1...pi-gate-v0.5.0
60
82
  [0.4.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.0...pi-gate-v0.4.1
61
83
  [0.4.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.3.0...pi-gate-v0.4.0
62
84
  [0.3.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.2.0...pi-gate-v0.3.0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zeldrisho/pi-gate
2
2
 
3
- Pi extension that blocks or confirms `bash` tool calls using a user-provided JSON configuration.
3
+ Pi extension that blocks or confirms `bash` tool calls and protects its configuration from `write` and `edit` calls.
4
4
 
5
5
  ## Install
6
6
 
@@ -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. 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:
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
  {
@@ -31,16 +33,20 @@ Rules use substring matching and have one of three actions:
31
33
 
32
34
  - `prompt`: ask the user to allow or deny;
33
35
  - `block`: deny without asking;
34
- - `allow`: explicitly permit an exception to a broader match.
36
+ - `allow`: explicitly permit matches fully contained within its matching text.
37
+
38
+ Empty or over-1,024-character patterns are ignored, at most 1,000 valid rules are loaded, and commands with no match are allowed. Matching is case-insensitive and collapses whitespace runs. An `allow` suppresses only prompt/block matches fully inside its matching span; otherwise any surviving `block` wins over `prompt`. `promptTimeoutMs` defaults to 30 s (maximum one day); a timeout counts as a denial and ends the turn, so raise it for attended sessions. Run `/reload` after editing.
35
39
 
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.
40
+ pi-gate is a guard rail, not a sandbox. Substring matching cannot see flag-order variants, `$(…)`, or commands run through other tools; keep `allow` patterns specific. Bash path checks are textual, so a separate earlier `cd ~/.pi/agent` followed by `echo x > gate.json` may not be recognized.
37
41
 
38
42
  ## Behavior
39
43
 
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.
44
+ The built-in `bash` tool is gated, and `write`/`edit` calls targeting the gate config require approval. 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.
41
45
 
42
46
  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.
43
47
 
48
+ When running in a Herdr-managed TUI pane, pi-gate can report an active approval prompt as `blocked` in Herdr. This is optional: pi-gate works normally without Herdr, and reporting requires Herdr's Pi integration (`herdr integration install pi`). If Herdr or that integration is unavailable, the report is simply ignored.
49
+
44
50
  ## Uninstall
45
51
 
46
52
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-gate",
3
- "version": "0.4.1",
3
+ "version": "0.6.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",
@@ -32,10 +32,10 @@
32
32
  "access": "public"
33
33
  },
34
34
  "devDependencies": {
35
- "@earendil-works/pi-coding-agent": "^0.84.2",
35
+ "@earendil-works/pi-coding-agent": "^0.99.1",
36
36
  "typescript": "^7.0.2",
37
- "vite": "npm:@voidzero-dev/vite-plus-core@0.3.2",
38
- "vite-plus": "0.3.2"
37
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.0.0",
38
+ "vite-plus": "1.0.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@earendil-works/pi-coding-agent": "*"
@@ -0,0 +1,133 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
+
5
+ export const CONFIG_CHANGE_REASON = "pi-gate: gate config change requires approval";
6
+
7
+ const CONFIG_NAMES = ["gate.json", "pi-gate.json"] as const;
8
+
9
+ /** Returns the configured agent directory, defaulting to ~/.pi/agent. */
10
+ function configDirectory(): string {
11
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
12
+ }
13
+
14
+ /** Resolves symlinks in the nearest existing ancestor and preserves any missing path suffix. */
15
+ function canonicalizeWithExistingParent(path: string): string {
16
+ const absolute = resolve(path);
17
+
18
+ if (existsSync(absolute)) return realpathSync(absolute);
19
+
20
+ const tail: string[] = [];
21
+ let parent = absolute;
22
+
23
+ while (!existsSync(parent)) {
24
+ const next = dirname(parent);
25
+
26
+ if (next === parent) return absolute;
27
+ tail.unshift(basename(parent));
28
+ parent = next;
29
+ }
30
+
31
+ return resolve(realpathSync(parent), ...tail);
32
+ }
33
+
34
+ /** Returns canonical paths for both the current and legacy gate configuration files. */
35
+ export function protectedConfigPaths(): string[] {
36
+ const directory = configDirectory();
37
+
38
+ return CONFIG_NAMES.map((name) => canonicalizeWithExistingParent(join(directory, name)));
39
+ }
40
+
41
+ /** Expands a leading ~ or ~/ using the current user's home directory. */
42
+ function expandHome(path: string): string {
43
+ if (path === "~") return homedir();
44
+
45
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2));
46
+
47
+ return path;
48
+ }
49
+
50
+ /**
51
+ * Checks whether a path targets either gate configuration file after home expansion
52
+ * and symlink resolution. Relative paths use the process working directory.
53
+ */
54
+ export function isProtectedConfigPath(inputPath: string): boolean {
55
+ if (inputPath.length === 0) return false;
56
+ const expanded = expandHome(inputPath);
57
+
58
+ const candidate = canonicalizeWithExistingParent(
59
+ isAbsolute(expanded) ? expanded : resolve(process.cwd(), expanded),
60
+ );
61
+
62
+ return protectedConfigPaths().includes(candidate);
63
+ }
64
+
65
+ /** Returns a tilde-relative path, or undefined when the path is outside the home directory. */
66
+ function homeForm(path: string): string | undefined {
67
+ const home = resolve(homedir());
68
+ const absolute = resolve(path);
69
+
70
+ if (absolute === home) return "~";
71
+
72
+ if (absolute.startsWith(`${home}/`)) return `~/${absolute.slice(home.length + 1)}`;
73
+
74
+ return undefined;
75
+ }
76
+
77
+ /** Lists unique canonical, configured, tilde, and applicable environment-variable file paths. */
78
+ function configPathForms(name: (typeof CONFIG_NAMES)[number], canonicalPath: string): string[] {
79
+ const configuredDirectory = configDirectory();
80
+ const logicalPath = resolve(configuredDirectory, name);
81
+ const envDirectory = process.env.PI_CODING_AGENT_DIR;
82
+ const forms = [canonicalPath, logicalPath];
83
+ const homeForms = [homeForm(canonicalPath), homeForm(logicalPath)];
84
+ forms.push(...homeForms.filter((form): form is string => form !== undefined));
85
+
86
+ if (envDirectory !== undefined) {
87
+ forms.push(`$PI_CODING_AGENT_DIR/${name}`, `\${PI_CODING_AGENT_DIR}/${name}`);
88
+ }
89
+
90
+ return [...new Set(forms)];
91
+ }
92
+
93
+ /** Lists unique directory spellings used to recognize relative config filenames in commands. */
94
+ function configDirectoryForms(canonicalPath: string): string[] {
95
+ const configuredDirectory = configDirectory();
96
+ const logicalDirectory = resolve(configuredDirectory);
97
+ const canonicalDirectory = dirname(canonicalPath);
98
+ const forms = [logicalDirectory, canonicalDirectory];
99
+ const homeForms = [homeForm(logicalDirectory), homeForm(canonicalDirectory)];
100
+ forms.push(...homeForms.filter((form): form is string => form !== undefined));
101
+
102
+ if (process.env.PI_CODING_AGENT_DIR !== undefined) {
103
+ forms.push("$PI_CODING_AGENT_DIR", "${PI_CODING_AGENT_DIR}");
104
+ }
105
+
106
+ return [...new Set(forms)];
107
+ }
108
+
109
+ /** Checks for a literal path substring without parsing shell syntax or expanding variables. */
110
+ function commandContainsPath(command: string, path: string): boolean {
111
+ return command.includes(path);
112
+ }
113
+
114
+ /**
115
+ * Detects textual references to gate configuration paths in a shell command.
116
+ * Bare filenames match only when the same command also names the config directory;
117
+ * this does not resolve shell expressions or track directory changes across calls.
118
+ */
119
+ export function bashTouchesConfig(command: string): boolean {
120
+ const paths = protectedConfigPaths();
121
+ const pathForms = CONFIG_NAMES.flatMap((name, index) => configPathForms(name, paths[index]!));
122
+
123
+ if (pathForms.some((path) => commandContainsPath(command, path))) return true;
124
+
125
+ // Relative filenames are guarded only when the command also names the config directory.
126
+ return CONFIG_NAMES.some((name, index) => {
127
+ const refersToDirectory = configDirectoryForms(paths[index]!).some((directory) =>
128
+ commandContainsPath(command, directory),
129
+ );
130
+
131
+ return refersToDirectory && commandContainsPath(command, name);
132
+ });
133
+ }
package/src/config.ts CHANGED
@@ -16,7 +16,9 @@ export interface ConfigLoadResult {
16
16
  status: ConfigLoadStatus;
17
17
  }
18
18
 
19
- const CONFIG_FILE_NAME = "pi-gate.json";
19
+ const CONFIG_FILE_NAME = "gate.json";
20
+
21
+ const LEGACY_CONFIG_FILE_NAME = "pi-gate.json";
20
22
 
21
23
  export const CONFIG_SCHEMA_URL =
22
24
  "https://raw.githubusercontent.com/zeldrisho/pi-packages/main/packages/pi-gate/config.schema.json";
@@ -54,10 +56,18 @@ function agentDir(): string {
54
56
  /**
55
57
  * Returns the full path to the pi-gate configuration file.
56
58
  *
57
- * @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
58
62
  */
59
63
  export function configPath(): string {
60
- 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;
61
71
  }
62
72
 
63
73
  /**
package/src/display.ts CHANGED
@@ -4,7 +4,8 @@ export const MAX_DISPLAY_COMMAND_CHARACTERS = 2_000;
4
4
 
5
5
  export const MAX_DISPLAY_COMMAND_LINES = 20;
6
6
 
7
- const DISPLAY_TRUNCATION_MARKER = "\n … [command display truncated]";
7
+ const DISPLAY_TRUNCATION_MARKER = (hiddenCharacters: number): string =>
8
+ `\n … [${hiddenCharacters} more characters hidden]`;
8
9
 
9
10
  const BIDI_CONTROL_CODE_POINTS = new Set([
10
11
  0x061c, 0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069,
@@ -58,8 +59,10 @@ function renderCommandForDisplay(command: string, pattern?: string): string {
58
59
  let lineCount = 1;
59
60
  let offset = 0;
60
61
 
61
- const truncate = (closeActiveRange = activeRange !== undefined): string =>
62
- output + (closeActiveRange ? "«" : "") + DISPLAY_TRUNCATION_MARKER;
62
+ const truncate = (
63
+ hiddenCharacters: number,
64
+ closeActiveRange = activeRange !== undefined,
65
+ ): string => output + (closeActiveRange ? "«" : "") + DISPLAY_TRUNCATION_MARKER(hiddenCharacters);
63
66
 
64
67
  while (offset < command.length) {
65
68
  const rawStart = offset;
@@ -90,12 +93,13 @@ function renderCommandForDisplay(command: string, pattern?: string): string {
90
93
  const decorated = `${opensRange ? "»" : ""}${rendered}${closesRange ? "«" : ""}`;
91
94
 
92
95
  if (rendered === "\n") {
93
- if (lineCount >= MAX_DISPLAY_COMMAND_LINES) return truncate(wasInsideRange);
96
+ if (lineCount >= MAX_DISPLAY_COMMAND_LINES)
97
+ return truncate(command.length - rawStart, wasInsideRange);
94
98
  lineCount += 1;
95
99
  }
96
100
 
97
101
  if (output.length + decorated.length > MAX_DISPLAY_COMMAND_CHARACTERS) {
98
- return truncate(wasInsideRange);
102
+ return truncate(command.length - rawStart, wasInsideRange);
99
103
  }
100
104
 
101
105
  output += decorated;
package/src/index.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * pi-gate Extension
3
3
  *
4
- * Intercepts the built-in `bash` tool and gates each command against a
5
- * user-provided JSON configuration. Without a configuration file, the
4
+ * Intercepts the built-in `bash` tool and protects the gate configuration
5
+ * from `write` and `edit` calls. 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:
@@ -14,11 +14,12 @@
14
14
  * - `block` - deny the command without asking
15
15
  * - `allow` - explicitly allow (use to carve out an exception)
16
16
  *
17
- * When multiple patterns match a command, the longest pattern wins, so a
18
- * narrow `allow` rule can override a broader `prompt` or `block` rule.
17
+ * Matching is case-insensitive and whitespace-normalized. An allow occurrence
18
+ * suppresses only restrictive occurrences fully contained within its span;
19
+ * surviving blocks take priority over prompts.
19
20
  *
20
21
  * On first run, a configuration with the default prompt timeout and starter
21
- * operation rules is written to `~/.pi/agent/pi-gate.json`.
22
+ * operation rules is written to `~/.pi/agent/gate.json` when neither file exists.
22
23
  *
23
24
  * Non-UI modes (print, JSON) never auto-approve. A `prompt` or `block` rule
24
25
  * in non-UI mode always blocks and requests termination of the agent turn.
@@ -28,11 +29,32 @@
28
29
 
29
30
  import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
31
  import { configPath, ensureConfig, loadConfigResult } from "./config";
32
+ import {
33
+ bashTouchesConfig,
34
+ CONFIG_CHANGE_REASON,
35
+ isProtectedConfigPath,
36
+ } from "./config-protection";
31
37
  import { resolveRule } from "./rules";
32
- import { formatPromptCommand, formatRule } from "./display";
38
+ import { formatCommandForDisplay, formatPromptCommand, formatRule } from "./display";
33
39
 
34
40
  const HERDR_BLOCKED_LABEL = "pi-gate: approval required";
35
41
 
42
+ const MAX_REASON_RULE_NAME_LENGTH = 27;
43
+
44
+ /** Escapes a rule pattern for terminal display and bounds its length in denial messages. */
45
+ function reasonRuleName(pattern: string): string {
46
+ const safePattern = formatCommandForDisplay(pattern);
47
+
48
+ return safePattern.length <= MAX_REASON_RULE_NAME_LENGTH
49
+ ? safePattern
50
+ : `${safePattern.slice(0, MAX_REASON_RULE_NAME_LENGTH - 1)}…`;
51
+ }
52
+
53
+ /** Formats an approval timeout with the matched rule, elapsed limit in seconds, and retry guidance. */
54
+ function timeoutReason(pattern: string, timeoutMs: number): string {
55
+ return `pi-gate: no response to rule "${reasonRuleName(pattern)}" within ${timeoutMs / 1_000}s; command not run. Ask the user whether to retry.`;
56
+ }
57
+
36
58
  function reportHerdrBlocked(pi: ExtensionAPI, ctx: { mode?: string }, active: boolean): void {
37
59
  if (
38
60
  ctx.mode !== "tui" ||
@@ -119,51 +141,72 @@ export default function piGate(pi: ExtensionAPI): void {
119
141
  });
120
142
 
121
143
  pi.on("tool_call", async (event, ctx) => {
122
- if (event.toolName !== "bash") return undefined;
123
-
124
- if (!isToolCallEventType("bash", event)) return undefined;
125
- const command = event.input.command;
126
-
127
- if (loadResult.status === "failed") {
128
- loadResult = loadConfigResult();
144
+ let command: string;
145
+ let configChange = false;
146
+
147
+ if (event.toolName === "bash") {
148
+ if (!isToolCallEventType("bash", event)) return undefined;
149
+ command = event.input.command;
150
+ configChange = bashTouchesConfig(command);
151
+ } else if (isToolCallEventType("write", event)) {
152
+ if (!isProtectedConfigPath(event.input.path)) return undefined;
153
+ command = event.input.path;
154
+ configChange = true;
155
+ } else if (isToolCallEventType("edit", event)) {
156
+ if (!isProtectedConfigPath(event.input.path)) return undefined;
157
+ command = event.input.path;
158
+ configChange = true;
159
+ } else {
160
+ return undefined;
129
161
  }
130
162
 
131
- const match =
132
- loadResult.status === "failed"
163
+ if (loadResult.status === "failed") loadResult = loadConfigResult();
164
+
165
+ const match = configChange
166
+ ? { pattern: "gate config change requires approval", action: "prompt" as const }
167
+ : loadResult.status === "failed"
133
168
  ? { pattern: "configuration unavailable", action: "prompt" as const }
134
169
  : resolveRule(command, loadResult.config.operations);
135
170
 
136
171
  if (match === null || match.action === "allow") return undefined;
137
- const rule = formatRule(match);
172
+ const rule = configChange ? CONFIG_CHANGE_REASON : formatRule(match);
138
173
 
139
174
  if (match.action === "block") {
140
- const reason = `pi-gate: command blocked by rule ${rule}`;
175
+ const reason = `pi-gate: blocked by rule "${reasonRuleName(match.pattern)}". Do not retry or use equivalent commands; ask the user.`;
141
176
 
142
- if (ctx.hasUI) {
143
- ctx.ui.notify(reason, "warning");
144
- }
177
+ if (ctx.hasUI) ctx.ui.notify(reason, "warning");
145
178
 
146
179
  return { block: true, reason, terminate: true };
147
180
  }
148
181
 
149
- // action === "prompt"
150
182
  if (!ctx.hasUI) {
151
183
  return {
152
184
  block: true,
153
- reason: `pi-gate: command blocked because rule ${rule} requires a prompt, but no UI is available`,
185
+ reason: configChange
186
+ ? CONFIG_CHANGE_REASON
187
+ : `pi-gate: rule "${reasonRuleName(match.pattern)}" needs approval but no UI is available. Do not retry; ask the user.`,
154
188
  terminate: true,
155
189
  };
156
190
  }
157
191
 
158
192
  reportHerdrBlocked(pi, ctx, true);
159
193
  let choice: string | undefined;
194
+ let timedOut = false;
195
+ const timeoutMs = loadResult.config.promptTimeoutMs;
196
+
197
+ const promptCommand = configChange
198
+ ? ` ${formatCommandForDisplay(command)}`
199
+ : formatPromptCommand(command, match.pattern);
160
200
 
161
201
  try {
202
+ const promptStartedAt = Date.now();
162
203
  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 »…«`,
204
+ `pi-gate: allow this command?\n\n${promptCommand}\n\nMatched rule: ${rule}${configChange ? "" : "\nMatched command text is wrapped in »…«"}`,
164
205
  ["Allow", "Deny"],
165
- { timeout: loadResult.config.promptTimeoutMs },
206
+ { timeout: timeoutMs },
166
207
  );
208
+ // Pi returns undefined for both dismissal and timeout; elapsed time separates the timeout case.
209
+ timedOut = choice === undefined && Date.now() - promptStartedAt >= timeoutMs;
167
210
  } finally {
168
211
  reportHerdrBlocked(pi, ctx, false);
169
212
  }
@@ -171,7 +214,11 @@ export default function piGate(pi: ExtensionAPI): void {
171
214
  if (choice !== "Allow") {
172
215
  return {
173
216
  block: true,
174
- reason: `pi-gate: command denied, dismissed, or timed out after matching rule ${rule}`,
217
+ reason: timedOut
218
+ ? timeoutReason(match.pattern, timeoutMs)
219
+ : configChange
220
+ ? CONFIG_CHANGE_REASON
221
+ : `pi-gate: denied by rule "${reasonRuleName(match.pattern)}". Do not retry or use equivalent commands; ask the user.`,
175
222
  terminate: true,
176
223
  };
177
224
  }
package/src/rules.ts CHANGED
@@ -5,34 +5,86 @@ export interface RuleMatch {
5
5
  action: Action;
6
6
  }
7
7
 
8
+ interface RuleOccurrence extends RuleMatch {
9
+ start: number;
10
+ end: number;
11
+ normalizedLength: number;
12
+ }
13
+
14
+ /** Normalize matching text while leaving the original command untouched for display/execution. */
15
+ export function normalizeMatchText(text: string): string {
16
+ return text.toLowerCase().replace(/\s+/gu, " ");
17
+ }
18
+
19
+ function findOccurrences(text: string, pattern: string, rule: RuleMatch): RuleOccurrence[] {
20
+ const occurrences: RuleOccurrence[] = [];
21
+ let offset = 0;
22
+
23
+ while (offset <= text.length - pattern.length) {
24
+ const start = text.indexOf(pattern, offset);
25
+
26
+ if (start === -1) break;
27
+ occurrences.push({
28
+ ...rule,
29
+ start,
30
+ end: start + pattern.length,
31
+ normalizedLength: pattern.length,
32
+ });
33
+ // Advance one code unit so overlapping occurrences are retained.
34
+ offset = start + 1;
35
+ }
36
+
37
+ return occurrences;
38
+ }
39
+
8
40
  /**
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
41
+ * Resolves all matching spans. An allow only suppresses a prompt/block occurrence
42
+ * when that allow occurrence fully contains it. A surviving block takes priority
43
+ * over prompt; the longest pattern for the winning action is reported.
18
44
  */
19
45
  export function resolveRule(command: string, rules: Record<string, Action>): RuleMatch | null {
20
- let bestPattern: string | null = null;
46
+ const normalizedCommand = normalizeMatchText(command);
47
+ const occurrences: RuleOccurrence[] = [];
48
+
49
+ let acceptedRuleCount = 0;
50
+
51
+ for (const [pattern, action] of Object.entries(rules)) {
52
+ if (acceptedRuleCount >= 1_000) break;
21
53
 
22
- for (const pattern of Object.keys(rules)) {
23
- if (!command.includes(pattern)) continue;
54
+ if (pattern.length === 0 || pattern.length > 1_024) continue;
55
+ const normalizedPattern = normalizeMatchText(pattern);
24
56
 
25
- if (bestPattern === null || pattern.length > bestPattern.length) {
26
- bestPattern = pattern;
27
- }
57
+ if (!normalizedPattern) continue;
58
+ occurrences.push(...findOccurrences(normalizedCommand, normalizedPattern, { pattern, action }));
59
+ acceptedRuleCount += 1;
28
60
  }
29
61
 
30
- if (bestPattern === null) return null;
62
+ const allows = occurrences.filter((occurrence) => occurrence.action === "allow");
63
+
64
+ const surviving = occurrences.filter(
65
+ (occurrence) =>
66
+ occurrence.action === "allow" ||
67
+ !allows.some((allow) => allow.start <= occurrence.start && allow.end >= occurrence.end),
68
+ );
69
+
70
+ const action: Action | null = surviving.some((occurrence) => occurrence.action === "block")
71
+ ? "block"
72
+ : surviving.some((occurrence) => occurrence.action === "prompt")
73
+ ? "prompt"
74
+ : surviving.some((occurrence) => occurrence.action === "allow")
75
+ ? "allow"
76
+ : null;
77
+
78
+ if (action === null) return null;
79
+
80
+ const winner = surviving
81
+ .filter((occurrence) => occurrence.action === action)
82
+ .sort((a, b) => b.normalizedLength - a.normalizedLength)[0];
31
83
 
32
- return { pattern: bestPattern, action: rules[bestPattern] };
84
+ return winner ? { pattern: winner.pattern, action: winner.action } : null;
33
85
  }
34
86
 
35
- /** Returns only the action selected by {@link resolveRule}. */
87
+ /** Returns the selected action, or null when no rule matches. */
36
88
  export function resolveAction(command: string, rules: Record<string, Action>): Action | null {
37
89
  return resolveRule(command, rules)?.action ?? null;
38
90
  }