@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 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
+ }