@zeldrisho/pi-gate 0.6.1 → 0.7.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 +26 -1
- package/README.md +19 -10
- package/config.schema.json +1 -1
- package/package.json +3 -2
- package/src/index.ts +158 -83
- package/src/literal-commands.ts +108 -0
- package/src/rules.ts +77 -20
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.1] - 2026-10-25
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Create starter configuration when a session starts rather than when the extension is loaded.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Cancel active approval dialogs and reject queued calls during shutdown, reload, and session replacement.
|
|
19
|
+
|
|
20
|
+
## [0.7.0] - 2026-10-24
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Apply `gate.json` edits to the next gated call without reloading Pi.
|
|
25
|
+
- Show one approval dialog at a time. Approvals apply only to the current call; persistent exceptions remain in `gate.json`.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Avoid unnecessary approvals for literal `echo`/`printf` output, comments, and partial command names such as `git checkout-index`, while retaining conservative checks for complex shell syntax.
|
|
30
|
+
- Reject pending approvals if the call is canceled, the session changes, or the rules change.
|
|
31
|
+
- Correct rule-precedence guidance shown by editors for `gate.json`.
|
|
32
|
+
|
|
10
33
|
## [0.6.1] - 2026-10-21
|
|
11
34
|
|
|
12
35
|
### Changed
|
|
@@ -82,7 +105,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
82
105
|
- support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
|
|
83
106
|
- block rather than auto-approve in non-UI modes
|
|
84
107
|
|
|
85
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.
|
|
108
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.1...HEAD
|
|
109
|
+
[0.7.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.0...pi-gate-v0.7.1
|
|
110
|
+
[0.7.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.6.1...pi-gate-v0.7.0
|
|
86
111
|
[0.6.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.6.0...pi-gate-v0.6.1
|
|
87
112
|
[0.6.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.5.0...pi-gate-v0.6.0
|
|
88
113
|
[0.5.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.4.1...pi-gate-v0.5.0
|
package/README.md
CHANGED
|
@@ -15,9 +15,7 @@ Use `pi config` to enable or disable this extension interactively. Run `pi list`
|
|
|
15
15
|
|
|
16
16
|
## Configure
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
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:
|
|
18
|
+
Edit `~/.pi/agent/gate.json` to choose which commands need approval. The extension creates this file with starter rules when the first session starts. Changes apply before the next gated call, without restarting Pi. Add the optional `$schema` below for editor completion and validation:
|
|
21
19
|
|
|
22
20
|
```json
|
|
23
21
|
{
|
|
@@ -32,23 +30,34 @@ On first load, if neither file exists, the extension creates `gate.json` with st
|
|
|
32
30
|
}
|
|
33
31
|
```
|
|
34
32
|
|
|
35
|
-
Rules
|
|
33
|
+
Rules match command text and have one of three actions:
|
|
36
34
|
|
|
37
35
|
- `prompt`: ask the user to allow or deny;
|
|
38
36
|
- `block`: deny without asking;
|
|
39
37
|
- `allow`: explicitly permit matches fully contained within its matching text.
|
|
40
38
|
|
|
41
|
-
|
|
39
|
+
For simple commands, matching is case-insensitive, treats runs of whitespace as a single space, and matches complete words: `git checkout` does not match `git checkout-index`. Restrictive flag and numeric patterns retain prefix coverage, so `rm -rf` still gates `rm -rfv` and `chmod 777` still gates `chmod 7777`.
|
|
40
|
+
|
|
41
|
+
Literal arguments to `echo` and `printf`, and shell comments, do not trigger unrelated rules: `echo sudo` does not match `sudo`. Explicit output rules such as `echo sudo` still work. An `allow` suppresses only prompt/block matches fully inside its matching text in the same simple command; otherwise any remaining `block` wins over `prompt`. Commands with no match are allowed. Patterns must contain 1–1,024 characters; at most 1,000 valid rules are loaded.
|
|
42
|
+
|
|
43
|
+
pi-gate is a guard rail, not a sandbox. Complex shell syntax, pipelines, redirects, substitutions, script arguments, or rules containing shell syntax use conservative substring matching and can still prompt on literal text. Generated commands, flag-order variants, and commands run through other tools are not fully understood; keep `allow` patterns specific. Bash path checks remain textual, so a separate earlier `cd ~/.pi/agent` followed by `echo x > gate.json` may not be recognized.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
The built-in `bash` tool is gated, and `write`/`edit` calls targeting the gate config require approval. Prompt dialogs offer `Allow` and `Deny`; approvals are never remembered. To stop repeated prompts, add a specific `allow` rule to `gate.json`.
|
|
42
48
|
|
|
43
|
-
|
|
49
|
+
Approval dialogs are serialized for concurrent tool calls; cancellation, shutdown, reload, session replacement, or a policy change while a dialog is open denies the pending call. Calls queued before shutdown or session replacement are also denied.
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
Long commands are truncated in approval dialogs, but an approved command executes in full. Denied, dismissed, blocked, and timed-out calls stop the turn; other allowed calls in a parallel batch may still run. In non-interactive modes (`-p`, JSON), commands requiring approval are denied.
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
In Herdr-managed panes, install Herdr's Pi integration with `herdr integration install pi` to report pending approvals as `blocked`. Herdr is optional.
|
|
48
54
|
|
|
49
|
-
|
|
55
|
+
## Configuration notes
|
|
50
56
|
|
|
51
|
-
|
|
57
|
+
- `promptTimeoutMs` defaults to 30 seconds and accepts up to one day. Increase it if you need more time to approve calls.
|
|
58
|
+
- Set `PI_CODING_AGENT_DIR` if your Pi configuration lives outside `~/.pi/agent`.
|
|
59
|
+
- Legacy `pi-gate.json` is used only when `gate.json` is absent. To migrate, rename it to `gate.json`; existing files are never overwritten automatically.
|
|
60
|
+
- An unreadable or invalid configuration causes every command to require approval until fixed. If no file exists and one cannot be created, pi-gate warns and allows commands until you create it.
|
|
52
61
|
|
|
53
62
|
## Uninstall
|
|
54
63
|
|
package/config.schema.json
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"operations": {
|
|
23
23
|
"type": "object",
|
|
24
|
-
"description": "
|
|
24
|
+
"description": "Case-insensitive command patterns. Simple commands use word matching and ignore literal echo/printf arguments; complex syntax uses substring matching. Contained allow matches suppress restrictions; remaining blocks win.",
|
|
25
25
|
"maxProperties": 1000,
|
|
26
26
|
"propertyNames": {
|
|
27
27
|
"minLength": 1,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Pi extension that gates bash commands and protects its configuration from tool edits",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"command-blocking",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@earendil-works/pi-coding-agent": "^1.
|
|
35
|
+
"@earendil-works/pi-coding-agent": "^1.4.0",
|
|
36
|
+
"typebox": "^1.4.2",
|
|
36
37
|
"typescript": "^7.0.2",
|
|
37
38
|
"vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
|
|
38
39
|
"vite-plus": "1.3.0"
|
package/src/index.ts
CHANGED
|
@@ -7,18 +7,19 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Configuration file: `~/.pi/agent/gate.json` (legacy fallback: `pi-gate.json`)
|
|
9
9
|
*
|
|
10
|
-
* The configuration has an `operations` section, which maps a
|
|
10
|
+
* The configuration has an `operations` section, which maps a command-text
|
|
11
11
|
* pattern to one of three actions, and an optional `promptTimeoutMs` setting:
|
|
12
12
|
*
|
|
13
13
|
* - `prompt` - ask the user to allow or deny the command
|
|
14
14
|
* - `block` - deny the command without asking
|
|
15
15
|
* - `allow` - explicitly allow (use to carve out an exception)
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Simple commands match whole words without treating literal echo/printf data
|
|
18
|
+
* as commands; uncertain syntax retains conservative substring matching.
|
|
19
|
+
* Matching is case-insensitive and whitespace-normalized. Allow occurrences
|
|
20
|
+
* suppress contained restrictions in the same command; surviving blocks win.
|
|
20
21
|
*
|
|
21
|
-
* On first
|
|
22
|
+
* On first session start, a configuration with the default prompt timeout and starter
|
|
22
23
|
* operation rules is written to `~/.pi/agent/gate.json` when neither file exists.
|
|
23
24
|
*
|
|
24
25
|
* Non-UI modes (print, JSON) never auto-approve. A `prompt` or `block` rule
|
|
@@ -27,7 +28,11 @@
|
|
|
27
28
|
* indefinitely.
|
|
28
29
|
*/
|
|
29
30
|
|
|
30
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
isToolCallEventType,
|
|
33
|
+
type ExtensionAPI,
|
|
34
|
+
type ToolCallEventResult,
|
|
35
|
+
} from "@earendil-works/pi-coding-agent";
|
|
31
36
|
import { configPath, ensureConfig, loadConfigResult } from "./config";
|
|
32
37
|
import {
|
|
33
38
|
bashTouchesConfig,
|
|
@@ -105,15 +110,46 @@ export {
|
|
|
105
110
|
highlightRuleForDisplay,
|
|
106
111
|
} from "./display";
|
|
107
112
|
|
|
108
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Gates `bash` calls against user rules and requires approval for protected
|
|
115
|
+
* configuration writes and edits. Attempts to create a starter configuration
|
|
116
|
+
* at session start if absent.
|
|
117
|
+
* Reloads rules before gating and after each prompt; approvals apply per call.
|
|
118
|
+
* Dialogs are serialized. Cancellation, shutdown, and session replacement deny
|
|
119
|
+
* active and queued gated calls; policy changes invalidate an active approval.
|
|
120
|
+
* UI failures reject the affected tool call.
|
|
121
|
+
*/
|
|
109
122
|
export default function piGate(pi: ExtensionAPI): void {
|
|
110
|
-
ensureConfig();
|
|
111
123
|
let loadResult = loadConfigResult();
|
|
112
|
-
|
|
124
|
+
let session = new AbortController();
|
|
125
|
+
let policy = JSON.stringify(loadResult);
|
|
126
|
+
let revision = 0;
|
|
127
|
+
let pending = Promise.resolve();
|
|
128
|
+
|
|
129
|
+
/** Reloads policy and invalidates active approvals when its parsed value or load status changes. */
|
|
130
|
+
const refreshConfig = () => {
|
|
131
|
+
loadResult = loadConfigResult();
|
|
132
|
+
const current = JSON.stringify(loadResult);
|
|
133
|
+
|
|
134
|
+
if (current !== policy) {
|
|
135
|
+
revision += 1;
|
|
136
|
+
policy = current;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
pi.on("session_shutdown", () => {
|
|
141
|
+
session.abort();
|
|
142
|
+
revision += 1;
|
|
143
|
+
});
|
|
113
144
|
|
|
114
145
|
pi.on("session_start", (_event, ctx) => {
|
|
146
|
+
session.abort();
|
|
147
|
+
session = new AbortController();
|
|
148
|
+
revision += 1;
|
|
149
|
+
ensureConfig();
|
|
150
|
+
refreshConfig();
|
|
115
151
|
const path = configPath();
|
|
116
|
-
const ruleCount = Object.keys(config.operations).length;
|
|
152
|
+
const ruleCount = Object.keys(loadResult.config.operations).length;
|
|
117
153
|
|
|
118
154
|
if (loadResult.status === "loaded" && ruleCount === 0) {
|
|
119
155
|
ctx.ui.notify(
|
|
@@ -133,89 +169,128 @@ export default function piGate(pi: ExtensionAPI): void {
|
|
|
133
169
|
}
|
|
134
170
|
});
|
|
135
171
|
|
|
136
|
-
pi.on("tool_call",
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (event.toolName === "bash") {
|
|
141
|
-
if (!isToolCallEventType("bash", event)) return undefined;
|
|
142
|
-
command = event.input.command;
|
|
143
|
-
configChange = bashTouchesConfig(command);
|
|
144
|
-
} else if (isToolCallEventType("write", event)) {
|
|
145
|
-
if (!isProtectedConfigPath(event.input.path)) return undefined;
|
|
146
|
-
command = event.input.path;
|
|
147
|
-
configChange = true;
|
|
148
|
-
} else if (isToolCallEventType("edit", event)) {
|
|
149
|
-
if (!isProtectedConfigPath(event.input.path)) return undefined;
|
|
150
|
-
command = event.input.path;
|
|
151
|
-
configChange = true;
|
|
152
|
-
} else {
|
|
153
|
-
return undefined;
|
|
154
|
-
}
|
|
172
|
+
pi.on("tool_call", (event, ctx) => {
|
|
173
|
+
// Capture ownership before queueing so old calls cannot enter a replacement session.
|
|
174
|
+
const signal = ctx.signal ? AbortSignal.any([ctx.signal, session.signal]) : session.signal;
|
|
155
175
|
|
|
156
|
-
|
|
176
|
+
// Pi can dispatch sibling tools concurrently; never overlap approval dialogs.
|
|
177
|
+
/**
|
|
178
|
+
* Returns a terminating block result for denied calls, or undefined to allow;
|
|
179
|
+
* UI errors propagate.
|
|
180
|
+
*/
|
|
181
|
+
const run = async (): Promise<ToolCallEventResult | undefined> => {
|
|
182
|
+
let command: string;
|
|
183
|
+
let configChange = false;
|
|
157
184
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
185
|
+
if (event.toolName === "bash") {
|
|
186
|
+
if (!isToolCallEventType("bash", event)) return undefined;
|
|
187
|
+
command = event.input.command;
|
|
188
|
+
configChange = bashTouchesConfig(command);
|
|
189
|
+
} else if (isToolCallEventType("write", event)) {
|
|
190
|
+
if (!isProtectedConfigPath(event.input.path)) return undefined;
|
|
191
|
+
command = event.input.path;
|
|
192
|
+
configChange = true;
|
|
193
|
+
} else if (isToolCallEventType("edit", event)) {
|
|
194
|
+
if (!isProtectedConfigPath(event.input.path)) return undefined;
|
|
195
|
+
command = event.input.path;
|
|
196
|
+
configChange = true;
|
|
197
|
+
} else {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
163
200
|
|
|
164
|
-
|
|
165
|
-
|
|
201
|
+
if (signal.aborted) {
|
|
202
|
+
return {
|
|
203
|
+
block: true,
|
|
204
|
+
reason: "pi-gate: approval cancelled; command not run.",
|
|
205
|
+
terminate: true,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
166
208
|
|
|
167
|
-
|
|
168
|
-
const reason = `pi-gate: blocked by rule "${reasonRuleName(match.pattern)}". Do not retry or use equivalent commands; ask the user.`;
|
|
209
|
+
refreshConfig();
|
|
169
210
|
|
|
170
|
-
|
|
211
|
+
const match = configChange
|
|
212
|
+
? { pattern: "gate config change requires approval", action: "prompt" as const }
|
|
213
|
+
: loadResult.status === "failed"
|
|
214
|
+
? { pattern: "configuration unavailable", action: "prompt" as const }
|
|
215
|
+
: resolveRule(command, loadResult.config.operations);
|
|
171
216
|
|
|
172
|
-
|
|
173
|
-
|
|
217
|
+
if (match === null || match.action === "allow") return undefined;
|
|
218
|
+
const rule = configChange ? CONFIG_CHANGE_REASON : formatRule(match);
|
|
174
219
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
block: true,
|
|
178
|
-
reason: configChange
|
|
179
|
-
? CONFIG_CHANGE_REASON
|
|
180
|
-
: `pi-gate: rule "${reasonRuleName(match.pattern)}" needs approval but no UI is available. Do not retry; ask the user.`,
|
|
181
|
-
terminate: true,
|
|
182
|
-
};
|
|
183
|
-
}
|
|
220
|
+
if (match.action === "block") {
|
|
221
|
+
const reason = `pi-gate: blocked by rule "${reasonRuleName(match.pattern)}". Do not retry or use equivalent commands; ask the user.`;
|
|
184
222
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const promptCommand = configChange
|
|
191
|
-
? ` ${formatCommandForDisplay(command)}`
|
|
192
|
-
: formatPromptCommand(command, match.pattern);
|
|
193
|
-
|
|
194
|
-
try {
|
|
195
|
-
const promptStartedAt = Date.now();
|
|
196
|
-
choice = await ctx.ui.select(
|
|
197
|
-
`pi-gate: allow this command?\n\n${promptCommand}\n\nMatched rule: ${rule}${configChange ? "" : "\nMatched command text is wrapped in »…«"}`,
|
|
198
|
-
["Allow", "Deny"],
|
|
199
|
-
{ timeout: timeoutMs },
|
|
200
|
-
);
|
|
201
|
-
// Pi returns undefined for both dismissal and timeout; elapsed time separates the timeout case.
|
|
202
|
-
timedOut = choice === undefined && Date.now() - promptStartedAt >= timeoutMs;
|
|
203
|
-
} finally {
|
|
204
|
-
reportHerdrBlocked(pi, ctx, false);
|
|
205
|
-
}
|
|
223
|
+
if (ctx.hasUI) ctx.ui.notify(reason, "warning");
|
|
224
|
+
|
|
225
|
+
return { block: true, reason, terminate: true };
|
|
226
|
+
}
|
|
206
227
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
? timeoutReason(match.pattern, timeoutMs)
|
|
212
|
-
: configChange
|
|
228
|
+
if (!ctx.hasUI) {
|
|
229
|
+
return {
|
|
230
|
+
block: true,
|
|
231
|
+
reason: configChange
|
|
213
232
|
? CONFIG_CHANGE_REASON
|
|
214
|
-
: `pi-gate:
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
233
|
+
: `pi-gate: rule "${reasonRuleName(match.pattern)}" needs approval but no UI is available. Do not retry; ask the user.`,
|
|
234
|
+
terminate: true,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const promptRevision = revision;
|
|
239
|
+
|
|
240
|
+
reportHerdrBlocked(pi, ctx, true);
|
|
241
|
+
let choice: string | undefined;
|
|
242
|
+
let timedOut = false;
|
|
243
|
+
const timeoutMs = loadResult.config.promptTimeoutMs;
|
|
244
|
+
|
|
245
|
+
const promptCommand = configChange
|
|
246
|
+
? ` ${formatCommandForDisplay(command)}`
|
|
247
|
+
: formatPromptCommand(command, match.pattern);
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const promptStartedAt = Date.now();
|
|
251
|
+
choice = await ctx.ui.select(
|
|
252
|
+
`pi-gate: allow this command?\n\n${promptCommand}\n\nMatched rule: ${rule}${configChange ? "" : "\nMatched command text is wrapped in »…«"}`,
|
|
253
|
+
["Allow", "Deny"],
|
|
254
|
+
{ timeout: timeoutMs, signal },
|
|
255
|
+
);
|
|
256
|
+
// Pi returns undefined for both dismissal and timeout; elapsed time separates the timeout case.
|
|
257
|
+
timedOut = choice === undefined && Date.now() - promptStartedAt >= timeoutMs;
|
|
258
|
+
} finally {
|
|
259
|
+
reportHerdrBlocked(pi, ctx, false);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
refreshConfig();
|
|
263
|
+
|
|
264
|
+
if (signal.aborted || revision !== promptRevision) {
|
|
265
|
+
return {
|
|
266
|
+
block: true,
|
|
267
|
+
reason:
|
|
268
|
+
"pi-gate: approval cancelled or policy changed; command not run. Ask the user whether to retry.",
|
|
269
|
+
terminate: true,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (choice !== "Allow") {
|
|
274
|
+
return {
|
|
275
|
+
block: true,
|
|
276
|
+
reason: timedOut
|
|
277
|
+
? timeoutReason(match.pattern, timeoutMs)
|
|
278
|
+
: configChange
|
|
279
|
+
? CONFIG_CHANGE_REASON
|
|
280
|
+
: `pi-gate: denied by rule "${reasonRuleName(match.pattern)}". Do not retry or use equivalent commands; ask the user.`,
|
|
281
|
+
terminate: true,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return undefined;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const result = pending.then(run);
|
|
289
|
+
pending = result.then(
|
|
290
|
+
() => undefined,
|
|
291
|
+
() => undefined,
|
|
292
|
+
);
|
|
218
293
|
|
|
219
|
-
return
|
|
294
|
+
return result;
|
|
220
295
|
});
|
|
221
296
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** A simple command whose words contain no shell expansion or evaluation syntax. */
|
|
2
|
+
export interface LiteralCommand {
|
|
3
|
+
text: string;
|
|
4
|
+
literalOutput: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const MAX_LITERAL_COMMAND_CHARACTERS = 65_536;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Recognizes only literal words, quotes, comments, and command separators.
|
|
11
|
+
* Unsupported syntax returns undefined for the entire input: callers must use
|
|
12
|
+
* conservative text matching, not treat an unrecognized command as safe.
|
|
13
|
+
* This does not expand variables, execute commands, or attempt full Bash parsing.
|
|
14
|
+
* Returned text joins recognized words with spaces after quote and escape
|
|
15
|
+
* handling; comments and separators are omitted. Empty or comment-only input returns an empty list.
|
|
16
|
+
* Inputs longer than 65,536 UTF-16 code units also return undefined.
|
|
17
|
+
*/
|
|
18
|
+
export function literalCommands(command: string): LiteralCommand[] | undefined {
|
|
19
|
+
if (command.length > MAX_LITERAL_COMMAND_CHARACTERS) return undefined;
|
|
20
|
+
|
|
21
|
+
const commands: LiteralCommand[] = [];
|
|
22
|
+
let words: string[] = [];
|
|
23
|
+
let word = "";
|
|
24
|
+
let active = false;
|
|
25
|
+
let quote: "'" | '"' | undefined;
|
|
26
|
+
let opaque = false;
|
|
27
|
+
|
|
28
|
+
const finishWord = () => {
|
|
29
|
+
if (!active) return;
|
|
30
|
+
words.push(word);
|
|
31
|
+
word = "";
|
|
32
|
+
active = false;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const finishCommand = () => {
|
|
36
|
+
finishWord();
|
|
37
|
+
|
|
38
|
+
if (words.length === 0) return;
|
|
39
|
+
const literalOutput = words[0] === "echo" || words[0] === "printf";
|
|
40
|
+
|
|
41
|
+
// Other programs may interpret quoted arguments as scripts. Do not discard
|
|
42
|
+
// shell-looking text passed to bash -c, eval, Python, or other interpreters.
|
|
43
|
+
if (!literalOutput && words.some((value) => /[\s$`(){}<>;&|*?[\]~!]/u.test(value))) {
|
|
44
|
+
opaque = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
commands.push({ text: words.join(" "), literalOutput });
|
|
48
|
+
words = [];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
52
|
+
const char = command[index];
|
|
53
|
+
|
|
54
|
+
if (quote === "'") {
|
|
55
|
+
if (char === "'") quote = undefined;
|
|
56
|
+
else word += char;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (char === "\\") {
|
|
61
|
+
const next = command[index + 1];
|
|
62
|
+
|
|
63
|
+
// Continued lines and trailing escapes need the conservative matcher.
|
|
64
|
+
if (next === undefined || next === "\n" || next === "\r") return undefined;
|
|
65
|
+
|
|
66
|
+
// Inside double quotes, only these characters lose their backslash.
|
|
67
|
+
if (quote === '"' && !'"\\$`'.includes(next)) word += "\\";
|
|
68
|
+
word += next;
|
|
69
|
+
active = true;
|
|
70
|
+
index += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (quote === '"') {
|
|
75
|
+
if (char === '"') quote = undefined;
|
|
76
|
+
else if (char === "$" || char === "`") return undefined;
|
|
77
|
+
else word += char;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (char === "'" || char === '"') {
|
|
82
|
+
quote = char;
|
|
83
|
+
active = true;
|
|
84
|
+
} else if (char === "#" && !active) {
|
|
85
|
+
while (index < command.length && command[index] !== "\n") index += 1;
|
|
86
|
+
finishCommand();
|
|
87
|
+
} else if (char === "\n" || char === ";" || char === "&" || char === "|") {
|
|
88
|
+
// Printed text can become executable input down a pipeline.
|
|
89
|
+
if (char === "|" && command[index + 1] !== "|") return undefined;
|
|
90
|
+
finishCommand();
|
|
91
|
+
|
|
92
|
+
if ((char === "&" || char === "|") && command[index + 1] === char) index += 1;
|
|
93
|
+
} else if (/\s/u.test(char ?? "")) {
|
|
94
|
+
finishWord();
|
|
95
|
+
} else if ("$`(){}<>*?[]~!".includes(char ?? "")) {
|
|
96
|
+
// Expansions, grouping, redirects, and globs are not parsed.
|
|
97
|
+
return undefined;
|
|
98
|
+
} else {
|
|
99
|
+
word += char;
|
|
100
|
+
active = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (quote !== undefined) return undefined;
|
|
105
|
+
finishCommand();
|
|
106
|
+
|
|
107
|
+
return opaque ? undefined : commands;
|
|
108
|
+
}
|
package/src/rules.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Action } from "./config";
|
|
2
|
+
import { literalCommands } from "./literal-commands";
|
|
2
3
|
|
|
3
4
|
export interface RuleMatch {
|
|
4
5
|
pattern: string;
|
|
@@ -9,6 +10,14 @@ interface RuleOccurrence extends RuleMatch {
|
|
|
9
10
|
start: number;
|
|
10
11
|
end: number;
|
|
11
12
|
normalizedLength: number;
|
|
13
|
+
scope: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface RuleText {
|
|
17
|
+
text: string;
|
|
18
|
+
scope: number;
|
|
19
|
+
wholeWords: boolean;
|
|
20
|
+
literalOutput: boolean;
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
/** Normalize matching text while leaving the original command untouched for display/execution. */
|
|
@@ -16,20 +25,42 @@ export function normalizeMatchText(text: string): string {
|
|
|
16
25
|
return text.toLowerCase().replace(/\s+/gu, " ");
|
|
17
26
|
}
|
|
18
27
|
|
|
19
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Finds overlapping spans of a nonempty, normalized pattern in normalized text,
|
|
30
|
+
* applying the context's boundaries for words and literal output and retaining its scope.
|
|
31
|
+
*/
|
|
32
|
+
function findOccurrences(context: RuleText, pattern: string, rule: RuleMatch): RuleOccurrence[] {
|
|
20
33
|
const occurrences: RuleOccurrence[] = [];
|
|
34
|
+
const { text } = context;
|
|
21
35
|
let offset = 0;
|
|
22
36
|
|
|
23
37
|
while (offset <= text.length - pattern.length) {
|
|
24
38
|
const start = text.indexOf(pattern, offset);
|
|
25
39
|
|
|
26
40
|
if (start === -1) break;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
const end = start + pattern.length;
|
|
42
|
+
const lastWord = pattern.slice(pattern.lastIndexOf(" ") + 1);
|
|
43
|
+
|
|
44
|
+
// Do not silently stop gating combined flags (-rfv) or extended modes
|
|
45
|
+
// (7777). Allow exceptions still require the complete final word.
|
|
46
|
+
const restrictivePrefix =
|
|
47
|
+
rule.action !== "allow" && (lastWord.startsWith("-") || /^\d+$/u.test(lastWord));
|
|
48
|
+
|
|
49
|
+
const bounded =
|
|
50
|
+
!context.wholeWords ||
|
|
51
|
+
((start === 0 || text[start - 1] === " " || text[start - 1] === "/") &&
|
|
52
|
+
(end === text.length || text[end] === " " || restrictivePrefix));
|
|
53
|
+
|
|
54
|
+
if (bounded && (!context.literalOutput || start === 0)) {
|
|
55
|
+
occurrences.push({
|
|
56
|
+
...rule,
|
|
57
|
+
start,
|
|
58
|
+
end,
|
|
59
|
+
scope: context.scope,
|
|
60
|
+
normalizedLength: pattern.length,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
33
64
|
// Advance one code unit so overlapping occurrences are retained.
|
|
34
65
|
offset = start + 1;
|
|
35
66
|
}
|
|
@@ -38,25 +69,46 @@ function findOccurrences(text: string, pattern: string, rule: RuleMatch): RuleOc
|
|
|
38
69
|
}
|
|
39
70
|
|
|
40
71
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
72
|
+
* Matches whole words in recognized literal commands, excluding data arguments
|
|
73
|
+
* to echo/printf. Restrictive final flag or numeric words also match prefixes.
|
|
74
|
+
* Complex syntax or patterns retain conservative text matching.
|
|
75
|
+
* Allows suppress only contained restrictions in the same command; surviving
|
|
76
|
+
* blocks take priority, with the longest pattern reported for the winning action.
|
|
77
|
+
* Returns null when no rule matches. Considers at most 1,000 rules, skipping
|
|
78
|
+
* empty patterns and patterns longer than 1,024 UTF-16 code units.
|
|
44
79
|
*/
|
|
45
80
|
export function resolveRule(command: string, rules: Record<string, Action>): RuleMatch | null {
|
|
46
|
-
const
|
|
47
|
-
const occurrences: RuleOccurrence[] = [];
|
|
48
|
-
|
|
49
|
-
let acceptedRuleCount = 0;
|
|
81
|
+
const acceptedRules: RuleMatch[] = [];
|
|
50
82
|
|
|
51
83
|
for (const [pattern, action] of Object.entries(rules)) {
|
|
52
|
-
if (
|
|
84
|
+
if (acceptedRules.length >= 1_000) break;
|
|
53
85
|
|
|
54
86
|
if (pattern.length === 0 || pattern.length > 1_024) continue;
|
|
55
|
-
|
|
87
|
+
acceptedRules.push({ pattern, action });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Shell syntax in a rule keeps its existing textual meaning.
|
|
91
|
+
const simplePatterns = acceptedRules.every(({ pattern }) =>
|
|
92
|
+
/^[\p{L}\p{N}_./:@%+=,-]+(?: [\p{L}\p{N}_./:@%+=,-]+)*$/u.test(normalizeMatchText(pattern)),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const literal = simplePatterns ? literalCommands(command) : undefined;
|
|
96
|
+
|
|
97
|
+
const contexts: RuleText[] = literal
|
|
98
|
+
? literal.map((entry, scope) => ({
|
|
99
|
+
...entry,
|
|
100
|
+
text: normalizeMatchText(entry.text),
|
|
101
|
+
scope,
|
|
102
|
+
wholeWords: true,
|
|
103
|
+
}))
|
|
104
|
+
: [{ text: normalizeMatchText(command), scope: 0, wholeWords: false, literalOutput: false }];
|
|
105
|
+
|
|
106
|
+
const occurrences: RuleOccurrence[] = [];
|
|
107
|
+
|
|
108
|
+
for (const rule of acceptedRules) {
|
|
109
|
+
const pattern = normalizeMatchText(rule.pattern);
|
|
56
110
|
|
|
57
|
-
|
|
58
|
-
occurrences.push(...findOccurrences(normalizedCommand, normalizedPattern, { pattern, action }));
|
|
59
|
-
acceptedRuleCount += 1;
|
|
111
|
+
for (const context of contexts) occurrences.push(...findOccurrences(context, pattern, rule));
|
|
60
112
|
}
|
|
61
113
|
|
|
62
114
|
const allows = occurrences.filter((occurrence) => occurrence.action === "allow");
|
|
@@ -64,7 +116,12 @@ export function resolveRule(command: string, rules: Record<string, Action>): Rul
|
|
|
64
116
|
const surviving = occurrences.filter(
|
|
65
117
|
(occurrence) =>
|
|
66
118
|
occurrence.action === "allow" ||
|
|
67
|
-
!allows.some(
|
|
119
|
+
!allows.some(
|
|
120
|
+
(allow) =>
|
|
121
|
+
allow.scope === occurrence.scope &&
|
|
122
|
+
allow.start <= occurrence.start &&
|
|
123
|
+
allow.end >= occurrence.end,
|
|
124
|
+
),
|
|
68
125
|
);
|
|
69
126
|
|
|
70
127
|
const action: Action | null = surviving.some((occurrence) => occurrence.action === "block")
|