@tinoy/pi-command-guard 0.1.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/LICENSE +21 -0
- package/README.md +31 -0
- package/command-guard.ts +446 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tinoy Thomas
|
|
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,31 @@
|
|
|
1
|
+
# @tinoy/pi-command-guard
|
|
2
|
+
|
|
3
|
+
Block destructive shell commands before they run, and name the safe alternative in the block reason.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pi install npm:@tinoy/pi-command-guard
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## What it needs at call time
|
|
10
|
+
|
|
11
|
+
nothing. The rule tables are data, and every check is a pure function over the tool call's
|
|
12
|
+
own text, so the guard works in any session shape. Its block reasons name tools (the grep
|
|
13
|
+
tool, the build tool) only when `getActiveTools()` says the calling session has them, and
|
|
14
|
+
always carry a shell form that satisfies the rule on its own.
|
|
15
|
+
|
|
16
|
+
## Registers
|
|
17
|
+
|
|
18
|
+
no tool — a `tool_call` hook.
|
|
19
|
+
|
|
20
|
+
## Caveats
|
|
21
|
+
|
|
22
|
+
No caveat rows declared: this unit has no soft dependency on another package in this repository. It reads the calling session's own tool list, never another package's API, so no neighbour changes what it can do.
|
|
23
|
+
|
|
24
|
+
## Dependencies
|
|
25
|
+
|
|
26
|
+
pi-supplied imports (`@earendil-works/pi-coding-agent`) are peer dependencies with a `*` range and are
|
|
27
|
+
never bundled. Plain dependencies: `@tinoy/pi-ext-lib`.
|
|
28
|
+
|
|
29
|
+
## Licence
|
|
30
|
+
|
|
31
|
+
MIT — see the repository [LICENSE](../../LICENSE).
|
package/command-guard.ts
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* command-guard: tool_call hook enforcing the command rules mechanically.
|
|
3
|
+
*
|
|
4
|
+
* Two rule families:
|
|
5
|
+
* RAW-INPUT (bash + the ctx_* execution tools, every session shape): raw
|
|
6
|
+
* synthetic-input binaries are blocked and redirected to the `inject`
|
|
7
|
+
* wrapper. A leaked press (`ydotool click 0x40` with no release of the
|
|
8
|
+
* button) leaves BTN_LEFT held on the virtual device, so the seat believes
|
|
9
|
+
* button 1 is already down and every real left click on the machine is
|
|
10
|
+
* ignored until the release lands. The check covers
|
|
11
|
+
* ctx_execute/ctx_execute_file/ctx_batch_execute because an execSync inside
|
|
12
|
+
* sandboxed code reaches the same binary and never passes through bash.
|
|
13
|
+
* R1/R2 (bash only): blocks bash calls that bypass the dedicated tools, with
|
|
14
|
+
* one-line redirect guidance in the block reason:
|
|
15
|
+
* - file-content reads via bash: cat / sed -n 'a,bp' / head -n / tail -n /
|
|
16
|
+
* grep <path> / rg <pattern> <path> (incl. cwd-recursive rg|grep -r)
|
|
17
|
+
* → "use read (offset/limit)"; for search the redirect names the tool
|
|
18
|
+
* the SESSION actually has (see callerCaps) and ALWAYS names the
|
|
19
|
+
* tool-free shell form — a pipeline whose later stage restricts grep's
|
|
20
|
+
* output (`| head -20`, `| wc -l`, `| grep -v x`, `| cut -c1-110`) or a
|
|
21
|
+
* redirect into /tmp. Both forms satisfy the rule in any session shape
|
|
22
|
+
* (a pi subagent runs its own process on a much smaller menu — no
|
|
23
|
+
* ctx_* tools at all — so a redirect naming ctx_execute is unfollowable
|
|
24
|
+
* and costs a retry)
|
|
25
|
+
* - truncation-only pipelines: cat X | head -120 (no transforming stage)
|
|
26
|
+
* → "use read (offset/limit)"
|
|
27
|
+
* - raw build/checker output: tsc / makepkg / npm run|test|build /
|
|
28
|
+
* ags bundle / go build / cargo build / make — blocked ONLY when the
|
|
29
|
+
* output is unfiltered; a pipe into a filter/limiter (grep/rg/awk/sed/
|
|
30
|
+
* head/tail with a pattern or count, `| wc -l`) or a redirect into a
|
|
31
|
+
* /tmp log passes → "filter it, log it under /tmp", plus "or use the
|
|
32
|
+
* build tool" only when that tool is in the session's menu
|
|
33
|
+
*
|
|
34
|
+
* Conservative by design (false positives are worse than misses):
|
|
35
|
+
* - a pipeline whose later stages TRANSFORM (grep, awk, jq, sort, uniq, cut,
|
|
36
|
+
* tr, sed, node, python, perl …) is the sanctioned extraction exception
|
|
37
|
+
* (canon R1); mere truncation (head/tail/wc) is NOT a transform.
|
|
38
|
+
* EXCEPTION TO THE EXCEPTION: grep/rg with an explicit path or -r are
|
|
39
|
+
* checked BEFORE the transform gate, because the harm here is grep's own
|
|
40
|
+
* output, not a later stage: a RESTRICTING stage (re-filter, limiter,
|
|
41
|
+
* count, field projection, or a /tmp log redirect) makes the output
|
|
42
|
+
* derived and passes; a bare dump and a merely reshaping stage
|
|
43
|
+
* (`grep -rn x . | sort`, `| tee out`) still block.
|
|
44
|
+
* - skips segments containing $ variables or regex-$ anchors, heredocs, or
|
|
45
|
+
* file output redirects (writes, not reads); glob chars are skipped EXCEPT
|
|
46
|
+
* for grep/rg, whose quoted-glob dump shapes are checked explicitly
|
|
47
|
+
* - allows tail -f / tail with -f, cat with no operands, sudo-prefixed
|
|
48
|
+
* commands (own flow), rg --files (listing mode), stdin-reading grep, and
|
|
49
|
+
* content-free grep output (-c/--count, -l/-L/--files-with-matches),
|
|
50
|
+
* which prints a number or file names, never file content
|
|
51
|
+
*
|
|
52
|
+
* Every block is appended to the shared hook log
|
|
53
|
+
* (~/.local/share/pi-hooks/log.jsonl, {ts, source, kind, detail}) which the
|
|
54
|
+
* monthly pi-tool-burn report reads. Rows carry source "command-guard"; the
|
|
55
|
+
* RAW-INPUT family was added to the extension formerly named bash-guard, and
|
|
56
|
+
* "bash-guard" rows read the same way in the footer's counter.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
60
|
+
import { hookLog } from "@tinoy/pi-ext-lib";
|
|
61
|
+
|
|
62
|
+
const BUILD_FIRST = new Set(["tsc", "makepkg", "cmake", "meson"]);
|
|
63
|
+
const BUILD_PAIRS: Record<string, RegExp> = {
|
|
64
|
+
npm: /^(run|test)$/,
|
|
65
|
+
npx: /^(tsc|eslint|prettier|biome)$/,
|
|
66
|
+
ags: /^(bundle)$/,
|
|
67
|
+
go: /^(build|test|vet)$/,
|
|
68
|
+
cargo: /^(build|check|test|clippy)$/,
|
|
69
|
+
make: /^(.*)$/,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// pipeline stages after the first that count as sanctioned extraction
|
|
73
|
+
const TRANSFORM = new Set([
|
|
74
|
+
"grep",
|
|
75
|
+
"egrep",
|
|
76
|
+
"fgrep",
|
|
77
|
+
"rg",
|
|
78
|
+
"ripgrep",
|
|
79
|
+
"awk",
|
|
80
|
+
"gawk",
|
|
81
|
+
"jq",
|
|
82
|
+
"sed",
|
|
83
|
+
"sort",
|
|
84
|
+
"uniq",
|
|
85
|
+
"cut",
|
|
86
|
+
"tr",
|
|
87
|
+
"python",
|
|
88
|
+
"python3",
|
|
89
|
+
"node",
|
|
90
|
+
"perl",
|
|
91
|
+
"ruby",
|
|
92
|
+
"xargs",
|
|
93
|
+
"tee",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
// R2 exemption: a later pipeline stage that filters or limits the build output
|
|
97
|
+
// (grep/rg/awk/sed with a pattern, head/tail with a count, `| wc -l`) leaves the
|
|
98
|
+
// caller only that slice — the shape the rule asks for. A bare `npx tsc` /
|
|
99
|
+
// `npm run …` / `makepkg` dump stays blocked.
|
|
100
|
+
const OUTPUT_FILTER = new Set([
|
|
101
|
+
"grep",
|
|
102
|
+
"egrep",
|
|
103
|
+
"fgrep",
|
|
104
|
+
"rg",
|
|
105
|
+
"ripgrep",
|
|
106
|
+
"awk",
|
|
107
|
+
"gawk",
|
|
108
|
+
"sed",
|
|
109
|
+
"jq",
|
|
110
|
+
"cut",
|
|
111
|
+
"sort",
|
|
112
|
+
"uniq",
|
|
113
|
+
"tr",
|
|
114
|
+
"head",
|
|
115
|
+
"tail",
|
|
116
|
+
"wc",
|
|
117
|
+
]);
|
|
118
|
+
// Filters that only shape output when given a pattern/program argument.
|
|
119
|
+
const FILTER_NEEDS_OPERAND = new Set([
|
|
120
|
+
"grep",
|
|
121
|
+
"egrep",
|
|
122
|
+
"fgrep",
|
|
123
|
+
"rg",
|
|
124
|
+
"ripgrep",
|
|
125
|
+
"awk",
|
|
126
|
+
"gawk",
|
|
127
|
+
"sed",
|
|
128
|
+
"jq",
|
|
129
|
+
"cut",
|
|
130
|
+
"tr",
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
// Stages that RESTRICT what grep/rg would otherwise print: a re-filter with a
|
|
134
|
+
// pattern/program (grep/rg/awk/sed/jq), a limiter (head/tail), a count (wc) or a
|
|
135
|
+
// field projection (cut). Reshaping stages (sort/uniq/tr/tee) leave a repo-wide
|
|
136
|
+
// match dump intact in the caller's context and do NOT qualify.
|
|
137
|
+
const OUTPUT_RESTRICT = new Set([
|
|
138
|
+
"grep",
|
|
139
|
+
"egrep",
|
|
140
|
+
"fgrep",
|
|
141
|
+
"rg",
|
|
142
|
+
"ripgrep",
|
|
143
|
+
"awk",
|
|
144
|
+
"gawk",
|
|
145
|
+
"sed",
|
|
146
|
+
"jq",
|
|
147
|
+
"head",
|
|
148
|
+
"tail",
|
|
149
|
+
"wc",
|
|
150
|
+
"cut",
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
/** A redirect into a /tmp log: nothing reaches the model directly, the caller
|
|
154
|
+
* reads the slice back with `read`. */
|
|
155
|
+
const TMP_LOG = /(?:^|\s)[0-9]?>{1,2}\s*\/tmp\//;
|
|
156
|
+
|
|
157
|
+
/** True when a later pipeline stage reduces grep/rg's own output to a derived
|
|
158
|
+
* slice — the shape canon R1 asks for ("print only the derived result"). */
|
|
159
|
+
function restrictsOutput(seg: string): boolean {
|
|
160
|
+
return pipeStages(seg)
|
|
161
|
+
.slice(1)
|
|
162
|
+
.some((s) => {
|
|
163
|
+
const words = stripRedirects(s).split(/\s+/).filter(Boolean);
|
|
164
|
+
if (!OUTPUT_RESTRICT.has(words[0])) return false;
|
|
165
|
+
if (!FILTER_NEEDS_OPERAND.has(words[0])) return true;
|
|
166
|
+
// `cut -c1-110` / `cut -f2` carry the field/char spec inside the flag token
|
|
167
|
+
if (words[0] === "cut" && words.slice(1).some((a) => /^-[a-zA-Z]*[cf][0-9]/.test(a)))
|
|
168
|
+
return true;
|
|
169
|
+
return words.slice(1).some((a) => !a.startsWith("-"));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** True when this segment pipes its output into a filter/limiter, or redirects
|
|
174
|
+
* it into a log file under /tmp (then nothing reaches the model directly). */
|
|
175
|
+
function filtersOutput(seg: string): boolean {
|
|
176
|
+
const stages = pipeStages(seg);
|
|
177
|
+
const filtered = stages.slice(1).some((s) => {
|
|
178
|
+
const words = stripRedirects(s).split(/\s+/).filter(Boolean);
|
|
179
|
+
if (!OUTPUT_FILTER.has(words[0])) return false;
|
|
180
|
+
if (!FILTER_NEEDS_OPERAND.has(words[0])) return true;
|
|
181
|
+
return words.slice(1).some((a) => !a.startsWith("-"));
|
|
182
|
+
});
|
|
183
|
+
if (filtered) return true;
|
|
184
|
+
return TMP_LOG.test(seg);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function log(command: string, rule: string): void {
|
|
188
|
+
hookLog("command-guard", "block", { rule, command: command.slice(0, 200) });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Read the calling session's active tool names so a block names only tools it
|
|
192
|
+
* has. The hook context carries no tool list (ExtensionContext exposes ui,
|
|
193
|
+
* mode, cwd, sessionManager, model, … and the tool_call event only toolName/
|
|
194
|
+
* input/toolCallId), so `pi.getActiveTools()` is the ONE capability signal; it
|
|
195
|
+
* reflects THIS pi process, and a pi-subagent child runs its own process on a
|
|
196
|
+
* much smaller menu.
|
|
197
|
+
* Returns undefined when the API or the call is unavailable — callers then
|
|
198
|
+
* fall back to wording that is true for every session shape. */
|
|
199
|
+
function callerCaps(pi: ExtensionAPI): GuardCaps | undefined {
|
|
200
|
+
try {
|
|
201
|
+
const get = (pi as { getActiveTools?: () => string[] }).getActiveTools;
|
|
202
|
+
if (typeof get !== "function") return undefined;
|
|
203
|
+
const tools = get.call(pi) ?? [];
|
|
204
|
+
return { grep: tools.includes("grep"), build: tools.includes("build") };
|
|
205
|
+
} catch {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function segments(command: string): string[] {
|
|
211
|
+
return command
|
|
212
|
+
.split(/&&|\|\||;|\n/)
|
|
213
|
+
.map((s) => s.trim())
|
|
214
|
+
.filter(Boolean);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function pipeStages(seg: string): string[] {
|
|
218
|
+
return seg
|
|
219
|
+
.split("|")
|
|
220
|
+
.map((s) => s.trim())
|
|
221
|
+
.filter(Boolean);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Drop file-output/input redirect tokens (2>/dev/null, > out.log, < in) from a stage. */
|
|
225
|
+
function stripRedirects(stage: string): string {
|
|
226
|
+
return stage.replace(/[0-9]?[<>]{1,2}\s*\S+/g, " ");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function firstWord(stage: string): string {
|
|
230
|
+
return stripRedirects(stage).split(/\s+/).filter(Boolean)[0] || "";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const R2_REASON = (caps?: GuardCaps): string =>
|
|
234
|
+
`R2: raw build/checker output — pipe it into a filter (\`2>&1 | grep -E 'error TS' | head -20\`), or write it under /tmp and read the slice${caps?.build ? ", or use the build tool" : ""}.`;
|
|
235
|
+
|
|
236
|
+
/** What the CALLING SESSION can actually call. Interactive parent sessions may
|
|
237
|
+
* carry tools a child never loads; subagent children run on a smaller menu
|
|
238
|
+
* (`build` is registered by the build package, which subagent sets do not
|
|
239
|
+
* load, and the ctx_* family is often absent). Undefined = unknown (the
|
|
240
|
+
* message then stays true for both shapes — it leads with the tool-free
|
|
241
|
+
* shell form). */
|
|
242
|
+
export type GuardCaps = { grep: boolean; build: boolean };
|
|
243
|
+
|
|
244
|
+
/** grep/rg via bash dumps raw file content into the context. Every variant
|
|
245
|
+
* states that rule, names the tool ONLY when this session has it, and always
|
|
246
|
+
* gives the shell form that satisfies the rule on its own — a pipeline whose
|
|
247
|
+
* later stage prints only the derived result, which any session shape can run. */
|
|
248
|
+
const R1_PATHS = (caps?: GuardCaps): string =>
|
|
249
|
+
caps?.grep
|
|
250
|
+
? "R1: grep/rg over an explicit path dumps raw file content — use the grep tool (path-unrestricted), or rerun it as a pipeline that prints only the derived result (`grep -rn <pat> <path> | head -20`, `| wc -l`)."
|
|
251
|
+
: caps
|
|
252
|
+
? "R1: grep/rg over an explicit path dumps raw file content — no grep tool in this session: rerun it as a pipeline that prints only the derived result (`grep -rn <pat> <path> | head -20`, `| wc -l`), or `read` with offset/limit."
|
|
253
|
+
: "R1: grep/rg over an explicit path dumps raw file content — rerun it as a pipeline that prints only the derived result (`grep -rn <pat> <path> | head -20`, `| wc -l`), or use the grep tool where the session has one.";
|
|
254
|
+
|
|
255
|
+
const R1_RECURSIVE = (caps?: GuardCaps): string =>
|
|
256
|
+
caps?.grep
|
|
257
|
+
? "R1: cwd-recursive grep/rg dumps raw file content — use the grep tool (path-unrestricted; scope it with a path or glob pattern), or rerun it as a pipeline that prints only the derived result (`grep -rn <pat> . | head -20`)."
|
|
258
|
+
: caps
|
|
259
|
+
? "R1: cwd-recursive grep/rg dumps raw file content — no grep tool in this session: scope it with a path and rerun it as a pipeline that prints only the derived result (`grep -rn <pat> <path> | head -20`), or `read` the file with offset/limit."
|
|
260
|
+
: "R1: cwd-recursive grep/rg dumps raw file content — scope it with a path and rerun it as a pipeline that prints only the derived result (`grep -rn <pat> <path> | head -20`), or use the grep tool where the session has one.";
|
|
261
|
+
|
|
262
|
+
/** Returns a redirect reason if this segment is a banned bypass, else null. */
|
|
263
|
+
export function inspect(seg: string, caps?: GuardCaps): string | null {
|
|
264
|
+
if (/[$<]/.test(seg)) return null; // vars/regex-$ anchors, heredoc/input-redirect → sanctioned
|
|
265
|
+
const stages = pipeStages(seg);
|
|
266
|
+
const base = stripRedirects(stages[0]);
|
|
267
|
+
const w = base.split(/\s+/).filter(Boolean);
|
|
268
|
+
if (w.length === 0) return null;
|
|
269
|
+
// Prefix wrappers: skip the wrapper AND its own operands, or the operand
|
|
270
|
+
// becomes the command name. `timeout 300 npx tsc` read as cmd "300" and
|
|
271
|
+
// `env FOO=1 npx tsc` as cmd "FOO=1", so every rule below missed both.
|
|
272
|
+
// `timeout [OPTIONS] DURATION COMMAND`:
|
|
273
|
+
if (w[0] === "timeout") {
|
|
274
|
+
w.shift();
|
|
275
|
+
while (w.length > 0 && (/^-/.test(w[0]) || /^[0-9]+(\.[0-9]+)?[smhd]?$/.test(w[0]))) w.shift();
|
|
276
|
+
} else if (w[0] === "env") {
|
|
277
|
+
// `env [OPTIONS] [NAME=VALUE]... COMMAND`:
|
|
278
|
+
w.shift();
|
|
279
|
+
while (w.length > 0 && (/^-/.test(w[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0]))) w.shift();
|
|
280
|
+
} else if (w[0] === "sudo") {
|
|
281
|
+
// sudo's own option operands (e.g. `sudo -u user cmd`) are NOT skipped here.
|
|
282
|
+
w.shift();
|
|
283
|
+
}
|
|
284
|
+
if (w.length === 0) return null;
|
|
285
|
+
const cmd = w[0];
|
|
286
|
+
const rest = w.slice(1);
|
|
287
|
+
const hasTransform = stages.slice(1).some((s) => TRANSFORM.has(firstWord(s)));
|
|
288
|
+
|
|
289
|
+
// grep/rg: checked BEFORE the glob skip — quoted glob patterns ('*.ts') and
|
|
290
|
+
// abs/relative path operands are exactly the big repo-dump shapes
|
|
291
|
+
if (cmd === "grep" || cmd === "egrep" || cmd === "fgrep" || cmd === "rg" || cmd === "ripgrep") {
|
|
292
|
+
// Listing mode returns file NAMES, not file content — allowed, as documented.
|
|
293
|
+
if (rest.includes("--files")) return null;
|
|
294
|
+
// Content-free grep output prints no file content: -c/--count answers a
|
|
295
|
+
// number, -l/-L/--files-with-matches answer file names. The R1 harm (raw
|
|
296
|
+
// content in the context) is absent, so these pass.
|
|
297
|
+
const contentFree = rest.some(
|
|
298
|
+
(a) =>
|
|
299
|
+
(!a.startsWith("--") && /^-[a-zA-Z]*[clL][a-zA-Z]*$/.test(a)) ||
|
|
300
|
+
a === "--count" ||
|
|
301
|
+
a === "--files-with-matches" ||
|
|
302
|
+
a === "--files-without-match",
|
|
303
|
+
);
|
|
304
|
+
if (contentFree) return null;
|
|
305
|
+
const ops = rest.filter((a) => !a.startsWith("-"));
|
|
306
|
+
if (ops.length > 0) {
|
|
307
|
+
const paths = ops.slice(1); // ops[0] is the pattern (or a path list for rg --files)
|
|
308
|
+
const recursive =
|
|
309
|
+
paths.length === 0 && (cmd !== "grep" || rest.some((a) => /^-[a-zA-Z]*[rR]/.test(a)));
|
|
310
|
+
if (paths.length > 0 || recursive) {
|
|
311
|
+
// grep's own output is the harm, later stage or not: a restricting
|
|
312
|
+
// pipeline prints a derived result, a /tmp redirect keeps it out of the
|
|
313
|
+
// context entirely — both pass. Bare dumps and reshaping stages block.
|
|
314
|
+
if (!restrictsOutput(seg) && !TMP_LOG.test(seg))
|
|
315
|
+
return paths.length > 0 ? R1_PATHS(caps) : R1_RECURSIVE(caps);
|
|
316
|
+
}
|
|
317
|
+
// plain `grep pat` with no path reads stdin → harmless
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (/\*/.test(seg)) return null; // unquoted glob operands → sanctioned dynamic use
|
|
322
|
+
|
|
323
|
+
// R2: raw build/checker output — a bare invocation dumps everything; a
|
|
324
|
+
// pipeline into a filter/limiter (or a redirect into /tmp) passes.
|
|
325
|
+
if (!filtersOutput(seg)) {
|
|
326
|
+
if (BUILD_FIRST.has(cmd)) return R2_REASON(caps);
|
|
327
|
+
if (BUILD_PAIRS[cmd]) {
|
|
328
|
+
const re = BUILD_PAIRS[cmd];
|
|
329
|
+
if (cmd === "make" ? true : rest.some((a) => re.test(a.replace(/^['"]|['"]$/g, ""))))
|
|
330
|
+
return R2_REASON(caps);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// R1 file-content reads — only when no transforming stage follows
|
|
335
|
+
if (!hasTransform) {
|
|
336
|
+
if (cmd === "cat" && rest.length > 0) return "R1: file content via bash — use the read tool.";
|
|
337
|
+
if (
|
|
338
|
+
/^sed$/.test(cmd) &&
|
|
339
|
+
rest.some(
|
|
340
|
+
(a, i) => a === "-n" && rest[i + 1] && /^\d+[,']/.test(rest[i + 1].replace(/['"]/g, "")),
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
return "R1: line-range print via sed — use read with offset/limit.";
|
|
344
|
+
if (cmd === "head" && rest.length > 0 && !rest.includes("-f"))
|
|
345
|
+
return "R1: head via bash — use read with limit.";
|
|
346
|
+
if (cmd === "tail" && rest.length > 0 && !rest.includes("-f"))
|
|
347
|
+
return "R1: tail via bash — use read with offset/limit.";
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── RAW-INPUT: synthetic input goes through the `inject` wrapper ────────────
|
|
353
|
+
//
|
|
354
|
+
// The harm is a HELD button/key, not the injection itself: `ydotool click 0x40`
|
|
355
|
+
// presses BTN_LEFT with no release, and the device keeps reporting it pressed,
|
|
356
|
+
// so every real left click is swallowed machine-wide until the release is sent.
|
|
357
|
+
// The wrapper pairs every press with its release (preflight repair of an
|
|
358
|
+
// existing leak, paired presses, release from an EXIT/INT/TERM trap), so raw
|
|
359
|
+
// invocations are blocked in every tool that can reach the binary.
|
|
360
|
+
//
|
|
361
|
+
// Match = an INVOCATION, not the name: `ydotool` in a command position followed
|
|
362
|
+
// by one of its subcommands. `command -v ydotool`, `pkill -f ydotool` and prose
|
|
363
|
+
// that merely names the binary therefore pass, while `… ; ydotool click 0x40`
|
|
364
|
+
// and `execSync('ydotool click 0x40')` block. An optional path prefix covers
|
|
365
|
+
// /usr/bin/ydotool; `ydotoold` never matches (its token continues with `d`).
|
|
366
|
+
const RAW_INPUT_SUBCOMMANDS = new Set(["click", "mousemove", "type", "key", "debug", "bakers"]);
|
|
367
|
+
const RAW_INPUT_INVOKE =
|
|
368
|
+
/(?:^|[\s'"`;&|()$=])(?:[^\s'"`;&|()$=]*\/)?ydotool\s+([A-Za-z][A-Za-z-]*)/g;
|
|
369
|
+
|
|
370
|
+
/** Escape hatch for a deliberate raw invocation: the marker must be present in
|
|
371
|
+
* the inspected text, so the bypass is visible in the transcript and the log. */
|
|
372
|
+
const RAW_INPUT_ESCAPE = "raw-ydotool-ok";
|
|
373
|
+
|
|
374
|
+
const RAW_INPUT_REASON =
|
|
375
|
+
"RAW-INPUT: raw `ydotool` is blocked — a leaked press (`click 0x40` with no release) leaves the button held on the virtual device and kills every real left click machine-wide until it is released. Use the `inject` wrapper: `inject status` (read-only probe), `inject click left [--at X Y] [--count N]`, `inject drag X1 Y1 X2 Y2`, `inject move X Y`, `inject scroll N`, `inject type TEXT`, `inject key CODE...`, `inject release` (clear a leak). It pre-checks the device, pairs every press with its release and releases from an EXIT/INT/TERM trap. Deliberate raw use: put the marker `raw-ydotool-ok` in the command.";
|
|
376
|
+
|
|
377
|
+
/** Every text a tool call can reach the input binary through. The ctx_* family
|
|
378
|
+
* carries the command inside sandboxed code (execSync/spawn), which a
|
|
379
|
+
* bash-only scan never sees. */
|
|
380
|
+
export function callTexts(toolName: string, input: unknown): string[] {
|
|
381
|
+
const i = (input ?? {}) as { command?: unknown; code?: unknown; commands?: unknown };
|
|
382
|
+
if (toolName === "bash") return typeof i.command === "string" ? [i.command] : [];
|
|
383
|
+
if (toolName === "ctx_execute" || toolName === "ctx_execute_file") {
|
|
384
|
+
return typeof i.code === "string" ? [i.code] : [];
|
|
385
|
+
}
|
|
386
|
+
if (toolName === "ctx_batch_execute") {
|
|
387
|
+
const out: string[] = [];
|
|
388
|
+
if (Array.isArray(i.commands)) {
|
|
389
|
+
for (const c of i.commands) {
|
|
390
|
+
const cmd = (c as { command?: unknown })?.command;
|
|
391
|
+
if (typeof cmd === "string") out.push(cmd);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (typeof i.code === "string") out.push(i.code);
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Returns the RAW-INPUT block reason when a text invokes the raw binary. */
|
|
401
|
+
export function inspectInjection(text: string): string | null {
|
|
402
|
+
if (text.includes(RAW_INPUT_ESCAPE)) return null;
|
|
403
|
+
RAW_INPUT_INVOKE.lastIndex = 0;
|
|
404
|
+
let m = RAW_INPUT_INVOKE.exec(text);
|
|
405
|
+
while (m !== null) {
|
|
406
|
+
if (RAW_INPUT_SUBCOMMANDS.has(m[1])) return RAW_INPUT_REASON;
|
|
407
|
+
m = RAW_INPUT_INVOKE.exec(text);
|
|
408
|
+
}
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function register(pi: ExtensionAPI): void {
|
|
413
|
+
pi.on("tool_call", (event) => {
|
|
414
|
+
const toolName = String((event as { toolName?: unknown }).toolName ?? "");
|
|
415
|
+
const input = (event as { input?: unknown }).input;
|
|
416
|
+
|
|
417
|
+
for (const text of callTexts(toolName, input)) {
|
|
418
|
+
const injected = inspectInjection(text);
|
|
419
|
+
if (injected) {
|
|
420
|
+
log(text, "RAW-INPUT");
|
|
421
|
+
return { block: true, reason: injected };
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (!isToolCallEventType("bash", event)) return;
|
|
426
|
+
const command = event.input.command || "";
|
|
427
|
+
const caps = callerCaps(pi);
|
|
428
|
+
for (const seg of segments(command)) {
|
|
429
|
+
const reason = inspect(seg, caps);
|
|
430
|
+
if (reason) {
|
|
431
|
+
log(command, reason.slice(0, 3));
|
|
432
|
+
return { block: true, reason: `${reason} (blocked segment: "${seg.slice(0, 80)}")` };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export default function (pi: ExtensionAPI): void {
|
|
439
|
+
try {
|
|
440
|
+
register(pi);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
hookLog("command-guard", "register-failed", {
|
|
443
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tinoy/pi-command-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Block destructive shell commands before they run, and name the safe alternative in the block reason.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/tinoy1336/pi-extensions.git",
|
|
9
|
+
"directory": "packages/command-guard"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/tinoy1336/pi-extensions/tree/main/packages/command-guard#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/tinoy1336/pi-extensions/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "command-guard.ts",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pi-package"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
"command-guard.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./command-guard.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@tinoy/pi-ext-lib": "*"
|
|
38
|
+
}
|
|
39
|
+
}
|