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