@zeldrisho/pi-gate 0.0.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 +21 -0
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/package.json +61 -0
- package/src/index.ts +223 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-08-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- gate bash tool calls against a user-provided JSON configuration
|
|
15
|
+
- ship without any default rules and notify the user to configure after install
|
|
16
|
+
- write a `pi-gate.json.example` next to the config on first run
|
|
17
|
+
- support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
|
|
18
|
+
- block rather than auto-approve in non-UI modes
|
|
19
|
+
|
|
20
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.0...HEAD
|
|
21
|
+
[0.1.0]: https://github.com/zeldrisho/pi-packages/releases/tag/pi-gate-v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zeldris
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @zeldrisho/pi-gate
|
|
2
|
+
|
|
3
|
+
Pi extension that blocks or confirms bash tool calls based on a user-provided JSON configuration.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@zeldrisho/pi-gate
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Install only for the current project:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install -l npm:@zeldrisho/pi-gate
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configure
|
|
18
|
+
|
|
19
|
+
The extension ships with **no default rules**. On first load, an example configuration is written to `~/.pi/agent/pi-gate.json.example` and a warning notifies you that no configuration is active. Every bash command is allowed until you create a configuration.
|
|
20
|
+
|
|
21
|
+
Copy the example and edit it:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
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:
|
|
28
|
+
|
|
29
|
+
- `prompt` — ask the user to allow or deny the command
|
|
30
|
+
- `block` — deny the command without asking
|
|
31
|
+
- `allow` — explicitly allow (use to carve out an exception to a broader rule)
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"operations": {
|
|
36
|
+
"rm -rf": "prompt",
|
|
37
|
+
"sudo": "prompt",
|
|
38
|
+
"chmod 777": "block",
|
|
39
|
+
"corepack enable": "block"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Patterns use simple substring matching. When several patterns match the same command, the longest pattern wins, so a narrow `allow` rule can override a broader `block` or `prompt` rule.
|
|
45
|
+
|
|
46
|
+
After editing, run `/reload` to apply the new rules in the current session.
|
|
47
|
+
|
|
48
|
+
## Behavior
|
|
49
|
+
|
|
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.
|
|
53
|
+
- `allow` rules are explicit pass-throughs, useful for carving out exceptions.
|
|
54
|
+
- In non-interactive modes (`-p`, JSON), `prompt` and `block` rules both block the command instead of auto-approving.
|
|
55
|
+
|
|
56
|
+
## Uninstall
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pi remove npm:@zeldrisho/pi-gate
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For a project-local installation:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pi remove -l npm:@zeldrisho/pi-gate
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
[MIT](LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zeldrisho/pi-gate",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Pi extension that blocks or confirms bash commands based on a user-provided JSON configuration",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"command-blocking",
|
|
7
|
+
"command-confirmation",
|
|
8
|
+
"pi",
|
|
9
|
+
"pi-coding-agent",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi-package",
|
|
12
|
+
"tool-gate"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/zeldrisho/pi-packages/tree/main/packages/pi-gate#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/zeldrisho/pi-packages/issues"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Zeldris",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/zeldrisho/pi-packages.git",
|
|
23
|
+
"directory": "packages/pi-gate"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md",
|
|
28
|
+
"CHANGELOG.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"type": "module",
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
37
|
+
"typescript": "^7.0.2",
|
|
38
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.0",
|
|
39
|
+
"vite-plus": "0.3.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=24"
|
|
46
|
+
},
|
|
47
|
+
"pi": {
|
|
48
|
+
"extensions": [
|
|
49
|
+
"./src/index.ts"
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"check": "vp check",
|
|
54
|
+
"test": "vp test",
|
|
55
|
+
"test:watch": "vp test --watch",
|
|
56
|
+
"lint": "vp lint",
|
|
57
|
+
"lint:fix": "vp lint --fix",
|
|
58
|
+
"format": "vp fmt --write",
|
|
59
|
+
"typecheck": "vp check --no-fmt --no-lint"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-gate Extension
|
|
3
|
+
*
|
|
4
|
+
* Intercepts the built-in `bash` tool and gates each command against a
|
|
5
|
+
* user-provided JSON configuration. Without a configuration file, every
|
|
6
|
+
* command is allowed and the extension posts a warning on `session_start`
|
|
7
|
+
* reminding the user to create one.
|
|
8
|
+
*
|
|
9
|
+
* Configuration file: `~/.pi/agent/pi-gate.json`
|
|
10
|
+
*
|
|
11
|
+
* The configuration has a single section, `operations`, which maps a
|
|
12
|
+
* substring pattern to one of three actions:
|
|
13
|
+
*
|
|
14
|
+
* - `prompt` - ask the user to allow or deny the command
|
|
15
|
+
* - `block` - deny the command without asking
|
|
16
|
+
* - `allow` - explicitly allow (use to carve out an exception)
|
|
17
|
+
*
|
|
18
|
+
* When multiple patterns match a command, the longest pattern wins, so a
|
|
19
|
+
* narrow `allow` rule can override a broader `prompt` or `block` rule.
|
|
20
|
+
*
|
|
21
|
+
* On first run, an example configuration is written next to the real one
|
|
22
|
+
* at `~/.pi/agent/pi-gate.json.example` to document the format.
|
|
23
|
+
*
|
|
24
|
+
* Non-UI modes (print, JSON) never auto-approve. A `prompt` or `block` rule
|
|
25
|
+
* in non-UI mode always blocks the command.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
32
|
+
|
|
33
|
+
export type Action = "prompt" | "block" | "allow";
|
|
34
|
+
|
|
35
|
+
export interface GateConfig {
|
|
36
|
+
operations: Record<string, Action>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const CONFIG_FILE_NAME = "pi-gate.json";
|
|
40
|
+
const EXAMPLE_FILE_NAME = "pi-gate.json.example";
|
|
41
|
+
const ACTIONS: readonly Action[] = ["prompt", "block", "allow"] as const;
|
|
42
|
+
|
|
43
|
+
const EXAMPLE_CONFIG: GateConfig = {
|
|
44
|
+
operations: {
|
|
45
|
+
"rm -rf": "prompt",
|
|
46
|
+
sudo: "prompt",
|
|
47
|
+
"chmod 777": "block",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function agentDir(): string {
|
|
52
|
+
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function configPath(): string {
|
|
56
|
+
return join(agentDir(), CONFIG_FILE_NAME);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function examplePath(): string {
|
|
60
|
+
return join(agentDir(), EXAMPLE_FILE_NAME);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- type-guard helper intentionally narrows untrusted JSON values at the parse boundary
|
|
64
|
+
function isAction(value: unknown): value is Action {
|
|
65
|
+
return (
|
|
66
|
+
typeof value === "string" &&
|
|
67
|
+
// SAFETY: ACTIONS is the source-of-truth tuple of supported action strings; widening
|
|
68
|
+
// it to readonly string[] lets Array.prototype.includes perform a runtime check
|
|
69
|
+
// before the type predicate narrows the value to Action.
|
|
70
|
+
(ACTIONS as readonly string[]).includes(value)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 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
|
+
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
76
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parses the user configuration file. Returns an empty configuration (no
|
|
81
|
+
* rules) when the file is missing, unreadable, malformed, or fails
|
|
82
|
+
* validation; an explicit empty `operations` object is also valid.
|
|
83
|
+
*
|
|
84
|
+
* Invalid entries are skipped so a single bad rule does not disable the
|
|
85
|
+
* entire gate; parsing continues for the remaining rules.
|
|
86
|
+
*/
|
|
87
|
+
export function parseConfig(content: string): GateConfig {
|
|
88
|
+
const rules: Record<string, Action> = {};
|
|
89
|
+
let parsed: unknown;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(content);
|
|
92
|
+
} catch {
|
|
93
|
+
return { operations: rules };
|
|
94
|
+
}
|
|
95
|
+
if (!isJsonObject(parsed)) {
|
|
96
|
+
return { operations: rules };
|
|
97
|
+
}
|
|
98
|
+
const operations = parsed["operations"];
|
|
99
|
+
if (!isJsonObject(operations)) {
|
|
100
|
+
return { operations: rules };
|
|
101
|
+
}
|
|
102
|
+
for (const [pattern, action] of Object.entries(operations)) {
|
|
103
|
+
if (isAction(action)) {
|
|
104
|
+
rules[pattern] = action;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { operations: rules };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Loads the configuration from disk. Returns an empty configuration when the
|
|
112
|
+
* file is missing; returns the parsed configuration otherwise.
|
|
113
|
+
*/
|
|
114
|
+
export function loadConfig(): GateConfig {
|
|
115
|
+
const path = configPath();
|
|
116
|
+
if (!existsSync(path)) {
|
|
117
|
+
return { operations: {} };
|
|
118
|
+
}
|
|
119
|
+
let content: string;
|
|
120
|
+
try {
|
|
121
|
+
content = readFileSync(path, "utf-8");
|
|
122
|
+
} catch {
|
|
123
|
+
return { operations: {} };
|
|
124
|
+
}
|
|
125
|
+
return parseConfig(content);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Writes the example configuration next to the real one. Existing example
|
|
130
|
+
* files are left alone so the user can rely on them to track the latest
|
|
131
|
+
* supported shape.
|
|
132
|
+
*/
|
|
133
|
+
export function ensureExampleConfig(): void {
|
|
134
|
+
const path = examplePath();
|
|
135
|
+
if (existsSync(path)) return;
|
|
136
|
+
try {
|
|
137
|
+
mkdirSync(agentDir(), { recursive: true });
|
|
138
|
+
} catch {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const example = JSON.stringify(EXAMPLE_CONFIG, null, 2) + "\n";
|
|
142
|
+
try {
|
|
143
|
+
writeFileSync(path, example, "utf-8");
|
|
144
|
+
} catch {
|
|
145
|
+
// Best-effort: the warning banner still surfaces the missing config.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolves the action that applies to a command against the configured rules.
|
|
151
|
+
*
|
|
152
|
+
* Returns `null` when no rule matches, in which case the caller should let the
|
|
153
|
+
* command through. When multiple rules match, the longest pattern wins so
|
|
154
|
+
* that narrow rules can override broader ones.
|
|
155
|
+
*/
|
|
156
|
+
export function resolveAction(command: string, rules: Record<string, Action>): Action | null {
|
|
157
|
+
let bestPattern: string | null = null;
|
|
158
|
+
for (const pattern of Object.keys(rules)) {
|
|
159
|
+
if (!command.includes(pattern)) continue;
|
|
160
|
+
if (bestPattern === null || pattern.length > bestPattern.length) {
|
|
161
|
+
bestPattern = pattern;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (bestPattern === null) return null;
|
|
165
|
+
return rules[bestPattern];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Gate the built-in `bash` tool against the user-provided rules. */
|
|
169
|
+
export default function piGate(pi: ExtensionAPI): void {
|
|
170
|
+
ensureExampleConfig();
|
|
171
|
+
const config = loadConfig();
|
|
172
|
+
|
|
173
|
+
pi.on("session_start", (_event, ctx) => {
|
|
174
|
+
const path = configPath();
|
|
175
|
+
const ruleCount = Object.keys(config.operations).length;
|
|
176
|
+
if (existsSync(path)) {
|
|
177
|
+
if (ruleCount === 0) {
|
|
178
|
+
ctx.ui.notify(
|
|
179
|
+
`pi-gate: ${path} is empty; no commands are gated. Add rules to the "operations" object to start gating.`,
|
|
180
|
+
"warning",
|
|
181
|
+
);
|
|
182
|
+
} else {
|
|
183
|
+
ctx.ui.notify(
|
|
184
|
+
`pi-gate: ${ruleCount} operation rule${ruleCount === 1 ? "" : "s"} loaded from ${path}`,
|
|
185
|
+
"info",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
ctx.ui.notify(
|
|
190
|
+
`pi-gate: no configuration found at ${path}; all commands are allowed. Copy ${examplePath()} to ${path} to start gating.`,
|
|
191
|
+
"warning",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
197
|
+
if (event.toolName !== "bash") return undefined;
|
|
198
|
+
if (!isToolCallEventType("bash", event)) return undefined;
|
|
199
|
+
const command = event.input.command;
|
|
200
|
+
const action = resolveAction(command, config.operations);
|
|
201
|
+
if (action === null || action === "allow") return undefined;
|
|
202
|
+
|
|
203
|
+
if (action === "block") {
|
|
204
|
+
if (ctx.hasUI) {
|
|
205
|
+
ctx.ui.notify(`pi-gate: blocked command matching a rule`, "warning");
|
|
206
|
+
}
|
|
207
|
+
return { block: true, reason: `pi-gate: command matches a "block" rule` };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// action === "prompt"
|
|
211
|
+
if (!ctx.hasUI) {
|
|
212
|
+
return { block: true, reason: "pi-gate: prompt required but no UI is available" };
|
|
213
|
+
}
|
|
214
|
+
const choice = await ctx.ui.select(
|
|
215
|
+
`pi-gate: allow this command?\n\n ${command}\n\nA rule in pi-gate.json requests confirmation.`,
|
|
216
|
+
["Allow", "Deny"],
|
|
217
|
+
);
|
|
218
|
+
if (choice !== "Allow") {
|
|
219
|
+
return { block: true, reason: "pi-gate: denied by user" };
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
});
|
|
223
|
+
}
|