@zeldrisho/pi-gate 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -1
- package/README.md +17 -2
- package/package.json +1 -1
- package/src/index.ts +67 -11
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.1.1] - 2026-09-01
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- identify the matched rule in confirmation prompts, block notifications, and agent-visible error results
|
|
15
|
+
|
|
10
16
|
## [0.1.0] - 2026-08-25
|
|
11
17
|
|
|
12
18
|
### Added
|
|
@@ -17,5 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
17
23
|
- support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
|
|
18
24
|
- block rather than auto-approve in non-UI modes
|
|
19
25
|
|
|
20
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.
|
|
26
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.1...HEAD
|
|
27
|
+
[0.1.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.0...pi-gate-v0.1.1
|
|
21
28
|
[0.1.0]: https://github.com/zeldrisho/pi-packages/releases/tag/pi-gate-v0.1.0
|
package/README.md
CHANGED
|
@@ -48,11 +48,26 @@ After editing, run `/reload` to apply the new rules in the current session.
|
|
|
48
48
|
## Behavior
|
|
49
49
|
|
|
50
50
|
- 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`).
|
|
52
|
-
- `block` rules never ask and always deny.
|
|
51
|
+
- `prompt` rules ask the user with a two-button confirmation (`Allow` / `Deny`). The dialog identifies the matched rule.
|
|
52
|
+
- `block` rules never ask and always deny. The warning identifies the matched rule.
|
|
53
53
|
- `allow` rules are explicit pass-throughs, useful for carving out exceptions.
|
|
54
54
|
- In non-interactive modes (`-p`, JSON), `prompt` and `block` rules both block the command instead of auto-approving.
|
|
55
55
|
|
|
56
|
+
### What the agent sees
|
|
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.
|
|
64
|
+
|
|
65
|
+
For example, a blocked `sudo apt update` call reports:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
pi-gate: command blocked by rule "sudo": "block"
|
|
69
|
+
```
|
|
70
|
+
|
|
56
71
|
## Uninstall
|
|
57
72
|
|
|
58
73
|
```bash
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -36,6 +36,11 @@ export interface GateConfig {
|
|
|
36
36
|
operations: Record<string, Action>;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export interface RuleMatch {
|
|
40
|
+
pattern: string;
|
|
41
|
+
action: Action;
|
|
42
|
+
}
|
|
43
|
+
|
|
39
44
|
const CONFIG_FILE_NAME = "pi-gate.json";
|
|
40
45
|
const EXAMPLE_FILE_NAME = "pi-gate.json.example";
|
|
41
46
|
const ACTIONS: readonly Action[] = ["prompt", "block", "allow"] as const;
|
|
@@ -48,18 +53,39 @@ const EXAMPLE_CONFIG: GateConfig = {
|
|
|
48
53
|
},
|
|
49
54
|
};
|
|
50
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Returns the directory where pi-gate configuration files are stored.
|
|
58
|
+
*
|
|
59
|
+
* @returns The absolute path to the agent configuration directory
|
|
60
|
+
*/
|
|
51
61
|
function agentDir(): string {
|
|
52
62
|
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
53
63
|
}
|
|
54
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Returns the full path to the pi-gate configuration file.
|
|
67
|
+
*
|
|
68
|
+
* @returns The absolute path to pi-gate.json
|
|
69
|
+
*/
|
|
55
70
|
function configPath(): string {
|
|
56
71
|
return join(agentDir(), CONFIG_FILE_NAME);
|
|
57
72
|
}
|
|
58
73
|
|
|
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
|
+
*/
|
|
59
79
|
function examplePath(): string {
|
|
60
80
|
return join(agentDir(), EXAMPLE_FILE_NAME);
|
|
61
81
|
}
|
|
62
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Type guard that checks if an unknown value is a valid Action.
|
|
85
|
+
*
|
|
86
|
+
* @param value - The value to check
|
|
87
|
+
* @returns `true` if the value is one of "prompt", "block", or "allow"
|
|
88
|
+
*/
|
|
63
89
|
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- type-guard helper intentionally narrows untrusted JSON values at the parse boundary
|
|
64
90
|
function isAction(value: unknown): value is Action {
|
|
65
91
|
return (
|
|
@@ -71,6 +97,12 @@ function isAction(value: unknown): value is Action {
|
|
|
71
97
|
);
|
|
72
98
|
}
|
|
73
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Type guard that checks if an unknown value is a plain JSON object.
|
|
102
|
+
*
|
|
103
|
+
* @param value - The value to check
|
|
104
|
+
* @returns `true` if the value is a non-null object and not an array
|
|
105
|
+
*/
|
|
74
106
|
// 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
|
|
75
107
|
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
76
108
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -147,13 +179,17 @@ export function ensureExampleConfig(): void {
|
|
|
147
179
|
}
|
|
148
180
|
|
|
149
181
|
/**
|
|
150
|
-
* Resolves the
|
|
182
|
+
* Resolves the rule that applies to a command against the configured rules.
|
|
151
183
|
*
|
|
152
184
|
* Returns `null` when no rule matches, in which case the caller should let the
|
|
153
185
|
* command through. When multiple rules match, the longest pattern wins so
|
|
154
186
|
* that narrow rules can override broader ones.
|
|
187
|
+
*
|
|
188
|
+
* @param command - The bash command to evaluate against the rules
|
|
189
|
+
* @param rules - A map of pattern strings to actions
|
|
190
|
+
* @returns The matched pattern and action, or `null` if no rule matches
|
|
155
191
|
*/
|
|
156
|
-
export function
|
|
192
|
+
export function resolveRule(command: string, rules: Record<string, Action>): RuleMatch | null {
|
|
157
193
|
let bestPattern: string | null = null;
|
|
158
194
|
for (const pattern of Object.keys(rules)) {
|
|
159
195
|
if (!command.includes(pattern)) continue;
|
|
@@ -162,7 +198,22 @@ export function resolveAction(command: string, rules: Record<string, Action>): A
|
|
|
162
198
|
}
|
|
163
199
|
}
|
|
164
200
|
if (bestPattern === null) return null;
|
|
165
|
-
return rules[bestPattern];
|
|
201
|
+
return { pattern: bestPattern, action: rules[bestPattern] };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Returns only the action selected by {@link resolveRule}. */
|
|
205
|
+
export function resolveAction(command: string, rules: Record<string, Action>): Action | null {
|
|
206
|
+
return resolveRule(command, rules)?.action ?? null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Formats a rule match as a human-readable string for display in prompts and notifications.
|
|
211
|
+
*
|
|
212
|
+
* @param match - The rule match to format
|
|
213
|
+
* @returns A string representation showing the pattern and action
|
|
214
|
+
*/
|
|
215
|
+
function formatRule(match: RuleMatch): string {
|
|
216
|
+
return `${JSON.stringify(match.pattern)}: ${JSON.stringify(match.action)}`;
|
|
166
217
|
}
|
|
167
218
|
|
|
168
219
|
/** Gate the built-in `bash` tool against the user-provided rules. */
|
|
@@ -197,26 +248,31 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
197
248
|
if (event.toolName !== "bash") return undefined;
|
|
198
249
|
if (!isToolCallEventType("bash", event)) return undefined;
|
|
199
250
|
const command = event.input.command;
|
|
200
|
-
const
|
|
201
|
-
if (
|
|
251
|
+
const match = resolveRule(command, config.operations);
|
|
252
|
+
if (match === null || match.action === "allow") return undefined;
|
|
253
|
+
const rule = formatRule(match);
|
|
202
254
|
|
|
203
|
-
if (action === "block") {
|
|
255
|
+
if (match.action === "block") {
|
|
256
|
+
const reason = `pi-gate: command blocked by rule ${rule}`;
|
|
204
257
|
if (ctx.hasUI) {
|
|
205
|
-
ctx.ui.notify(
|
|
258
|
+
ctx.ui.notify(reason, "warning");
|
|
206
259
|
}
|
|
207
|
-
return { block: true, reason
|
|
260
|
+
return { block: true, reason };
|
|
208
261
|
}
|
|
209
262
|
|
|
210
263
|
// action === "prompt"
|
|
211
264
|
if (!ctx.hasUI) {
|
|
212
|
-
return {
|
|
265
|
+
return {
|
|
266
|
+
block: true,
|
|
267
|
+
reason: `pi-gate: command blocked because rule ${rule} requires a prompt, but no UI is available`,
|
|
268
|
+
};
|
|
213
269
|
}
|
|
214
270
|
const choice = await ctx.ui.select(
|
|
215
|
-
`pi-gate: allow this command?\n\n ${command}\n\
|
|
271
|
+
`pi-gate: allow this command?\n\n ${command}\n\nMatched rule: ${rule}`,
|
|
216
272
|
["Allow", "Deny"],
|
|
217
273
|
);
|
|
218
274
|
if (choice !== "Allow") {
|
|
219
|
-
return { block: true, reason:
|
|
275
|
+
return { block: true, reason: `pi-gate: command denied by user after matching rule ${rule}` };
|
|
220
276
|
}
|
|
221
277
|
return undefined;
|
|
222
278
|
});
|