@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 +23 -1
- package/README.md +11 -5
- package/package.json +4 -4
- package/src/config-protection.ts +133 -0
- package/src/config.ts +13 -3
- package/src/display.ts +9 -5
- package/src/index.ts +73 -26
- package/src/rules.ts +70 -18
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
35
|
+
"@earendil-works/pi-coding-agent": "^0.99.1",
|
|
36
36
|
"typescript": "^7.0.2",
|
|
37
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@0.
|
|
38
|
-
"vite-plus": "0.
|
|
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 = "
|
|
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
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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 = (
|
|
62
|
-
|
|
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)
|
|
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
|
|
5
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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/
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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:
|
|
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:
|
|
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${
|
|
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:
|
|
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:
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
54
|
+
if (pattern.length === 0 || pattern.length > 1_024) continue;
|
|
55
|
+
const normalizedPattern = normalizeMatchText(pattern);
|
|
24
56
|
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
57
|
+
if (!normalizedPattern) continue;
|
|
58
|
+
occurrences.push(...findOccurrences(normalizedCommand, normalizedPattern, { pattern, action }));
|
|
59
|
+
acceptedRuleCount += 1;
|
|
28
60
|
}
|
|
29
61
|
|
|
30
|
-
|
|
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:
|
|
84
|
+
return winner ? { pattern: winner.pattern, action: winner.action } : null;
|
|
33
85
|
}
|
|
34
86
|
|
|
35
|
-
/** Returns
|
|
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
|
}
|