@zeldrisho/pi-gate 0.5.0 → 0.6.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 +23 -1
- package/LICENSE +1 -1
- package/README.md +15 -6
- package/package.json +6 -6
- package/src/config-protection.ts +133 -0
- package/src/display.ts +9 -5
- package/src/index.ts +78 -38
- 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.1] - 2026-10-21
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Stop notifying at session start when operation rules load successfully.
|
|
15
|
+
|
|
16
|
+
## [0.6.0] - 2026-10-01
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Match bash config protection against resolved config paths instead of bare filenames, and shorten denial reasons while retaining the matched rule.
|
|
21
|
+
- Give prompt timeouts a distinct denial reason that includes the configured timeout and asks whether to retry.
|
|
22
|
+
- 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.
|
|
23
|
+
- Prompt before bash commands and write/edit calls that target gate configuration files.
|
|
24
|
+
- Show the number of hidden characters when command text is truncated in a prompt.
|
|
25
|
+
|
|
26
|
+
### Security
|
|
27
|
+
|
|
28
|
+
- Escape terminal controls and bound config paths shown in approval prompts.
|
|
29
|
+
|
|
10
30
|
## [0.5.0] - 2026-09-24
|
|
11
31
|
|
|
12
32
|
### Changed
|
|
@@ -62,7 +82,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
62
82
|
- support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
|
|
63
83
|
- block rather than auto-approve in non-UI modes
|
|
64
84
|
|
|
65
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.
|
|
85
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.6.1...HEAD
|
|
86
|
+
[0.6.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.6.0...pi-gate-v0.6.1
|
|
87
|
+
[0.6.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.5.0...pi-gate-v0.6.0
|
|
66
88
|
[0.5.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.1...pi-gate-v0.5.0
|
|
67
89
|
[0.4.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.0...pi-gate-v0.4.1
|
|
68
90
|
[0.4.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.3.0...pi-gate-v0.4.0
|
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
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
|
|
|
7
7
|
```bash
|
|
8
8
|
pi install npm:@zeldrisho/pi-gate
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
# Project:
|
|
10
11
|
pi install -l npm:@zeldrisho/pi-gate
|
|
11
12
|
```
|
|
12
13
|
|
|
14
|
+
Use `pi config` to enable or disable this extension interactively. Run `pi list` to see configured packages and their install paths.
|
|
15
|
+
|
|
13
16
|
## Configure
|
|
14
17
|
|
|
15
18
|
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.
|
|
@@ -33,21 +36,27 @@ Rules use substring matching and have one of three actions:
|
|
|
33
36
|
|
|
34
37
|
- `prompt`: ask the user to allow or deny;
|
|
35
38
|
- `block`: deny without asking;
|
|
36
|
-
- `allow`: explicitly permit
|
|
39
|
+
- `allow`: explicitly permit matches fully contained within its matching text.
|
|
40
|
+
|
|
41
|
+
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.
|
|
37
42
|
|
|
38
|
-
|
|
43
|
+
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.
|
|
39
44
|
|
|
40
45
|
## Behavior
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
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.
|
|
43
48
|
|
|
44
49
|
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.
|
|
45
50
|
|
|
51
|
+
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.
|
|
52
|
+
|
|
46
53
|
## Uninstall
|
|
47
54
|
|
|
48
55
|
```bash
|
|
49
56
|
pi remove npm:@zeldrisho/pi-gate
|
|
50
|
-
|
|
57
|
+
|
|
58
|
+
# Project:
|
|
59
|
+
pi remove -l npm:@zeldrisho/pi-gate
|
|
51
60
|
```
|
|
52
61
|
|
|
53
62
|
## License
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-gate",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension that
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "Pi extension that gates bash commands and protects its configuration from tool edits",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"command-blocking",
|
|
7
7
|
"command-confirmation",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"url": "https://github.com/zeldrisho/pi-packages/issues"
|
|
17
17
|
},
|
|
18
18
|
"license": "MIT",
|
|
19
|
-
"author": "
|
|
19
|
+
"author": "zeldrisho",
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
22
|
"url": "https://github.com/zeldrisho/pi-packages.git",
|
|
@@ -32,10 +32,10 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@earendil-works/pi-coding-agent": "^
|
|
35
|
+
"@earendil-works/pi-coding-agent": "^1.3.0",
|
|
36
36
|
"typescript": "^7.0.2",
|
|
37
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@
|
|
38
|
-
"vite-plus": "
|
|
37
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
|
|
38
|
+
"vite-plus": "1.3.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/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,8 +1,8 @@
|
|
|
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
8
|
* Configuration file: `~/.pi/agent/gate.json` (legacy fallback: `pi-gate.json`)
|
|
@@ -14,8 +14,9 @@
|
|
|
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
22
|
* operation rules is written to `~/.pi/agent/gate.json` when neither file exists.
|
|
@@ -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" ||
|
|
@@ -93,77 +115,91 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
93
115
|
const path = configPath();
|
|
94
116
|
const ruleCount = Object.keys(config.operations).length;
|
|
95
117
|
|
|
96
|
-
if (loadResult.status === "loaded") {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
);
|
|
102
|
-
} else {
|
|
103
|
-
ctx.ui.notify(
|
|
104
|
-
`pi-gate: ${ruleCount} operation rule${ruleCount === 1 ? "" : "s"} loaded from ${path}`,
|
|
105
|
-
"info",
|
|
106
|
-
);
|
|
107
|
-
}
|
|
118
|
+
if (loadResult.status === "loaded" && ruleCount === 0) {
|
|
119
|
+
ctx.ui.notify(
|
|
120
|
+
`pi-gate: ${path} is empty; no commands are gated. Add rules to the "operations" object to start gating.`,
|
|
121
|
+
"warning",
|
|
122
|
+
);
|
|
108
123
|
} else if (loadResult.status === "failed") {
|
|
109
124
|
ctx.ui.notify(
|
|
110
125
|
"pi-gate: configuration exists but could not be loaded; prompting for all commands until it is fixed.",
|
|
111
126
|
"warning",
|
|
112
127
|
);
|
|
113
|
-
} else {
|
|
128
|
+
} else if (loadResult.status === "missing") {
|
|
114
129
|
ctx.ui.notify(
|
|
115
|
-
`pi-gate: no configuration found at ${
|
|
130
|
+
`pi-gate: no configuration found at ${path}; all commands are allowed until it is created.`,
|
|
116
131
|
"warning",
|
|
117
132
|
);
|
|
118
133
|
}
|
|
119
134
|
});
|
|
120
135
|
|
|
121
136
|
pi.on("tool_call", async (event, ctx) => {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
let command: string;
|
|
138
|
+
let configChange = false;
|
|
139
|
+
|
|
140
|
+
if (event.toolName === "bash") {
|
|
141
|
+
if (!isToolCallEventType("bash", event)) return undefined;
|
|
142
|
+
command = event.input.command;
|
|
143
|
+
configChange = bashTouchesConfig(command);
|
|
144
|
+
} else if (isToolCallEventType("write", event)) {
|
|
145
|
+
if (!isProtectedConfigPath(event.input.path)) return undefined;
|
|
146
|
+
command = event.input.path;
|
|
147
|
+
configChange = true;
|
|
148
|
+
} else if (isToolCallEventType("edit", event)) {
|
|
149
|
+
if (!isProtectedConfigPath(event.input.path)) return undefined;
|
|
150
|
+
command = event.input.path;
|
|
151
|
+
configChange = true;
|
|
152
|
+
} else {
|
|
153
|
+
return undefined;
|
|
129
154
|
}
|
|
130
155
|
|
|
131
|
-
|
|
132
|
-
|
|
156
|
+
if (loadResult.status === "failed") loadResult = loadConfigResult();
|
|
157
|
+
|
|
158
|
+
const match = configChange
|
|
159
|
+
? { pattern: "gate config change requires approval", action: "prompt" as const }
|
|
160
|
+
: loadResult.status === "failed"
|
|
133
161
|
? { pattern: "configuration unavailable", action: "prompt" as const }
|
|
134
162
|
: resolveRule(command, loadResult.config.operations);
|
|
135
163
|
|
|
136
164
|
if (match === null || match.action === "allow") return undefined;
|
|
137
|
-
const rule = formatRule(match);
|
|
165
|
+
const rule = configChange ? CONFIG_CHANGE_REASON : formatRule(match);
|
|
138
166
|
|
|
139
167
|
if (match.action === "block") {
|
|
140
|
-
const reason = `pi-gate:
|
|
168
|
+
const reason = `pi-gate: blocked by rule "${reasonRuleName(match.pattern)}". Do not retry or use equivalent commands; ask the user.`;
|
|
141
169
|
|
|
142
|
-
if (ctx.hasUI)
|
|
143
|
-
ctx.ui.notify(reason, "warning");
|
|
144
|
-
}
|
|
170
|
+
if (ctx.hasUI) ctx.ui.notify(reason, "warning");
|
|
145
171
|
|
|
146
172
|
return { block: true, reason, terminate: true };
|
|
147
173
|
}
|
|
148
174
|
|
|
149
|
-
// action === "prompt"
|
|
150
175
|
if (!ctx.hasUI) {
|
|
151
176
|
return {
|
|
152
177
|
block: true,
|
|
153
|
-
reason:
|
|
178
|
+
reason: configChange
|
|
179
|
+
? CONFIG_CHANGE_REASON
|
|
180
|
+
: `pi-gate: rule "${reasonRuleName(match.pattern)}" needs approval but no UI is available. Do not retry; ask the user.`,
|
|
154
181
|
terminate: true,
|
|
155
182
|
};
|
|
156
183
|
}
|
|
157
184
|
|
|
158
185
|
reportHerdrBlocked(pi, ctx, true);
|
|
159
186
|
let choice: string | undefined;
|
|
187
|
+
let timedOut = false;
|
|
188
|
+
const timeoutMs = loadResult.config.promptTimeoutMs;
|
|
189
|
+
|
|
190
|
+
const promptCommand = configChange
|
|
191
|
+
? ` ${formatCommandForDisplay(command)}`
|
|
192
|
+
: formatPromptCommand(command, match.pattern);
|
|
160
193
|
|
|
161
194
|
try {
|
|
195
|
+
const promptStartedAt = Date.now();
|
|
162
196
|
choice = await ctx.ui.select(
|
|
163
|
-
`pi-gate: allow this command?\n\n${
|
|
197
|
+
`pi-gate: allow this command?\n\n${promptCommand}\n\nMatched rule: ${rule}${configChange ? "" : "\nMatched command text is wrapped in »…«"}`,
|
|
164
198
|
["Allow", "Deny"],
|
|
165
|
-
{ timeout:
|
|
199
|
+
{ timeout: timeoutMs },
|
|
166
200
|
);
|
|
201
|
+
// Pi returns undefined for both dismissal and timeout; elapsed time separates the timeout case.
|
|
202
|
+
timedOut = choice === undefined && Date.now() - promptStartedAt >= timeoutMs;
|
|
167
203
|
} finally {
|
|
168
204
|
reportHerdrBlocked(pi, ctx, false);
|
|
169
205
|
}
|
|
@@ -171,7 +207,11 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
171
207
|
if (choice !== "Allow") {
|
|
172
208
|
return {
|
|
173
209
|
block: true,
|
|
174
|
-
reason:
|
|
210
|
+
reason: timedOut
|
|
211
|
+
? timeoutReason(match.pattern, timeoutMs)
|
|
212
|
+
: configChange
|
|
213
|
+
? CONFIG_CHANGE_REASON
|
|
214
|
+
: `pi-gate: denied by rule "${reasonRuleName(match.pattern)}". Do not retry or use equivalent commands; ask the user.`,
|
|
175
215
|
terminate: true,
|
|
176
216
|
};
|
|
177
217
|
}
|
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
|
}
|