@aliou/pi-guardrails 0.5.3 → 0.6.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/shell-utils.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Shared shell AST helpers used by guardrails hooks.
3
+ *
4
+ * Each hook imports `parse` from `@aliou/sh` directly and uses these
5
+ * for common AST operations.
6
+ */
7
+
8
+ import type {
9
+ Command,
10
+ Program,
11
+ SimpleCommand,
12
+ Statement,
13
+ Word,
14
+ WordPart,
15
+ } from "@aliou/sh";
16
+
17
+ /**
18
+ * Resolve a Word node to its literal string value.
19
+ * Concatenates Literal, SglQuoted, and simple DblQuoted parts.
20
+ * For parts containing parameter expansions, command substitutions, etc.,
21
+ * includes the raw text representation (e.g. `$VAR`).
22
+ */
23
+ export function wordToString(word: Word): string {
24
+ return word.parts.map(partToString).join("");
25
+ }
26
+
27
+ function partToString(part: WordPart): string {
28
+ switch (part.type) {
29
+ case "Literal":
30
+ return part.value;
31
+ case "SglQuoted":
32
+ return part.value;
33
+ case "DblQuoted":
34
+ return part.parts.map(partToString).join("");
35
+ case "ParamExp":
36
+ return part.short
37
+ ? `$${part.param.value}`
38
+ : `\${${part.param.value}${part.op ?? ""}${part.value ? wordToString(part.value) : ""}}`;
39
+ case "CmdSubst":
40
+ return "$(...)";
41
+ case "ArithExp":
42
+ return `$((${part.expr}))`;
43
+ case "ProcSubst":
44
+ return `${part.op}(...)`;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Walk the AST and call `callback` for every SimpleCommand found at any
50
+ * nesting depth. Returns early if callback returns `true`.
51
+ */
52
+ export function walkCommands(
53
+ node: Program,
54
+ callback: (cmd: SimpleCommand) => boolean | undefined,
55
+ ): void {
56
+ for (const stmt of node.body) {
57
+ if (walkStatement(stmt, callback)) return;
58
+ }
59
+ }
60
+
61
+ function walkStatement(
62
+ stmt: Statement,
63
+ callback: (cmd: SimpleCommand) => boolean | undefined,
64
+ ): boolean {
65
+ return walkCommand(stmt.command, callback);
66
+ }
67
+
68
+ function walkStatements(
69
+ stmts: Statement[],
70
+ callback: (cmd: SimpleCommand) => boolean | undefined,
71
+ ): boolean {
72
+ for (const stmt of stmts) {
73
+ if (walkStatement(stmt, callback)) return true;
74
+ }
75
+ return false;
76
+ }
77
+
78
+ function walkCommand(
79
+ cmd: Command,
80
+ callback: (cmd: SimpleCommand) => boolean | undefined,
81
+ ): boolean {
82
+ switch (cmd.type) {
83
+ case "SimpleCommand":
84
+ return callback(cmd) === true;
85
+
86
+ case "Pipeline":
87
+ return walkStatements(cmd.commands, callback);
88
+
89
+ case "Logical":
90
+ return (
91
+ walkStatement(cmd.left, callback) || walkStatement(cmd.right, callback)
92
+ );
93
+
94
+ case "Subshell":
95
+ case "Block":
96
+ return walkStatements(cmd.body, callback);
97
+
98
+ case "IfClause":
99
+ return (
100
+ walkStatements(cmd.cond, callback) ||
101
+ walkStatements(cmd.then, callback) ||
102
+ (cmd.else ? walkStatements(cmd.else, callback) : false)
103
+ );
104
+
105
+ case "ForClause":
106
+ case "SelectClause":
107
+ case "WhileClause":
108
+ return (
109
+ ("cond" in cmd && cmd.cond
110
+ ? walkStatements(cmd.cond, callback)
111
+ : false) || walkStatements(cmd.body, callback)
112
+ );
113
+
114
+ case "CaseClause":
115
+ for (const item of cmd.items) {
116
+ if (walkStatements(item.body, callback)) return true;
117
+ }
118
+ return false;
119
+
120
+ case "FunctionDecl":
121
+ return walkStatements(cmd.body, callback);
122
+
123
+ case "TimeClause":
124
+ return walkStatement(cmd.command, callback);
125
+
126
+ case "CoprocClause":
127
+ return walkStatement(cmd.body, callback);
128
+
129
+ case "CStyleLoop":
130
+ return walkStatements(cmd.body, callback);
131
+
132
+ // These don't contain nested commands we need to walk
133
+ case "TestClause":
134
+ case "ArithCmd":
135
+ case "DeclClause":
136
+ case "LetClause":
137
+ return false;
138
+ }
139
+ }
package/array-editor.ts DELETED
@@ -1,213 +0,0 @@
1
- import type { Component, SettingsListTheme } from "@mariozechner/pi-tui";
2
- import {
3
- Input,
4
- Key,
5
- matchesKey,
6
- truncateToWidth,
7
- visibleWidth,
8
- } from "@mariozechner/pi-tui";
9
-
10
- /**
11
- * A submenu component for editing string arrays inside a SettingsList.
12
- *
13
- * Modes:
14
- * - list: navigate items, delete with 'd', add with 'a', edit with 'e'/Enter
15
- * - add: text input for new item, confirm with Enter, cancel with Escape
16
- * - edit: text input pre-filled with current value, Enter saves, Escape cancels
17
- */
18
-
19
- export interface ArrayEditorOptions {
20
- label: string;
21
- items: string[];
22
- theme: SettingsListTheme;
23
- onSave: (items: string[]) => void;
24
- onDone: () => void;
25
- /** Max visible items before scrolling */
26
- maxVisible?: number;
27
- }
28
-
29
- export class ArrayEditor implements Component {
30
- private items: string[];
31
- private label: string;
32
- private theme: SettingsListTheme;
33
- private onSave: (items: string[]) => void;
34
- private onDone: () => void;
35
- private selectedIndex = 0;
36
- private maxVisible: number;
37
- private mode: "list" | "add" | "edit" = "list";
38
- private input: Input;
39
- private editIndex = -1;
40
-
41
- constructor(options: ArrayEditorOptions) {
42
- this.items = [...options.items];
43
- this.label = options.label;
44
- this.theme = options.theme;
45
- this.onSave = options.onSave;
46
- this.onDone = options.onDone;
47
- this.maxVisible = options.maxVisible ?? 10;
48
- this.input = new Input();
49
- this.input.onSubmit = (value: string) => {
50
- if (this.mode === "edit") {
51
- this.submitEdit(value);
52
- } else {
53
- this.submitAdd(value);
54
- }
55
- };
56
- this.input.onEscape = () => {
57
- this.mode = "list";
58
- this.editIndex = -1;
59
- };
60
- }
61
-
62
- private submitAdd(value: string) {
63
- const trimmed = value.trim();
64
- if (!trimmed) {
65
- this.mode = "list";
66
- return;
67
- }
68
- this.items.push(trimmed);
69
- this.selectedIndex = this.items.length - 1;
70
- this.save();
71
- this.mode = "list";
72
- this.input.setValue("");
73
- }
74
-
75
- private submitEdit(value: string) {
76
- const trimmed = value.trim();
77
- if (!trimmed) {
78
- // Empty value = cancel edit
79
- this.mode = "list";
80
- this.editIndex = -1;
81
- return;
82
- }
83
- this.items[this.editIndex] = trimmed;
84
- this.save();
85
- this.mode = "list";
86
- this.editIndex = -1;
87
- this.input.setValue("");
88
- }
89
-
90
- private deleteSelected() {
91
- if (this.items.length === 0) return;
92
- this.items.splice(this.selectedIndex, 1);
93
- if (this.selectedIndex >= this.items.length) {
94
- this.selectedIndex = Math.max(0, this.items.length - 1);
95
- }
96
- this.save();
97
- }
98
-
99
- private startEdit() {
100
- if (this.items.length === 0) return;
101
- this.editIndex = this.selectedIndex;
102
- this.mode = "edit";
103
- this.input.setValue(this.items[this.selectedIndex] as string);
104
- }
105
-
106
- private save() {
107
- this.onSave([...this.items]);
108
- }
109
-
110
- invalidate() {}
111
-
112
- render(width: number): string[] {
113
- const lines: string[] = [];
114
-
115
- // Header
116
- lines.push(this.theme.label(` ${this.label}`, true));
117
- lines.push("");
118
-
119
- if (this.mode === "add" || this.mode === "edit") {
120
- return [...lines, ...this.renderInputMode(width)];
121
- }
122
-
123
- return [...lines, ...this.renderListMode(width)];
124
- }
125
-
126
- private renderListMode(width: number): string[] {
127
- const lines: string[] = [];
128
-
129
- if (this.items.length === 0) {
130
- lines.push(this.theme.hint(" (empty)"));
131
- } else {
132
- const startIndex = Math.max(
133
- 0,
134
- Math.min(
135
- this.selectedIndex - Math.floor(this.maxVisible / 2),
136
- this.items.length - this.maxVisible,
137
- ),
138
- );
139
- const endIndex = Math.min(
140
- startIndex + this.maxVisible,
141
- this.items.length,
142
- );
143
-
144
- for (let i = startIndex; i < endIndex; i++) {
145
- const item = this.items[i];
146
- if (!item) continue;
147
- const isSelected = i === this.selectedIndex;
148
- const prefix = isSelected ? this.theme.cursor : " ";
149
- const prefixWidth = visibleWidth(prefix);
150
- const maxItemWidth = width - prefixWidth - 2;
151
- const text = this.theme.value(
152
- truncateToWidth(item, maxItemWidth, ""),
153
- isSelected,
154
- );
155
- lines.push(prefix + text);
156
- }
157
-
158
- if (startIndex > 0 || endIndex < this.items.length) {
159
- lines.push(
160
- this.theme.hint(` (${this.selectedIndex + 1}/${this.items.length})`),
161
- );
162
- }
163
- }
164
-
165
- lines.push("");
166
- lines.push(
167
- this.theme.hint(" a: add · e/Enter: edit · d: delete · Esc: back"),
168
- );
169
-
170
- return lines;
171
- }
172
-
173
- private renderInputMode(width: number): string[] {
174
- const lines: string[] = [];
175
- const label = this.mode === "edit" ? " Edit item:" : " New item:";
176
- lines.push(this.theme.hint(label));
177
- lines.push(` ${this.input.render(width - 4).join("")}`);
178
- lines.push("");
179
- lines.push(this.theme.hint(" Enter: confirm · Esc: cancel"));
180
- return lines;
181
- }
182
-
183
- handleInput(data: string) {
184
- if (this.mode === "add" || this.mode === "edit") {
185
- this.input.handleInput(data);
186
- return;
187
- }
188
-
189
- // List mode
190
- if (matchesKey(data, Key.up) || data === "k") {
191
- if (this.items.length === 0) return;
192
- this.selectedIndex =
193
- this.selectedIndex === 0
194
- ? this.items.length - 1
195
- : this.selectedIndex - 1;
196
- } else if (matchesKey(data, Key.down) || data === "j") {
197
- if (this.items.length === 0) return;
198
- this.selectedIndex =
199
- this.selectedIndex === this.items.length - 1
200
- ? 0
201
- : this.selectedIndex + 1;
202
- } else if (data === "a" || data === "A") {
203
- this.mode = "add";
204
- this.input.setValue("");
205
- } else if (data === "e" || data === "E" || matchesKey(data, Key.enter)) {
206
- this.startEdit();
207
- } else if (data === "d" || data === "D") {
208
- this.deleteSelected();
209
- } else if (matchesKey(data, Key.escape)) {
210
- this.onDone();
211
- }
212
- }
213
- }
@@ -1,96 +0,0 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import type { ResolvedConfig } from "../config-schema";
3
- import { emitBlocked } from "../events";
4
-
5
- /**
6
- * Enforces using a specific Node package manager (bun, pnpm, or npm).
7
- * Blocks commands using non-selected package managers.
8
- */
9
-
10
- const BUN_PATTERN = /\bbun\b/;
11
- const PNPM_PATTERN = /\bpnpm\b/;
12
- const NPM_PATTERN = /\bnpm\b/;
13
-
14
- type PackageManager = "bun" | "pnpm" | "npm";
15
-
16
- interface ManagerInfo {
17
- pattern: RegExp;
18
- name: string;
19
- installCmd: string;
20
- addCmd: string;
21
- runCmd: string;
22
- }
23
-
24
- const MANAGER_INFO: Record<PackageManager, ManagerInfo> = {
25
- bun: {
26
- pattern: BUN_PATTERN,
27
- name: "bun",
28
- installCmd: "bun install",
29
- addCmd: "bun add <package>",
30
- runCmd: "bun run <script>",
31
- },
32
- pnpm: {
33
- pattern: PNPM_PATTERN,
34
- name: "pnpm",
35
- installCmd: "pnpm install",
36
- addCmd: "pnpm add <package>",
37
- runCmd: "pnpm run <script>",
38
- },
39
- npm: {
40
- pattern: NPM_PATTERN,
41
- name: "npm",
42
- installCmd: "npm install",
43
- addCmd: "npm install <package>",
44
- runCmd: "npm run <script>",
45
- },
46
- };
47
-
48
- export function setupEnforcePackageManagerHook(
49
- pi: ExtensionAPI,
50
- config: ResolvedConfig,
51
- ) {
52
- if (!config.features.enforcePackageManager) return;
53
-
54
- const selectedManager = config.packageManager.selected;
55
- const selected = MANAGER_INFO[selectedManager];
56
-
57
- // Get all managers that should be blocked (all except the selected one)
58
- const blockedManagers = (
59
- Object.keys(MANAGER_INFO) as PackageManager[]
60
- ).filter((m) => m !== selectedManager);
61
-
62
- pi.on("tool_call", async (event, ctx) => {
63
- if (event.toolName !== "bash") return;
64
-
65
- const command = String(event.input.command ?? "");
66
-
67
- for (const blockedManager of blockedManagers) {
68
- const blocked = MANAGER_INFO[blockedManager];
69
-
70
- if (blocked.pattern.test(command)) {
71
- ctx.ui.notify(
72
- `Blocked ${blocked.name} command. Use ${selected.name} instead.`,
73
- "warning",
74
- );
75
-
76
- const reason =
77
- `This project uses ${selected.name} as its package manager. ` +
78
- `Use ${selected.name} instead of ${blocked.name}. ` +
79
- `Run \`${selected.installCmd}\` to install dependencies, ` +
80
- `\`${selected.addCmd}\` to add packages, ` +
81
- `and \`${selected.runCmd}\` to run scripts.`;
82
-
83
- emitBlocked(pi, {
84
- feature: "enforcePackageManager",
85
- toolName: "bash",
86
- input: event.input,
87
- reason,
88
- });
89
-
90
- return { block: true, reason };
91
- }
92
- }
93
-
94
- return;
95
- });
96
- }
@@ -1,41 +0,0 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import type { ResolvedConfig } from "../config-schema";
3
- import { emitBlocked } from "../events";
4
-
5
- /**
6
- * Blocks all brew commands. Homebrew is not installed on this machine.
7
- */
8
-
9
- const BREW_PATTERN = /\bbrew\b/;
10
-
11
- export function setupPreventBrewHook(pi: ExtensionAPI, config: ResolvedConfig) {
12
- if (!config.features.preventBrew) return;
13
-
14
- pi.on("tool_call", async (event, ctx) => {
15
- if (event.toolName !== "bash") return;
16
-
17
- const command = String(event.input.command ?? "");
18
-
19
- if (BREW_PATTERN.test(command)) {
20
- ctx.ui.notify(
21
- "Blocked brew command. Homebrew is not installed.",
22
- "warning",
23
- );
24
-
25
- const reason =
26
- "Homebrew is not installed on this machine. " +
27
- "Use Nix for package management instead. " +
28
- "Run packages via nix-shell or add them to the project's Nix configuration.";
29
-
30
- emitBlocked(pi, {
31
- feature: "preventBrew",
32
- toolName: "bash",
33
- input: event.input,
34
- reason,
35
- });
36
-
37
- return { block: true, reason };
38
- }
39
- return;
40
- });
41
- }
@@ -1,45 +0,0 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import type { ResolvedConfig } from "../config-schema";
3
- import { emitBlocked } from "../events";
4
-
5
- /**
6
- * Blocks all Python-related commands including python, python3, pip, poetry, etc.
7
- * Use uv for Python package management instead.
8
- */
9
-
10
- const PYTHON_PATTERN =
11
- /\b(python|python3|pip|pip3|poetry|pyenv|virtualenv|venv)\b/;
12
-
13
- export function setupPreventPythonHook(
14
- pi: ExtensionAPI,
15
- config: ResolvedConfig,
16
- ) {
17
- if (!config.features.preventPython) return;
18
-
19
- pi.on("tool_call", async (event, ctx) => {
20
- if (event.toolName !== "bash") return;
21
-
22
- const command = String(event.input.command ?? "");
23
-
24
- if (PYTHON_PATTERN.test(command)) {
25
- ctx.ui.notify("Blocked Python command. Use uv instead.", "warning");
26
-
27
- const reason =
28
- "Python is not available globally on this machine. " +
29
- "Use uv for Python package management instead. " +
30
- "Run `uv init` to create a new Python project, " +
31
- "or `uv run python` to run Python scripts. " +
32
- "Use `uv add` to install packages (replaces pip/poetry).";
33
-
34
- emitBlocked(pi, {
35
- feature: "preventPython",
36
- toolName: "bash",
37
- input: event.input,
38
- reason,
39
- });
40
-
41
- return { block: true, reason };
42
- }
43
- return;
44
- });
45
- }