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