@zeldrisho/pi-gate 0.4.0 → 0.5.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 +15 -1
- package/README.md +3 -1
- package/package.json +5 -7
- package/src/config.ts +64 -7
- package/src/display.ts +16 -0
- package/src/index.ts +38 -9
- package/src/rules.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Use `gate.json` as the default configuration filename, retaining `pi-gate.json` as a legacy fallback only when `gate.json` is absent; existing files are never automatically renamed or overwritten.
|
|
15
|
+
|
|
16
|
+
## [0.4.1] - 2026-09-17
|
|
17
|
+
|
|
18
|
+
### Security
|
|
19
|
+
|
|
20
|
+
- prompt for all commands when an existing configuration cannot be read or parsed instead of allowing every command through unblocked
|
|
21
|
+
|
|
10
22
|
## [0.4.0] - 2026-09-12
|
|
11
23
|
|
|
12
24
|
### Changed
|
|
@@ -50,7 +62,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
50
62
|
- support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
|
|
51
63
|
- block rather than auto-approve in non-UI modes
|
|
52
64
|
|
|
53
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.
|
|
65
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.5.0...HEAD
|
|
66
|
+
[0.5.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.1...pi-gate-v0.5.0
|
|
67
|
+
[0.4.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.0...pi-gate-v0.4.1
|
|
54
68
|
[0.4.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.3.0...pi-gate-v0.4.0
|
|
55
69
|
[0.3.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.2.0...pi-gate-v0.3.0
|
|
56
70
|
[0.2.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.1.1...pi-gate-v0.2.0
|
package/README.md
CHANGED
|
@@ -12,7 +12,9 @@ pi install -l npm:@zeldrisho/pi-gate
|
|
|
12
12
|
|
|
13
13
|
## Configure
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
The extension reads `~/.pi/agent/gate.json`, falling back to legacy `~/.pi/agent/pi-gate.json` only when `gate.json` is absent. Set `PI_CODING_AGENT_DIR` to use a different configuration directory. If both files exist, only `gate.json` is used—even if it is invalid. To migrate, rename `pi-gate.json` to `gate.json` and run `/reload`; existing files are never automatically renamed, merged, or overwritten.
|
|
16
|
+
|
|
17
|
+
On first load, if neither file exists, the extension creates `gate.json` with starter rules and a 30-second prompt timeout. If no configuration can be created yet, pi-gate warns at session start and allows commands until the file is created. If an existing configuration cannot be read or parsed, pi-gate warns and prompts for every command until it is fixed. The published package includes [`config.schema.json`](config.schema.json); use this schema URL for editor completion:
|
|
16
18
|
|
|
17
19
|
```json
|
|
18
20
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -25,19 +25,17 @@
|
|
|
25
25
|
"files": [
|
|
26
26
|
"src",
|
|
27
27
|
"config.schema.json",
|
|
28
|
-
"
|
|
29
|
-
"CHANGELOG.md",
|
|
30
|
-
"LICENSE"
|
|
28
|
+
"CHANGELOG.md"
|
|
31
29
|
],
|
|
32
30
|
"type": "module",
|
|
33
31
|
"publishConfig": {
|
|
34
32
|
"access": "public"
|
|
35
33
|
},
|
|
36
34
|
"devDependencies": {
|
|
37
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
35
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
38
36
|
"typescript": "^7.0.2",
|
|
39
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.
|
|
40
|
-
"vite-plus": "0.3.
|
|
37
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
|
|
38
|
+
"vite-plus": "0.3.3"
|
|
41
39
|
},
|
|
42
40
|
"peerDependencies": {
|
|
43
41
|
"@earendil-works/pi-coding-agent": "*"
|
package/src/config.ts
CHANGED
|
@@ -9,13 +9,28 @@ export interface GateConfig {
|
|
|
9
9
|
promptTimeoutMs: number;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
export type ConfigLoadStatus = "missing" | "loaded" | "failed";
|
|
13
|
+
|
|
14
|
+
export interface ConfigLoadResult {
|
|
15
|
+
config: GateConfig;
|
|
16
|
+
status: ConfigLoadStatus;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CONFIG_FILE_NAME = "gate.json";
|
|
20
|
+
|
|
21
|
+
const LEGACY_CONFIG_FILE_NAME = "pi-gate.json";
|
|
22
|
+
|
|
13
23
|
export const CONFIG_SCHEMA_URL =
|
|
14
24
|
"https://raw.githubusercontent.com/zeldrisho/pi-packages/main/packages/pi-gate/config.schema.json";
|
|
25
|
+
|
|
15
26
|
const ACTIONS: readonly Action[] = ["prompt", "block", "allow"] as const;
|
|
27
|
+
|
|
16
28
|
export const DEFAULT_PROMPT_TIMEOUT_MS = 30_000;
|
|
29
|
+
|
|
17
30
|
export const MAX_RULE_COUNT = 1_000;
|
|
31
|
+
|
|
18
32
|
export const MAX_RULE_PATTERN_LENGTH = 1_024;
|
|
33
|
+
|
|
19
34
|
export const MAX_PROMPT_TIMEOUT_MS = 86_400_000;
|
|
20
35
|
|
|
21
36
|
const DEFAULT_CONFIG: GateConfig = {
|
|
@@ -41,10 +56,18 @@ function agentDir(): string {
|
|
|
41
56
|
/**
|
|
42
57
|
* Returns the full path to the pi-gate configuration file.
|
|
43
58
|
*
|
|
44
|
-
*
|
|
59
|
+
* Prefers gate.json, falling back to pi-gate.json only when gate.json is absent.
|
|
60
|
+
*
|
|
61
|
+
* @returns The selected configuration path, or gate.json for first-run creation
|
|
45
62
|
*/
|
|
46
63
|
export function configPath(): string {
|
|
47
|
-
|
|
64
|
+
const path = join(agentDir(), CONFIG_FILE_NAME);
|
|
65
|
+
|
|
66
|
+
if (existsSync(path)) return path;
|
|
67
|
+
|
|
68
|
+
const legacyPath = join(agentDir(), LEGACY_CONFIG_FILE_NAME);
|
|
69
|
+
|
|
70
|
+
return existsSync(legacyPath) ? legacyPath : path;
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
/**
|
|
@@ -87,15 +110,19 @@ export function parseConfig(content: string): GateConfig {
|
|
|
87
110
|
const rules: Record<string, Action> = {};
|
|
88
111
|
let acceptedRuleCount = 0;
|
|
89
112
|
let parsed: unknown;
|
|
113
|
+
|
|
90
114
|
try {
|
|
91
115
|
parsed = JSON.parse(content);
|
|
92
116
|
} catch {
|
|
93
117
|
return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
94
118
|
}
|
|
119
|
+
|
|
95
120
|
if (!isJsonObject(parsed)) {
|
|
96
121
|
return { operations: rules, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
97
122
|
}
|
|
123
|
+
|
|
98
124
|
const configuredTimeout = parsed["promptTimeoutMs"];
|
|
125
|
+
|
|
99
126
|
const promptTimeoutMs =
|
|
100
127
|
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validate the optional numeric field at the untrusted JSON boundary before applying range checks
|
|
101
128
|
typeof configuredTimeout === "number" &&
|
|
@@ -104,18 +131,24 @@ export function parseConfig(content: string): GateConfig {
|
|
|
104
131
|
configuredTimeout <= MAX_PROMPT_TIMEOUT_MS
|
|
105
132
|
? configuredTimeout
|
|
106
133
|
: DEFAULT_PROMPT_TIMEOUT_MS;
|
|
134
|
+
|
|
107
135
|
const operations = parsed["operations"];
|
|
136
|
+
|
|
108
137
|
if (!isJsonObject(operations)) {
|
|
109
138
|
return { operations: rules, promptTimeoutMs };
|
|
110
139
|
}
|
|
140
|
+
|
|
111
141
|
for (const [pattern, action] of Object.entries(operations)) {
|
|
112
142
|
if (acceptedRuleCount >= MAX_RULE_COUNT) break;
|
|
143
|
+
|
|
113
144
|
if (pattern.length === 0 || pattern.length > MAX_RULE_PATTERN_LENGTH) continue;
|
|
145
|
+
|
|
114
146
|
if (isAction(action)) {
|
|
115
147
|
rules[pattern] = action;
|
|
116
148
|
acceptedRuleCount += 1;
|
|
117
149
|
}
|
|
118
150
|
}
|
|
151
|
+
|
|
119
152
|
return { operations: rules, promptTimeoutMs };
|
|
120
153
|
}
|
|
121
154
|
|
|
@@ -123,18 +156,38 @@ export function parseConfig(content: string): GateConfig {
|
|
|
123
156
|
* Loads the configuration from disk. Returns an empty configuration when the
|
|
124
157
|
* file is missing; returns the parsed configuration otherwise.
|
|
125
158
|
*/
|
|
126
|
-
export function
|
|
159
|
+
export function loadConfigResult(): ConfigLoadResult {
|
|
160
|
+
const emptyConfig = { operations: {}, promptTimeoutMs: DEFAULT_PROMPT_TIMEOUT_MS };
|
|
127
161
|
const path = configPath();
|
|
162
|
+
|
|
128
163
|
if (!existsSync(path)) {
|
|
129
|
-
return {
|
|
164
|
+
return { config: emptyConfig, status: "missing" };
|
|
130
165
|
}
|
|
166
|
+
|
|
131
167
|
let content: string;
|
|
168
|
+
|
|
132
169
|
try {
|
|
133
170
|
content = readFileSync(path, "utf-8");
|
|
134
171
|
} catch {
|
|
135
|
-
return {
|
|
172
|
+
return { config: emptyConfig, status: "failed" };
|
|
136
173
|
}
|
|
137
|
-
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const parsed: unknown = JSON.parse(content);
|
|
177
|
+
|
|
178
|
+
if (!isJsonObject(parsed) || !isJsonObject(parsed["operations"])) {
|
|
179
|
+
return { config: parseConfig(content), status: "failed" };
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
return { config: emptyConfig, status: "failed" };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { config: parseConfig(content), status: "loaded" };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Loads the configuration, preserving the historical empty-config fallback. */
|
|
189
|
+
export function loadConfig(): GateConfig {
|
|
190
|
+
return loadConfigResult().config;
|
|
138
191
|
}
|
|
139
192
|
|
|
140
193
|
/**
|
|
@@ -143,13 +196,17 @@ export function loadConfig(): GateConfig {
|
|
|
143
196
|
*/
|
|
144
197
|
export function ensureConfig(): void {
|
|
145
198
|
const path = configPath();
|
|
199
|
+
|
|
146
200
|
if (existsSync(path)) return;
|
|
201
|
+
|
|
147
202
|
try {
|
|
148
203
|
mkdirSync(agentDir(), { recursive: true });
|
|
149
204
|
} catch {
|
|
150
205
|
return;
|
|
151
206
|
}
|
|
207
|
+
|
|
152
208
|
const content = JSON.stringify({ $schema: CONFIG_SCHEMA_URL, ...DEFAULT_CONFIG }, null, 2) + "\n";
|
|
209
|
+
|
|
153
210
|
try {
|
|
154
211
|
writeFileSync(path, content, { encoding: "utf-8", flag: "wx", mode: 0o600 });
|
|
155
212
|
} catch {
|
package/src/display.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { RuleMatch } from "./rules";
|
|
2
2
|
|
|
3
3
|
export const MAX_DISPLAY_COMMAND_CHARACTERS = 2_000;
|
|
4
|
+
|
|
4
5
|
export const MAX_DISPLAY_COMMAND_LINES = 20;
|
|
6
|
+
|
|
5
7
|
const DISPLAY_TRUNCATION_MARKER = "\n … [command display truncated]";
|
|
8
|
+
|
|
6
9
|
const BIDI_CONTROL_CODE_POINTS = new Set([
|
|
7
10
|
0x061c, 0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069,
|
|
8
11
|
]);
|
|
@@ -10,7 +13,9 @@ const BIDI_CONTROL_CODE_POINTS = new Set([
|
|
|
10
13
|
/** Converts terminal controls and bidirectional formatting characters to visible escapes. */
|
|
11
14
|
function escapeUnsafeDisplayCharacter(character: string): string {
|
|
12
15
|
const codePoint = character.codePointAt(0);
|
|
16
|
+
|
|
13
17
|
if (codePoint === undefined) return "";
|
|
18
|
+
|
|
14
19
|
if (
|
|
15
20
|
codePoint < 0x20 ||
|
|
16
21
|
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
|
@@ -18,6 +23,7 @@ function escapeUnsafeDisplayCharacter(character: string): string {
|
|
|
18
23
|
) {
|
|
19
24
|
return `\\u{${codePoint.toString(16).padStart(4, "0")}}`;
|
|
20
25
|
}
|
|
26
|
+
|
|
21
27
|
return character;
|
|
22
28
|
}
|
|
23
29
|
|
|
@@ -31,12 +37,15 @@ function findMatchRanges(command: string, pattern?: string): MatchRange[] {
|
|
|
31
37
|
if (!pattern) return [];
|
|
32
38
|
const ranges: MatchRange[] = [];
|
|
33
39
|
let offset = 0;
|
|
40
|
+
|
|
34
41
|
while (offset <= command.length - pattern.length) {
|
|
35
42
|
const start = command.indexOf(pattern, offset);
|
|
43
|
+
|
|
36
44
|
if (start === -1) break;
|
|
37
45
|
ranges.push({ start, end: start + pattern.length });
|
|
38
46
|
offset = start + pattern.length;
|
|
39
47
|
}
|
|
48
|
+
|
|
40
49
|
return ranges;
|
|
41
50
|
}
|
|
42
51
|
|
|
@@ -57,6 +66,7 @@ function renderCommandForDisplay(command: string, pattern?: string): string {
|
|
|
57
66
|
let rawEnd: number;
|
|
58
67
|
let rendered: string;
|
|
59
68
|
const codePoint = command.codePointAt(offset);
|
|
69
|
+
|
|
60
70
|
if (codePoint === 0x0d) {
|
|
61
71
|
rawEnd = offset + (command.codePointAt(offset + 1) === 0x0a ? 2 : 1);
|
|
62
72
|
rendered = "\n";
|
|
@@ -68,11 +78,13 @@ function renderCommandForDisplay(command: string, pattern?: string): string {
|
|
|
68
78
|
|
|
69
79
|
const nextRange = ranges[rangeIndex];
|
|
70
80
|
const wasInsideRange = activeRange !== undefined;
|
|
81
|
+
|
|
71
82
|
const opensRange =
|
|
72
83
|
activeRange === undefined &&
|
|
73
84
|
nextRange !== undefined &&
|
|
74
85
|
nextRange.start < rawEnd &&
|
|
75
86
|
nextRange.end > rawStart;
|
|
87
|
+
|
|
76
88
|
if (opensRange) activeRange = nextRange;
|
|
77
89
|
const closesRange = activeRange !== undefined && activeRange.end <= rawEnd;
|
|
78
90
|
const decorated = `${opensRange ? "»" : ""}${rendered}${closesRange ? "«" : ""}`;
|
|
@@ -81,17 +93,21 @@ function renderCommandForDisplay(command: string, pattern?: string): string {
|
|
|
81
93
|
if (lineCount >= MAX_DISPLAY_COMMAND_LINES) return truncate(wasInsideRange);
|
|
82
94
|
lineCount += 1;
|
|
83
95
|
}
|
|
96
|
+
|
|
84
97
|
if (output.length + decorated.length > MAX_DISPLAY_COMMAND_CHARACTERS) {
|
|
85
98
|
return truncate(wasInsideRange);
|
|
86
99
|
}
|
|
87
100
|
|
|
88
101
|
output += decorated;
|
|
102
|
+
|
|
89
103
|
if (closesRange) {
|
|
90
104
|
activeRange = undefined;
|
|
91
105
|
rangeIndex += 1;
|
|
92
106
|
}
|
|
107
|
+
|
|
93
108
|
offset = rawEnd;
|
|
94
109
|
}
|
|
110
|
+
|
|
95
111
|
return output;
|
|
96
112
|
}
|
|
97
113
|
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* user-provided JSON configuration. Without a configuration file, the
|
|
6
6
|
* extension creates one with starter rules and a default prompt timeout.
|
|
7
7
|
*
|
|
8
|
-
* Configuration file: `~/.pi/agent/pi-gate.json`
|
|
8
|
+
* Configuration file: `~/.pi/agent/gate.json` (legacy fallback: `pi-gate.json`)
|
|
9
9
|
*
|
|
10
10
|
* The configuration has an `operations` section, which maps a substring
|
|
11
11
|
* pattern to one of three actions, and an optional `promptTimeoutMs` setting:
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* narrow `allow` rule can override a broader `prompt` or `block` rule.
|
|
19
19
|
*
|
|
20
20
|
* On first run, a configuration with the default prompt timeout and starter
|
|
21
|
-
* operation rules is written to `~/.pi/agent/
|
|
21
|
+
* operation rules is written to `~/.pi/agent/gate.json` when neither file exists.
|
|
22
22
|
*
|
|
23
23
|
* Non-UI modes (print, JSON) never auto-approve. A `prompt` or `block` rule
|
|
24
24
|
* in non-UI mode always blocks and requests termination of the agent turn.
|
|
@@ -26,9 +26,8 @@
|
|
|
26
26
|
* indefinitely.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import { existsSync } from "node:fs";
|
|
30
29
|
import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
31
|
-
import { configPath, ensureConfig,
|
|
30
|
+
import { configPath, ensureConfig, loadConfigResult } from "./config";
|
|
32
31
|
import { resolveRule } from "./rules";
|
|
33
32
|
import { formatPromptCommand, formatRule } from "./display";
|
|
34
33
|
|
|
@@ -58,6 +57,7 @@ function reportHerdrBlocked(pi: ExtensionAPI, ctx: { mode?: string }, active: bo
|
|
|
58
57
|
}
|
|
59
58
|
|
|
60
59
|
export type { Action, GateConfig } from "./config";
|
|
60
|
+
|
|
61
61
|
export {
|
|
62
62
|
CONFIG_SCHEMA_URL,
|
|
63
63
|
DEFAULT_PROMPT_TIMEOUT_MS,
|
|
@@ -66,10 +66,16 @@ export {
|
|
|
66
66
|
MAX_RULE_PATTERN_LENGTH,
|
|
67
67
|
ensureConfig,
|
|
68
68
|
loadConfig,
|
|
69
|
+
loadConfigResult,
|
|
69
70
|
parseConfig,
|
|
70
71
|
} from "./config";
|
|
72
|
+
|
|
73
|
+
export type { ConfigLoadResult, ConfigLoadStatus } from "./config";
|
|
74
|
+
|
|
71
75
|
export type { RuleMatch } from "./rules";
|
|
76
|
+
|
|
72
77
|
export { resolveAction, resolveRule } from "./rules";
|
|
78
|
+
|
|
73
79
|
export {
|
|
74
80
|
MAX_DISPLAY_COMMAND_CHARACTERS,
|
|
75
81
|
MAX_DISPLAY_COMMAND_LINES,
|
|
@@ -80,12 +86,14 @@ export {
|
|
|
80
86
|
/** Gate the built-in `bash` tool against the user-provided rules. */
|
|
81
87
|
export default function piGate(pi: ExtensionAPI): void {
|
|
82
88
|
ensureConfig();
|
|
83
|
-
|
|
89
|
+
let loadResult = loadConfigResult();
|
|
90
|
+
const { config } = loadResult;
|
|
84
91
|
|
|
85
92
|
pi.on("session_start", (_event, ctx) => {
|
|
86
93
|
const path = configPath();
|
|
87
94
|
const ruleCount = Object.keys(config.operations).length;
|
|
88
|
-
|
|
95
|
+
|
|
96
|
+
if (loadResult.status === "loaded") {
|
|
89
97
|
if (ruleCount === 0) {
|
|
90
98
|
ctx.ui.notify(
|
|
91
99
|
`pi-gate: ${path} is empty; no commands are gated. Add rules to the "operations" object to start gating.`,
|
|
@@ -97,9 +105,14 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
97
105
|
"info",
|
|
98
106
|
);
|
|
99
107
|
}
|
|
108
|
+
} else if (loadResult.status === "failed") {
|
|
109
|
+
ctx.ui.notify(
|
|
110
|
+
"pi-gate: configuration exists but could not be loaded; prompting for all commands until it is fixed.",
|
|
111
|
+
"warning",
|
|
112
|
+
);
|
|
100
113
|
} else {
|
|
101
114
|
ctx.ui.notify(
|
|
102
|
-
`pi-gate:
|
|
115
|
+
`pi-gate: no configuration found at ${configPath()}; all commands are allowed until it is created.`,
|
|
103
116
|
"warning",
|
|
104
117
|
);
|
|
105
118
|
}
|
|
@@ -107,17 +120,29 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
107
120
|
|
|
108
121
|
pi.on("tool_call", async (event, ctx) => {
|
|
109
122
|
if (event.toolName !== "bash") return undefined;
|
|
123
|
+
|
|
110
124
|
if (!isToolCallEventType("bash", event)) return undefined;
|
|
111
125
|
const command = event.input.command;
|
|
112
|
-
|
|
126
|
+
|
|
127
|
+
if (loadResult.status === "failed") {
|
|
128
|
+
loadResult = loadConfigResult();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const match =
|
|
132
|
+
loadResult.status === "failed"
|
|
133
|
+
? { pattern: "configuration unavailable", action: "prompt" as const }
|
|
134
|
+
: resolveRule(command, loadResult.config.operations);
|
|
135
|
+
|
|
113
136
|
if (match === null || match.action === "allow") return undefined;
|
|
114
137
|
const rule = formatRule(match);
|
|
115
138
|
|
|
116
139
|
if (match.action === "block") {
|
|
117
140
|
const reason = `pi-gate: command blocked by rule ${rule}`;
|
|
141
|
+
|
|
118
142
|
if (ctx.hasUI) {
|
|
119
143
|
ctx.ui.notify(reason, "warning");
|
|
120
144
|
}
|
|
145
|
+
|
|
121
146
|
return { block: true, reason, terminate: true };
|
|
122
147
|
}
|
|
123
148
|
|
|
@@ -129,17 +154,20 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
129
154
|
terminate: true,
|
|
130
155
|
};
|
|
131
156
|
}
|
|
157
|
+
|
|
132
158
|
reportHerdrBlocked(pi, ctx, true);
|
|
133
159
|
let choice: string | undefined;
|
|
160
|
+
|
|
134
161
|
try {
|
|
135
162
|
choice = await ctx.ui.select(
|
|
136
163
|
`pi-gate: allow this command?\n\n${formatPromptCommand(command, match.pattern)}\n\nMatched rule: ${rule}\nMatched command text is wrapped in »…«`,
|
|
137
164
|
["Allow", "Deny"],
|
|
138
|
-
{ timeout: config.promptTimeoutMs },
|
|
165
|
+
{ timeout: loadResult.config.promptTimeoutMs },
|
|
139
166
|
);
|
|
140
167
|
} finally {
|
|
141
168
|
reportHerdrBlocked(pi, ctx, false);
|
|
142
169
|
}
|
|
170
|
+
|
|
143
171
|
if (choice !== "Allow") {
|
|
144
172
|
return {
|
|
145
173
|
block: true,
|
|
@@ -147,6 +175,7 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
147
175
|
terminate: true,
|
|
148
176
|
};
|
|
149
177
|
}
|
|
178
|
+
|
|
150
179
|
return undefined;
|
|
151
180
|
});
|
|
152
181
|
}
|
package/src/rules.ts
CHANGED
|
@@ -18,13 +18,17 @@ export interface RuleMatch {
|
|
|
18
18
|
*/
|
|
19
19
|
export function resolveRule(command: string, rules: Record<string, Action>): RuleMatch | null {
|
|
20
20
|
let bestPattern: string | null = null;
|
|
21
|
+
|
|
21
22
|
for (const pattern of Object.keys(rules)) {
|
|
22
23
|
if (!command.includes(pattern)) continue;
|
|
24
|
+
|
|
23
25
|
if (bestPattern === null || pattern.length > bestPattern.length) {
|
|
24
26
|
bestPattern = pattern;
|
|
25
27
|
}
|
|
26
28
|
}
|
|
29
|
+
|
|
27
30
|
if (bestPattern === null) return null;
|
|
31
|
+
|
|
28
32
|
return { pattern: bestPattern, action: rules[bestPattern] };
|
|
29
33
|
}
|
|
30
34
|
|