@zeldrisho/pi-gate 0.3.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 +8 -1
- package/README.md +13 -45
- package/package.json +1 -1
- package/src/config.ts +159 -0
- package/src/display.ts +128 -0
- package/src/index.ts +51 -314
- package/src/rules.ts +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ 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
|
+
|
|
10
16
|
## [0.3.0] - 2026-09-07
|
|
11
17
|
|
|
12
18
|
### Added
|
|
@@ -44,7 +50,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
44
50
|
- support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
|
|
45
51
|
- block rather than auto-approve in non-UI modes
|
|
46
52
|
|
|
47
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.
|
|
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
|
|
48
55
|
[0.3.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.2.0...pi-gate-v0.3.0
|
|
49
56
|
[0.2.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.1...pi-gate-v0.2.0
|
|
50
57
|
[0.1.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.0...pi-gate-v0.1.1
|
package/README.md
CHANGED
|
@@ -1,30 +1,18 @@
|
|
|
1
1
|
# @zeldrisho/pi-gate
|
|
2
2
|
|
|
3
|
-
Pi extension that blocks or confirms bash tool calls
|
|
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
|
|
20
|
-
|
|
21
|
-
The published package includes [`config.schema.json`](config.schema.json). New configurations reference its canonical URL automatically; existing configurations can add the same `$schema` property shown below to enable completion and validation in compatible editors.
|
|
22
|
-
|
|
23
|
-
Edit `~/.pi/agent/pi-gate.json` to customize the rules. `promptTimeoutMs` controls how long a confirmation remains open (30 seconds by default if omitted, with a maximum of one day). Each rule is a substring pattern and one of three actions:
|
|
24
|
-
|
|
25
|
-
- `prompt` — ask the user to allow or deny the command
|
|
26
|
-
- `block` — deny the command without asking
|
|
27
|
-
- `allow` — explicitly allow an exception to a broader matching rule
|
|
15
|
+
On first load, the extension creates `~/.pi/agent/pi-gate.json` with starter rules and a 30-second prompt timeout. Existing files are never overwritten. The published package includes [`config.schema.json`](config.schema.json); use this schema URL for editor completion:
|
|
28
16
|
|
|
29
17
|
```json
|
|
30
18
|
{
|
|
@@ -34,50 +22,30 @@ Edit `~/.pi/agent/pi-gate.json` to customize the rules. `promptTimeoutMs` contro
|
|
|
34
22
|
"rm -rf": "prompt",
|
|
35
23
|
"sudo": "prompt",
|
|
36
24
|
"sudo apt update": "allow",
|
|
37
|
-
"chmod 777": "block"
|
|
38
|
-
"corepack enable": "block"
|
|
25
|
+
"chmod 777": "block"
|
|
39
26
|
}
|
|
40
27
|
}
|
|
41
28
|
```
|
|
42
29
|
|
|
43
|
-
|
|
30
|
+
Rules use substring matching and have one of three actions:
|
|
44
31
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
32
|
+
- `prompt`: ask the user to allow or deny;
|
|
33
|
+
- `block`: deny without asking;
|
|
34
|
+
- `allow`: explicitly permit an exception to a broader match.
|
|
48
35
|
|
|
49
|
-
-
|
|
50
|
-
- `prompt` rules ask the user with a two-button confirmation (`Allow` / `Deny`). The dialog identifies the matched rule, wraps its visible occurrences in `»…«`, and auto-denies after `promptTimeoutMs` instead of waiting indefinitely. Commands are normalized and terminal control characters are escaped for display; prompts show at most 2,000 command characters and 20 lines, but an allowed command always executes in full and unchanged.
|
|
51
|
-
- `block` rules never ask and always deny. The warning identifies the matched rule.
|
|
52
|
-
- `allow` rules are explicit pass-throughs, useful for carving out exceptions.
|
|
53
|
-
- Blocked, denied, dismissed, and timed-out calls request early termination. Pi ends the turn only when every finalized result in the tool-call batch requests termination; a mixed parallel batch containing allowed calls may continue.
|
|
54
|
-
- RPC hosts use their native selection dialog. In non-interactive modes (`-p`, JSON), `prompt` and `block` rules both block and request termination instead of auto-approving.
|
|
36
|
+
Empty or over-1,024-character patterns are ignored, at most 1,000 valid rules are loaded, and commands with no match are allowed. The longest matching pattern wins. `promptTimeoutMs` defaults to 30 seconds and is capped at one day. Run `/reload` after editing.
|
|
55
37
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
The confirmation dialog and your choice are not sent directly to the agent.
|
|
59
|
-
|
|
60
|
-
- If you allow a `prompt` rule, the command runs and the agent receives its normal bash result.
|
|
61
|
-
- If you deny or dismiss a `prompt`, the agent receives an error result naming the matched rule and saying the user denied it.
|
|
62
|
-
- If a `block` rule matches, the agent receives an error result naming that rule.
|
|
63
|
-
- If a `prompt` rule matches without an available UI, the agent receives an error explaining that the matched rule required a prompt.
|
|
38
|
+
## Behavior
|
|
64
39
|
|
|
65
|
-
|
|
40
|
+
Only the built-in `bash` tool is gated. Prompt dialogs offer `Allow` and `Deny`, escape terminal controls, and show at most 2,000 command characters and 20 lines; an allowed command executes in full and unchanged. Blocked, denied, dismissed, and timed-out calls request early termination. A parallel batch continues when it contains allowed calls. In non-interactive modes (`-p`, JSON), prompts and blocks deny and request termination.
|
|
66
41
|
|
|
67
|
-
|
|
68
|
-
pi-gate: command blocked by rule "sudo": "block"
|
|
69
|
-
```
|
|
42
|
+
The agent receives normal bash output after approval, or a bounded error naming the matched rule after denial or blocking. Dialogs and choices are not sent directly to the agent. RPC hosts use their native selection dialog.
|
|
70
43
|
|
|
71
44
|
## Uninstall
|
|
72
45
|
|
|
73
46
|
```bash
|
|
74
47
|
pi remove npm:@zeldrisho/pi-gate
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
For a project-local installation:
|
|
78
|
-
|
|
79
|
-
```bash
|
|
80
|
-
pi remove -l npm:@zeldrisho/pi-gate
|
|
48
|
+
pi remove -l npm:@zeldrisho/pi-gate # project-local
|
|
81
49
|
```
|
|
82
50
|
|
|
83
51
|
## License
|
package/package.json
CHANGED
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,325 +26,56 @@
|
|
|
26
26
|
* indefinitely.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import { existsSync
|
|
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";
|
|
31
|
+
import { configPath, ensureConfig, loadConfig } from "./config";
|
|
32
|
+
import { resolveRule } from "./rules";
|
|
33
|
+
import { formatPromptCommand, formatRule } from "./display";
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
const HERDR_BLOCKED_LABEL = "pi-gate: approval required";
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
operations: Record<string, Action>;
|
|
38
|
-
promptTimeoutMs: number;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface RuleMatch {
|
|
42
|
-
pattern: string;
|
|
43
|
-
action: Action;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const CONFIG_FILE_NAME = "pi-gate.json";
|
|
47
|
-
export const CONFIG_SCHEMA_URL =
|
|
48
|
-
"https://raw.githubusercontent.com/zeldrisho/pi-packages/main/packages/pi-gate/config.schema.json";
|
|
49
|
-
const ACTIONS: readonly Action[] = ["prompt", "block", "allow"] as const;
|
|
50
|
-
export const DEFAULT_PROMPT_TIMEOUT_MS = 30_000;
|
|
51
|
-
export const MAX_RULE_COUNT = 1_000;
|
|
52
|
-
export const MAX_RULE_PATTERN_LENGTH = 1_024;
|
|
53
|
-
export const MAX_DISPLAY_COMMAND_CHARACTERS = 2_000;
|
|
54
|
-
export const MAX_DISPLAY_COMMAND_LINES = 20;
|
|
55
|
-
export const MAX_PROMPT_TIMEOUT_MS = 86_400_000;
|
|
56
|
-
const DISPLAY_TRUNCATION_MARKER = "\n … [command display truncated]";
|
|
57
|
-
const BIDI_CONTROL_CODE_POINTS = new Set([
|
|
58
|
-
0x061c, 0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069,
|
|
59
|
-
]);
|
|
60
|
-
|
|
61
|
-
const DEFAULT_CONFIG: GateConfig = {
|
|
62
|
-
promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS,
|
|
63
|
-
operations: {
|
|
64
|
-
"rm -rf": "prompt",
|
|
65
|
-
sudo: "prompt",
|
|
66
|
-
"sudo apt update": "allow",
|
|
67
|
-
"chmod 777": "block",
|
|
68
|
-
"corepack enable": "block",
|
|
69
|
-
},
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Returns the directory where pi-gate configuration files are stored.
|
|
74
|
-
*
|
|
75
|
-
* @returns The absolute path to the agent configuration directory
|
|
76
|
-
*/
|
|
77
|
-
function agentDir(): string {
|
|
78
|
-
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Returns the full path to the pi-gate configuration file.
|
|
83
|
-
*
|
|
84
|
-
* @returns The absolute path to pi-gate.json
|
|
85
|
-
*/
|
|
86
|
-
function configPath(): string {
|
|
87
|
-
return join(agentDir(), CONFIG_FILE_NAME);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Type guard that checks if an unknown value is a valid Action.
|
|
92
|
-
*
|
|
93
|
-
* @param value - The value to check
|
|
94
|
-
* @returns `true` if the value is one of "prompt", "block", or "allow"
|
|
95
|
-
*/
|
|
96
|
-
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- type-guard helper intentionally narrows untrusted JSON values at the parse boundary
|
|
97
|
-
function isAction(value: unknown): value is Action {
|
|
98
|
-
return (
|
|
99
|
-
typeof value === "string" &&
|
|
100
|
-
// SAFETY: ACTIONS is the source-of-truth tuple of supported action strings; widening
|
|
101
|
-
// it to readonly string[] lets Array.prototype.includes perform a runtime check
|
|
102
|
-
// before the type predicate narrows the value to Action.
|
|
103
|
-
(ACTIONS as readonly string[]).includes(value)
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Type guard that checks if an unknown value is a plain JSON object.
|
|
109
|
-
*
|
|
110
|
-
* @param value - The value to check
|
|
111
|
-
* @returns `true` if the value is a non-null object and not an array
|
|
112
|
-
*/
|
|
113
|
-
// oxlint-disable-next-line anti-slop/no-unknown-parameters, anti-slop/no-unsafe-dictionary-type -- type-guard helper intentionally narrows untrusted JSON values at the parse boundary
|
|
114
|
-
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
115
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Parses the user configuration file. Returns an empty configuration (no
|
|
120
|
-
* rules) when the file is missing, unreadable, malformed, or fails
|
|
121
|
-
* validation; an explicit empty `operations` object is also valid.
|
|
122
|
-
*
|
|
123
|
-
* Invalid entries are skipped so a single bad rule does not disable the
|
|
124
|
-
* entire gate; parsing continues for the remaining rules.
|
|
125
|
-
*/
|
|
126
|
-
export function parseConfig(content: string): GateConfig {
|
|
127
|
-
const rules: Record<string, Action> = {};
|
|
128
|
-
let acceptedRuleCount = 0;
|
|
129
|
-
let parsed: unknown;
|
|
130
|
-
try {
|
|
131
|
-
parsed = JSON.parse(content);
|
|
132
|
-
} catch {
|
|
133
|
-
return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
134
|
-
}
|
|
135
|
-
if (!isJsonObject(parsed)) {
|
|
136
|
-
return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
137
|
-
}
|
|
138
|
-
const configuredTimeout = parsed["promptTimeoutMs"];
|
|
139
|
-
const promptTimeoutMs =
|
|
140
|
-
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validate the optional numeric field at the untrusted JSON boundary before applying range checks
|
|
141
|
-
typeof configuredTimeout === "number" &&
|
|
142
|
-
Number.isInteger(configuredTimeout) &&
|
|
143
|
-
configuredTimeout > 0 &&
|
|
144
|
-
configuredTimeout <= MAX_PROMPT_TIMEOUT_MS
|
|
145
|
-
? configuredTimeout
|
|
146
|
-
: DEFAULT_PROMPT_TIMEOUT_MS;
|
|
147
|
-
const operations = parsed["operations"];
|
|
148
|
-
if (!isJsonObject(operations)) {
|
|
149
|
-
return { operations: rules, promptTimeoutMs };
|
|
150
|
-
}
|
|
151
|
-
for (const [pattern, action] of Object.entries(operations)) {
|
|
152
|
-
if (acceptedRuleCount >= MAX_RULE_COUNT) break;
|
|
153
|
-
if (pattern.length === 0 || pattern.length > MAX_RULE_PATTERN_LENGTH) continue;
|
|
154
|
-
if (isAction(action)) {
|
|
155
|
-
rules[pattern] = action;
|
|
156
|
-
acceptedRuleCount += 1;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
return { operations: rules, promptTimeoutMs };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Loads the configuration from disk. Returns an empty configuration when the
|
|
164
|
-
* file is missing; returns the parsed configuration otherwise.
|
|
165
|
-
*/
|
|
166
|
-
export function loadConfig(): GateConfig {
|
|
167
|
-
const path = configPath();
|
|
168
|
-
if (!existsSync(path)) {
|
|
169
|
-
return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
170
|
-
}
|
|
171
|
-
let content: string;
|
|
172
|
-
try {
|
|
173
|
-
content = readFileSync(path, "utf-8");
|
|
174
|
-
} catch {
|
|
175
|
-
return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
176
|
-
}
|
|
177
|
-
return parseConfig(content);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Writes the default configuration on first run. Existing configuration files
|
|
182
|
-
* are never overwritten.
|
|
183
|
-
*/
|
|
184
|
-
export function ensureConfig(): void {
|
|
185
|
-
const path = configPath();
|
|
186
|
-
if (existsSync(path)) return;
|
|
187
|
-
try {
|
|
188
|
-
mkdirSync(agentDir(), { recursive: true });
|
|
189
|
-
} catch {
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
const content = JSON.stringify({ $schema: CONFIG_SCHEMA_URL, ...DEFAULT_CONFIG }, null, 2) + "\n";
|
|
193
|
-
try {
|
|
194
|
-
writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
|
|
195
|
-
} catch {
|
|
196
|
-
// Best-effort: another process may have created it, or the warning banner
|
|
197
|
-
// will surface that no configuration is active.
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Resolves the rule that applies to a command against the configured rules.
|
|
203
|
-
*
|
|
204
|
-
* Returns `null` when no rule matches, in which case the caller should let the
|
|
205
|
-
* command through. When multiple rules match, the longest pattern wins so
|
|
206
|
-
* that narrow rules can override broader ones.
|
|
207
|
-
*
|
|
208
|
-
* @param command - The bash command to evaluate against the rules
|
|
209
|
-
* @param rules - A map of pattern strings to actions
|
|
210
|
-
* @returns The matched pattern and action, or `null` if no rule matches
|
|
211
|
-
*/
|
|
212
|
-
export function resolveRule(command: string, rules: Record<string, Action>): RuleMatch | null {
|
|
213
|
-
let bestPattern: string | null = null;
|
|
214
|
-
for (const pattern of Object.keys(rules)) {
|
|
215
|
-
if (!command.includes(pattern)) continue;
|
|
216
|
-
if (bestPattern === null || pattern.length > bestPattern.length) {
|
|
217
|
-
bestPattern = pattern;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
if (bestPattern === null) return null;
|
|
221
|
-
return { pattern: bestPattern, action: rules[bestPattern] };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** Returns only the action selected by {@link resolveRule}. */
|
|
225
|
-
export function resolveAction(command: string, rules: Record<string, Action>): Action | null {
|
|
226
|
-
return resolveRule(command, rules)?.action ?? null;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** Converts terminal controls and bidirectional formatting characters to visible escapes. */
|
|
230
|
-
function escapeUnsafeDisplayCharacter(character: string): string {
|
|
231
|
-
const codePoint = character.codePointAt(0);
|
|
232
|
-
if (codePoint === undefined) return "";
|
|
37
|
+
function reportHerdrBlocked(pi: ExtensionAPI, ctx: { mode?: string }, active: boolean): void {
|
|
233
38
|
if (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
39
|
+
ctx.mode !== "tui" ||
|
|
40
|
+
process.env.HERDR_ENV !== "1" ||
|
|
41
|
+
!process.env.HERDR_SOCKET_PATH ||
|
|
42
|
+
!process.env.HERDR_PANE_ID
|
|
237
43
|
) {
|
|
238
|
-
return
|
|
239
|
-
}
|
|
240
|
-
return character;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
interface MatchRange {
|
|
244
|
-
start: number;
|
|
245
|
-
end: number;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** Finds non-overlapping raw match ranges before any display escaping occurs. */
|
|
249
|
-
function findMatchRanges(command: string, pattern?: string): MatchRange[] {
|
|
250
|
-
if (!pattern) return [];
|
|
251
|
-
const ranges: MatchRange[] = [];
|
|
252
|
-
let offset = 0;
|
|
253
|
-
while (offset <= command.length - pattern.length) {
|
|
254
|
-
const start = command.indexOf(pattern, offset);
|
|
255
|
-
if (start === -1) break;
|
|
256
|
-
ranges.push({ start, end: start + pattern.length });
|
|
257
|
-
offset = start + pattern.length;
|
|
44
|
+
return;
|
|
258
45
|
}
|
|
259
|
-
return ranges;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** Produces bounded terminal-safe text, optionally marking raw matched spans. */
|
|
263
|
-
function renderCommandForDisplay(command: string, pattern?: string): string {
|
|
264
|
-
const ranges = findMatchRanges(command, pattern);
|
|
265
|
-
let rangeIndex = 0;
|
|
266
|
-
let activeRange: MatchRange | undefined;
|
|
267
|
-
let output = "";
|
|
268
|
-
let lineCount = 1;
|
|
269
|
-
let offset = 0;
|
|
270
46
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
rendered = "\n";
|
|
282
|
-
} else {
|
|
283
|
-
const character = String.fromCodePoint(codePoint!);
|
|
284
|
-
rawEnd = offset + character.length;
|
|
285
|
-
rendered = character === "\n" ? character : escapeUnsafeDisplayCharacter(character);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const nextRange = ranges[rangeIndex];
|
|
289
|
-
const wasInsideRange = activeRange !== undefined;
|
|
290
|
-
const opensRange =
|
|
291
|
-
activeRange === undefined &&
|
|
292
|
-
nextRange !== undefined &&
|
|
293
|
-
nextRange.start < rawEnd &&
|
|
294
|
-
nextRange.end > rawStart;
|
|
295
|
-
if (opensRange) activeRange = nextRange;
|
|
296
|
-
const closesRange = activeRange !== undefined && activeRange.end <= rawEnd;
|
|
297
|
-
const decorated = `${opensRange ? "»" : ""}${rendered}${closesRange ? "«" : ""}`;
|
|
298
|
-
|
|
299
|
-
if (rendered === "\n") {
|
|
300
|
-
if (lineCount >= MAX_DISPLAY_COMMAND_LINES) return truncate(wasInsideRange);
|
|
301
|
-
lineCount += 1;
|
|
302
|
-
}
|
|
303
|
-
if (output.length + decorated.length > MAX_DISPLAY_COMMAND_CHARACTERS) {
|
|
304
|
-
return truncate(wasInsideRange);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
output += decorated;
|
|
308
|
-
if (closesRange) {
|
|
309
|
-
activeRange = undefined;
|
|
310
|
-
rangeIndex += 1;
|
|
311
|
-
}
|
|
312
|
-
offset = rawEnd;
|
|
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.
|
|
50
|
+
try {
|
|
51
|
+
pi.events.emit("herdr:blocked", {
|
|
52
|
+
active,
|
|
53
|
+
label: HERDR_BLOCKED_LABEL,
|
|
54
|
+
});
|
|
55
|
+
} catch {
|
|
56
|
+
// Herdr is an optional host integration.
|
|
313
57
|
}
|
|
314
|
-
return output;
|
|
315
58
|
}
|
|
316
59
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Formats a rule match as a terminal-safe, human-readable string.
|
|
339
|
-
*
|
|
340
|
-
* @param match - The rule match to format
|
|
341
|
-
* @returns A string representation showing the pattern and action
|
|
342
|
-
*/
|
|
343
|
-
function formatRule(match: RuleMatch): string {
|
|
344
|
-
return formatCommandForDisplay(
|
|
345
|
-
`${JSON.stringify(match.pattern)}: ${JSON.stringify(match.action)}`,
|
|
346
|
-
);
|
|
347
|
-
}
|
|
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";
|
|
348
79
|
|
|
349
80
|
/** Gate the built-in `bash` tool against the user-provided rules. */
|
|
350
81
|
export default function piGate(pi: ExtensionAPI): void {
|
|
@@ -398,11 +129,17 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
398
129
|
terminate: true,
|
|
399
130
|
};
|
|
400
131
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
+
}
|
|
406
143
|
if (choice !== "Allow") {
|
|
407
144
|
return {
|
|
408
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
|
+
}
|