@zeldrisho/pi-gate 0.1.1 → 0.3.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 +24 -1
- package/README.md +11 -11
- package/config.schema.json +36 -0
- package/package.json +2 -1
- package/src/index.ts +176 -40
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-09-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- ship a JSON Schema for `pi-gate.json` and reference it from newly seeded configurations for editor completion and validation
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- wrap visible matched-rule occurrences in confirmation-dialog command text so guarded operations are easier to identify
|
|
19
|
+
- escape terminal and bidirectional control characters and bound long or multiline command text in confirmation dialogs without changing the command executed
|
|
20
|
+
- ignore empty or oversized rule patterns and load at most 1,000 valid rules
|
|
21
|
+
- document and test RPC-host confirmation behavior
|
|
22
|
+
|
|
23
|
+
## [0.2.0] - 2026-09-02
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- request termination of the current agent turn when a command is blocked, denied, dismissed, or times out; mixed parallel batches containing allowed calls may continue
|
|
28
|
+
- auto-deny interactive prompts after a configurable `promptTimeoutMs` (30 seconds by default)
|
|
29
|
+
- create `pi-gate.json` directly on first load with the default timeout and starter prompt/block/allow rules instead of writing an example file
|
|
30
|
+
|
|
10
31
|
## [0.1.1] - 2026-09-01
|
|
11
32
|
|
|
12
33
|
### Changed
|
|
@@ -23,6 +44,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
23
44
|
- support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
|
|
24
45
|
- block rather than auto-approve in non-UI modes
|
|
25
46
|
|
|
26
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.
|
|
47
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.3.0...HEAD
|
|
48
|
+
[0.3.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.2.0...pi-gate-v0.3.0
|
|
49
|
+
[0.2.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.1...pi-gate-v0.2.0
|
|
27
50
|
[0.1.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.0...pi-gate-v0.1.1
|
|
28
51
|
[0.1.0]: https://github.com/zeldrisho/pi-packages/releases/tag/pi-gate-v0.1.0
|
package/README.md
CHANGED
|
@@ -16,42 +16,42 @@ pi install -l npm:@zeldrisho/pi-gate
|
|
|
16
16
|
|
|
17
17
|
## Configure
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
On first load, the extension creates `~/.pi/agent/pi-gate.json` with a default 30-second prompt timeout, starter rules, and a `$schema` reference for editor validation. Existing configuration files are never overwritten.
|
|
20
20
|
|
|
21
|
-
|
|
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
22
|
|
|
23
|
-
|
|
24
|
-
cp ~/.pi/agent/pi-gate.json.example ~/.pi/agent/pi-gate.json
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
Then edit `~/.pi/agent/pi-gate.json` to add your rules. Each rule is a substring pattern and one of three actions:
|
|
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:
|
|
28
24
|
|
|
29
25
|
- `prompt` — ask the user to allow or deny the command
|
|
30
26
|
- `block` — deny the command without asking
|
|
31
|
-
- `allow` — explicitly allow
|
|
27
|
+
- `allow` — explicitly allow an exception to a broader matching rule
|
|
32
28
|
|
|
33
29
|
```json
|
|
34
30
|
{
|
|
31
|
+
"$schema": "https://raw.githubusercontent.com/zeldrisho/pi-packages/main/packages/pi-gate/config.schema.json",
|
|
32
|
+
"promptTimeoutMs": 30000,
|
|
35
33
|
"operations": {
|
|
36
34
|
"rm -rf": "prompt",
|
|
37
35
|
"sudo": "prompt",
|
|
36
|
+
"sudo apt update": "allow",
|
|
38
37
|
"chmod 777": "block",
|
|
39
38
|
"corepack enable": "block"
|
|
40
39
|
}
|
|
41
40
|
}
|
|
42
41
|
```
|
|
43
42
|
|
|
44
|
-
Patterns use simple substring matching.
|
|
43
|
+
Patterns use simple substring matching. Empty patterns and patterns longer than 1,024 characters are ignored, and at most 1,000 valid rules are loaded. Commands with no matching rule are allowed automatically. The explicit `allow` action is only needed to carve out an exception: in the starter configuration, `sudo apt update` is allowed even though the broader `sudo` rule prompts. When several patterns match, the longest pattern wins.
|
|
45
44
|
|
|
46
45
|
After editing, run `/reload` to apply the new rules in the current session.
|
|
47
46
|
|
|
48
47
|
## Behavior
|
|
49
48
|
|
|
50
49
|
- Only the built-in `bash` tool is gated. Other tools pass through unchanged.
|
|
51
|
-
- `prompt` rules ask the user with a two-button confirmation (`Allow` / `Deny`). The dialog identifies the matched rule.
|
|
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.
|
|
52
51
|
- `block` rules never ask and always deny. The warning identifies the matched rule.
|
|
53
52
|
- `allow` rules are explicit pass-throughs, useful for carving out exceptions.
|
|
54
|
-
-
|
|
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.
|
|
55
55
|
|
|
56
56
|
### What the agent sees
|
|
57
57
|
|
|
@@ -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.3.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/index.ts
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
* pi-gate Extension
|
|
3
3
|
*
|
|
4
4
|
* Intercepts the built-in `bash` tool and gates each command against a
|
|
5
|
-
* user-provided JSON configuration. Without a configuration file,
|
|
6
|
-
*
|
|
7
|
-
* reminding the user to create one.
|
|
5
|
+
* user-provided JSON configuration. Without a configuration file, the
|
|
6
|
+
* extension creates one with starter rules and a default prompt timeout.
|
|
8
7
|
*
|
|
9
8
|
* Configuration file: `~/.pi/agent/pi-gate.json`
|
|
10
9
|
*
|
|
11
|
-
* The configuration has
|
|
12
|
-
*
|
|
10
|
+
* The configuration has an `operations` section, which maps a substring
|
|
11
|
+
* pattern to one of three actions, and an optional `promptTimeoutMs` setting:
|
|
13
12
|
*
|
|
14
13
|
* - `prompt` - ask the user to allow or deny the command
|
|
15
14
|
* - `block` - deny the command without asking
|
|
@@ -18,11 +17,13 @@
|
|
|
18
17
|
* When multiple patterns match a command, the longest pattern wins, so a
|
|
19
18
|
* narrow `allow` rule can override a broader `prompt` or `block` rule.
|
|
20
19
|
*
|
|
21
|
-
* On first run,
|
|
22
|
-
*
|
|
20
|
+
* On first run, a configuration with the default prompt timeout and starter
|
|
21
|
+
* operation rules is written to `~/.pi/agent/pi-gate.json`.
|
|
23
22
|
*
|
|
24
23
|
* Non-UI modes (print, JSON) never auto-approve. A `prompt` or `block` rule
|
|
25
|
-
* in non-UI mode always blocks the
|
|
24
|
+
* in non-UI mode always blocks and requests termination of the agent turn.
|
|
25
|
+
* Interactive prompts auto-deny after `promptTimeoutMs` rather than waiting
|
|
26
|
+
* indefinitely.
|
|
26
27
|
*/
|
|
27
28
|
|
|
28
29
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -34,6 +35,7 @@ export type Action = "prompt" | "block" | "allow";
|
|
|
34
35
|
|
|
35
36
|
export interface GateConfig {
|
|
36
37
|
operations: Record<string, Action>;
|
|
38
|
+
promptTimeoutMs: number;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export interface RuleMatch {
|
|
@@ -42,14 +44,28 @@ export interface RuleMatch {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
const CONFIG_FILE_NAME = "pi-gate.json";
|
|
45
|
-
const
|
|
47
|
+
export const CONFIG_SCHEMA_URL =
|
|
48
|
+
"https://raw.githubusercontent.com/zeldrisho/pi-packages/main/packages/pi-gate/config.schema.json";
|
|
46
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
|
+
]);
|
|
47
60
|
|
|
48
|
-
const
|
|
61
|
+
const DEFAULT_CONFIG: GateConfig = {
|
|
62
|
+
promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS,
|
|
49
63
|
operations: {
|
|
50
64
|
"rm -rf": "prompt",
|
|
51
65
|
sudo: "prompt",
|
|
66
|
+
"sudo apt update": "allow",
|
|
52
67
|
"chmod 777": "block",
|
|
68
|
+
"corepack enable": "block",
|
|
53
69
|
},
|
|
54
70
|
};
|
|
55
71
|
|
|
@@ -71,15 +87,6 @@ function configPath(): string {
|
|
|
71
87
|
return join(agentDir(), CONFIG_FILE_NAME);
|
|
72
88
|
}
|
|
73
89
|
|
|
74
|
-
/**
|
|
75
|
-
* Returns the full path to the example pi-gate configuration file.
|
|
76
|
-
*
|
|
77
|
-
* @returns The absolute path to pi-gate.json.example
|
|
78
|
-
*/
|
|
79
|
-
function examplePath(): string {
|
|
80
|
-
return join(agentDir(), EXAMPLE_FILE_NAME);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
90
|
/**
|
|
84
91
|
* Type guard that checks if an unknown value is a valid Action.
|
|
85
92
|
*
|
|
@@ -118,25 +125,38 @@ function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
|
118
125
|
*/
|
|
119
126
|
export function parseConfig(content: string): GateConfig {
|
|
120
127
|
const rules: Record<string, Action> = {};
|
|
128
|
+
let acceptedRuleCount = 0;
|
|
121
129
|
let parsed: unknown;
|
|
122
130
|
try {
|
|
123
131
|
parsed = JSON.parse(content);
|
|
124
132
|
} catch {
|
|
125
|
-
return { operations: rules };
|
|
133
|
+
return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
126
134
|
}
|
|
127
135
|
if (!isJsonObject(parsed)) {
|
|
128
|
-
return { operations: rules };
|
|
136
|
+
return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
129
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;
|
|
130
147
|
const operations = parsed["operations"];
|
|
131
148
|
if (!isJsonObject(operations)) {
|
|
132
|
-
return { operations: rules };
|
|
149
|
+
return { operations: rules, promptTimeoutMs };
|
|
133
150
|
}
|
|
134
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;
|
|
135
154
|
if (isAction(action)) {
|
|
136
155
|
rules[pattern] = action;
|
|
156
|
+
acceptedRuleCount += 1;
|
|
137
157
|
}
|
|
138
158
|
}
|
|
139
|
-
return { operations: rules };
|
|
159
|
+
return { operations: rules, promptTimeoutMs };
|
|
140
160
|
}
|
|
141
161
|
|
|
142
162
|
/**
|
|
@@ -146,35 +166,35 @@ export function parseConfig(content: string): GateConfig {
|
|
|
146
166
|
export function loadConfig(): GateConfig {
|
|
147
167
|
const path = configPath();
|
|
148
168
|
if (!existsSync(path)) {
|
|
149
|
-
return { operations: {} };
|
|
169
|
+
return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
150
170
|
}
|
|
151
171
|
let content: string;
|
|
152
172
|
try {
|
|
153
173
|
content = readFileSync(path, "utf-8");
|
|
154
174
|
} catch {
|
|
155
|
-
return { operations: {} };
|
|
175
|
+
return { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
156
176
|
}
|
|
157
177
|
return parseConfig(content);
|
|
158
178
|
}
|
|
159
179
|
|
|
160
180
|
/**
|
|
161
|
-
* Writes the
|
|
162
|
-
*
|
|
163
|
-
* supported shape.
|
|
181
|
+
* Writes the default configuration on first run. Existing configuration files
|
|
182
|
+
* are never overwritten.
|
|
164
183
|
*/
|
|
165
|
-
export function
|
|
166
|
-
const path =
|
|
184
|
+
export function ensureConfig(): void {
|
|
185
|
+
const path = configPath();
|
|
167
186
|
if (existsSync(path)) return;
|
|
168
187
|
try {
|
|
169
188
|
mkdirSync(agentDir(), { recursive: true });
|
|
170
189
|
} catch {
|
|
171
190
|
return;
|
|
172
191
|
}
|
|
173
|
-
const
|
|
192
|
+
const content = JSON.stringify({ $schema: CONFIG_SCHEMA_URL, ...DEFAULT_CONFIG }, null, 2) + "\n";
|
|
174
193
|
try {
|
|
175
|
-
writeFileSync(path,
|
|
194
|
+
writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
|
|
176
195
|
} catch {
|
|
177
|
-
// Best-effort:
|
|
196
|
+
// Best-effort: another process may have created it, or the warning banner
|
|
197
|
+
// will surface that no configuration is active.
|
|
178
198
|
}
|
|
179
199
|
}
|
|
180
200
|
|
|
@@ -206,19 +226,129 @@ export function resolveAction(command: string, rules: Record<string, Action>): A
|
|
|
206
226
|
return resolveRule(command, rules)?.action ?? null;
|
|
207
227
|
}
|
|
208
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 "";
|
|
233
|
+
if (
|
|
234
|
+
codePoint < 0x20 ||
|
|
235
|
+
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
|
236
|
+
BIDI_CONTROL_CODE_POINTS.has(codePoint)
|
|
237
|
+
) {
|
|
238
|
+
return `\\u{${codePoint.toString(16).padStart(4, "0")}}`;
|
|
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;
|
|
258
|
+
}
|
|
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
|
+
|
|
271
|
+
const truncate = (closeActiveRange = activeRange !== undefined): string =>
|
|
272
|
+
output + (closeActiveRange ? "«" : "") + DISPLAY_TRUNCATION_MARKER;
|
|
273
|
+
|
|
274
|
+
while (offset < command.length) {
|
|
275
|
+
const rawStart = offset;
|
|
276
|
+
let rawEnd: number;
|
|
277
|
+
let rendered: string;
|
|
278
|
+
const codePoint = command.codePointAt(offset);
|
|
279
|
+
if (codePoint === 0x0d) {
|
|
280
|
+
rawEnd = offset + (command.codePointAt(offset + 1) === 0x0a ? 2 : 1);
|
|
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;
|
|
313
|
+
}
|
|
314
|
+
return output;
|
|
315
|
+
}
|
|
316
|
+
|
|
209
317
|
/**
|
|
210
|
-
*
|
|
318
|
+
* Produces bounded terminal-safe text for a command without changing the command that is executed.
|
|
319
|
+
*/
|
|
320
|
+
export function formatCommandForDisplay(command: string): string {
|
|
321
|
+
return renderCommandForDisplay(command);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Marks raw matched-rule occurrences while producing terminal-safe command text. */
|
|
325
|
+
export function highlightRuleForDisplay(command: string, pattern: string): string {
|
|
326
|
+
return renderCommandForDisplay(command, pattern);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Indents every line of terminal-safe command text for the confirmation dialog. */
|
|
330
|
+
function formatPromptCommand(command: string, pattern: string): string {
|
|
331
|
+
return highlightRuleForDisplay(command, pattern)
|
|
332
|
+
.split("\n")
|
|
333
|
+
.map((line) => ` ${line}`)
|
|
334
|
+
.join("\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Formats a rule match as a terminal-safe, human-readable string.
|
|
211
339
|
*
|
|
212
340
|
* @param match - The rule match to format
|
|
213
341
|
* @returns A string representation showing the pattern and action
|
|
214
342
|
*/
|
|
215
343
|
function formatRule(match: RuleMatch): string {
|
|
216
|
-
return
|
|
344
|
+
return formatCommandForDisplay(
|
|
345
|
+
`${JSON.stringify(match.pattern)}: ${JSON.stringify(match.action)}`,
|
|
346
|
+
);
|
|
217
347
|
}
|
|
218
348
|
|
|
219
349
|
/** Gate the built-in `bash` tool against the user-provided rules. */
|
|
220
350
|
export default function piGate(pi: ExtensionAPI): void {
|
|
221
|
-
|
|
351
|
+
ensureConfig();
|
|
222
352
|
const config = loadConfig();
|
|
223
353
|
|
|
224
354
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -238,7 +368,7 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
238
368
|
}
|
|
239
369
|
} else {
|
|
240
370
|
ctx.ui.notify(
|
|
241
|
-
`pi-gate:
|
|
371
|
+
`pi-gate: could not create configuration at ${path}; all commands are allowed until that file is created.`,
|
|
242
372
|
"warning",
|
|
243
373
|
);
|
|
244
374
|
}
|
|
@@ -257,7 +387,7 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
257
387
|
if (ctx.hasUI) {
|
|
258
388
|
ctx.ui.notify(reason, "warning");
|
|
259
389
|
}
|
|
260
|
-
return { block: true, reason };
|
|
390
|
+
return { block: true, reason, terminate: true };
|
|
261
391
|
}
|
|
262
392
|
|
|
263
393
|
// action === "prompt"
|
|
@@ -265,14 +395,20 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
265
395
|
return {
|
|
266
396
|
block: true,
|
|
267
397
|
reason: `pi-gate: command blocked because rule ${rule} requires a prompt, but no UI is available`,
|
|
398
|
+
terminate: true,
|
|
268
399
|
};
|
|
269
400
|
}
|
|
270
401
|
const choice = await ctx.ui.select(
|
|
271
|
-
`pi-gate: allow this command?\n\n
|
|
402
|
+
`pi-gate: allow this command?\n\n${formatPromptCommand(command, match.pattern)}\n\nMatched rule: ${rule}\nMatched command text is wrapped in »…«`,
|
|
272
403
|
["Allow", "Deny"],
|
|
404
|
+
{ timeout: config.promptTimeoutMs },
|
|
273
405
|
);
|
|
274
406
|
if (choice !== "Allow") {
|
|
275
|
-
return {
|
|
407
|
+
return {
|
|
408
|
+
block: true,
|
|
409
|
+
reason: `pi-gate: command denied, dismissed, or timed out after matching rule ${rule}`,
|
|
410
|
+
terminate: true,
|
|
411
|
+
};
|
|
276
412
|
}
|
|
277
413
|
return undefined;
|
|
278
414
|
});
|