@bacnh85/pi-plan 0.1.9 → 0.2.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.
Files changed (3) hide show
  1. package/index.ts +24 -108
  2. package/lib/bash-gating.ts +154 -0
  3. package/package.json +12 -2
package/index.ts CHANGED
@@ -4,6 +4,7 @@ import { Type } from "typebox";
4
4
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import { isDestructiveBash, isReadOnlyBash } from "./lib/bash-gating";
7
8
 
8
9
  const STATUS_KEY = "pi-plan";
9
10
  const PLAN_DIR = path.join(".agents", "plans");
@@ -11,7 +12,29 @@ const PLAN_TOOL = "write_plan";
11
12
  const PLAN_QUESTION_TOOL = "ask_plan_question";
12
13
  const PLAN_EXECUTE_COMMAND = "plan-execute";
13
14
  const PREFERENCES_FILE = path.join(os.homedir(), ".pi", "agent", "pi-plan", "preferences.json");
14
- const DEFAULT_PLAN_TOOLS = ["read", "bash", "grep", "find", "ls", "searxng_search", "brave_search", "brave_content", "firecrawl_search", "firecrawl_scrape", "firecrawl_map", "firecrawl_crawl", "web_status", PLAN_TOOL, PLAN_QUESTION_TOOL];
15
+ const SERENA_PLAN_TOOLS = [
16
+ "serena_status",
17
+ "serena_list_tools",
18
+ "serena_get_symbols_overview",
19
+ "serena_find_symbol",
20
+ "serena_find_referencing_symbols",
21
+ "serena_find_declaration",
22
+ "serena_find_implementations",
23
+ "serena_search_for_pattern",
24
+ "serena_get_current_config",
25
+ "serena_get_diagnostics_for_file",
26
+ "serena_list_memories",
27
+ "serena_read_memory",
28
+ ];
29
+
30
+ const DEFAULT_PLAN_TOOLS = [
31
+ "read", "bash", "grep", "find", "ls",
32
+ "searxng_search", "brave_search", "brave_content",
33
+ "firecrawl_search", "firecrawl_scrape", "firecrawl_map", "firecrawl_crawl",
34
+ "web_status",
35
+ ...SERENA_PLAN_TOOLS,
36
+ PLAN_TOOL, PLAN_QUESTION_TOOL,
37
+ ];
15
38
  const PLAN_ALLOWED_TOOLS = new Set(DEFAULT_PLAN_TOOLS);
16
39
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
17
40
 
@@ -52,28 +75,6 @@ interface PlanQuestionParams {
52
75
  allowOther?: boolean;
53
76
  }
54
77
 
55
- const DESTRUCTIVE_BASH_PATTERNS = [
56
- /\brm\b/i,
57
- /\brmdir\b/i,
58
- /\bmv\b/i,
59
- /\bcp\b/i,
60
- /\bmkdir\b/i,
61
- /\btouch\b/i,
62
- /\bchmod\b/i,
63
- /\bchown\b/i,
64
- /\btee\b/i,
65
- /(^|[^<])>(?!>)/,
66
- />>/,
67
- /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
68
- /\b(yarn|pnpm)\s+(add|remove|install|publish)/i,
69
- /\bpip\s+(install|uninstall)/i,
70
- /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|stash|cherry-pick|revert|tag|init|clone)/i,
71
- /\bsudo\b/i,
72
- /\bkill(all)?\b/i,
73
- /\b(pk|re)?kill\b/i,
74
- /\b(vim?|nano|emacs|code|subl)\b/i,
75
- ];
76
-
77
78
  function isThinkingLevel(value: string): value is ThinkingLevel {
78
79
  return (THINKING_LEVELS as readonly string[]).includes(value);
79
80
  }
@@ -169,91 +170,6 @@ async function savePreferences(preferences: PlanPreferences): Promise<void> {
169
170
  await rename(temporaryPath, PREFERENCES_FILE);
170
171
  }
171
172
 
172
- function isDestructiveBash(command: string): boolean {
173
- return DESTRUCTIVE_BASH_PATTERNS.some((pattern) => pattern.test(command));
174
- }
175
-
176
- function tokenizeSimpleCommand(command: string): string[] | undefined {
177
- const trimmed = command.trim();
178
- if (!trimmed) return [];
179
- if (/[;&|`$(){}<>]/.test(trimmed) || /\b(python|python3|node|ruby|perl|php|sh|bash|zsh|fish)\b/i.test(trimmed)) return undefined;
180
- if (/['"]/.test(trimmed)) return undefined;
181
- return trimmed.split(/\s+/).filter(Boolean);
182
- }
183
-
184
- function sanitizeCommand(command: string): string[] {
185
- let sanitized = command;
186
-
187
- // Strip /dev/null redirects: 2>/dev/null, >/dev/null, >>/dev/null, &>/dev/null
188
- sanitized = sanitized.replace(/\d*>>?\s*\/dev\/null/g, "");
189
- sanitized = sanitized.replace(/&>\s*\/dev\/null/g, "");
190
- // Strip fd redirections: 2>&1, 1>&2, etc.
191
- sanitized = sanitized.replace(/\s*\d*>&\d+\s*/g, " ");
192
-
193
- // Strip cd <path> && / cd <path> ; prefix
194
- sanitized = sanitized.replace(/^cd\s+(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s*(?:&&|;)\s*/i, "").trim();
195
-
196
- // If command contains pipes, split into segments and validate each
197
- if (sanitized.includes("|")) {
198
- return sanitized.split("|").map((s) => s.trim()).filter(Boolean);
199
- }
200
-
201
- return [sanitized.trim()];
202
- }
203
-
204
- function hasOptionValue(tokens: string[], index: number): boolean {
205
- return index + 1 < tokens.length && !tokens[index + 1].startsWith("-");
206
- }
207
-
208
- function isAllowedNpmMetadataCommand(tokens: string[]): boolean {
209
- if (tokens[0] !== "npm" || !["view", "info"].includes(tokens[1])) return false;
210
- let hasSpec = false;
211
- for (let index = 2; index < tokens.length; index += 1) {
212
- const token = tokens[index];
213
- if (["--registry", "--tag"].includes(token)) {
214
- if (!hasOptionValue(tokens, index)) return false;
215
- index += 1;
216
- continue;
217
- }
218
- if (token.startsWith("--registry=") || token.startsWith("--tag=") || token === "--json" || token === "--parseable" || token === "--silent") continue;
219
- if (token.startsWith("-")) return false;
220
- hasSpec = true;
221
- }
222
- return hasSpec;
223
- }
224
-
225
- function isAllowedGitCommand(tokens: string[]): boolean {
226
- if (tokens[0] !== "git" || !tokens[1]) return false;
227
- const subcommand = tokens[1];
228
- const args = tokens.slice(2);
229
- if (["add", "commit", "push", "pull", "merge", "rebase", "reset", "checkout", "switch", "restore", "stash", "cherry-pick", "revert", "tag", "init", "clone", "fetch", "remote", "config", "worktree"].includes(subcommand)) return false;
230
-
231
- if (subcommand === "status") return args.every((arg) => ["--short", "-s", "--porcelain", "--porcelain=v1", "--porcelain=v2", "--branch", "-b", "--ignored", "--ignored=matching", "--ignored=traditional", "--ignored=no", "--untracked-files", "--untracked-files=no", "--untracked-files=normal", "--untracked-files=all", "-uno", "-unormal", "-uall"].includes(arg));
232
- if (subcommand === "diff") return !args.some((arg) => arg === "--output" || arg.startsWith("--output=") || arg === "--ext-diff" || arg === "--textconv");
233
- if (["show", "log", "rev-parse", "ls-files"].includes(subcommand)) return true;
234
- if (subcommand === "branch") {
235
- const mutating = new Set(["-d", "-D", "-m", "-M", "-c", "-C", "--delete", "--move", "--copy", "--set-upstream-to", "--track", "--unset-upstream", "--edit-description"]);
236
- return !args.some((arg) => mutating.has(arg) || arg.startsWith("--set-upstream-to=") || arg === "-u");
237
- }
238
- return false;
239
- }
240
-
241
- export function isReadOnlyBash(command: string): boolean {
242
- const segments = sanitizeCommand(command);
243
- if (segments.length === 0) return true;
244
-
245
- return segments.every((segment) => {
246
- const tokens = tokenizeSimpleCommand(segment);
247
- if (!tokens) return false;
248
- if (tokens.length === 0) return true;
249
- const normalized = tokens[0] === "rtk" ? tokens.slice(1) : tokens;
250
- if (normalized.length === 0) return false;
251
- if (isAllowedGitCommand(normalized)) return true;
252
- if (isAllowedNpmMetadataCommand(normalized)) return true;
253
- return /^(rg|grep|find|fd|ls|pwd|cat|head|tail|sed|awk|wc|sort|uniq|cut|read)$/.test(normalized[0]);
254
- });
255
- }
256
-
257
173
  export default function piPlanExtension(pi: ExtensionAPI): void {
258
174
  let planModeEnabled = false;
259
175
  let executionMode = false;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Bash command gating logic for pi-plan's read-only plan mode.
3
+ * Extracted from index.ts so it can be tested without importing @earendil-works/pi-coding-agent.
4
+ */
5
+
6
+ export const DESTRUCTIVE_BASH_PATTERNS = [
7
+ // File-system modifying commands — match only at command start (^) or after
8
+ // command separators (; && || | &), not after plain spaces within arguments.
9
+ /(?:^|(?:;\s*|&&\s*|\|\|?\s*|&\s*))(?:rm|rmdir|mv|cp|mkdir|touch|chmod|chown|tee)\b/i,
10
+ /(?:^|(?:;\s*|&&\s*|\|\|?\s*|&\s*))sudo\b/i,
11
+ /(?:^|(?:;\s*|&&\s*|\|\|?\s*|&\s*))(?:kill(?:all)?|pkill|rekill)\b/i,
12
+ /(?:^|(?:;\s*|&&\s*|\|\|?\s*|&\s*))(?:vim?|nano|emacs|code|subl)\b/i,
13
+ // File output redirects >, >>, 1>, 2> (NOT fd duplication like 2>&1)
14
+ // Matches after whitespace or command separators since that's how shell redirects work
15
+ /(?:^|[\s;|&])[0-9]*>(?!>|&\d)/,
16
+ />>/,
17
+ // Package manager commands (\b prefix correctly checks first word)
18
+ /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
19
+ /\b(yarn|pnpm)\s+(add|remove|install|publish)/i,
20
+ /\bpip\s+(install|uninstall)/i,
21
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|stash|cherry-pick|revert|tag|init|clone)/i,
22
+ ];
23
+
24
+ export function isDestructiveBash(command: string): boolean {
25
+ return DESTRUCTIVE_BASH_PATTERNS.some((pattern) => pattern.test(command));
26
+ }
27
+
28
+ export function tokenizeSimpleCommand(command: string): string[] | undefined {
29
+ const trimmed = command.trim();
30
+ if (!trimmed) return [];
31
+ if (/[;&|`$(){}<>]/.test(trimmed)) return undefined;
32
+ // Only check the first word for interpreter names, not arguments.
33
+ // e.g., "which node" is read-only (node is an argument), "node script.js" is not.
34
+ const firstWord = trimmed.split(/\s+/)[0];
35
+ if (firstWord && /\b(python|python3|node|ruby|perl|php|sh|bash|zsh|fish)\b/i.test(firstWord)) return undefined;
36
+ if (/['"]/.test(trimmed)) {
37
+ // Parse tokens respecting quotes so quoted paths like ls "C:\\path" work.
38
+ const tokens: string[] = [];
39
+ let current = "";
40
+ let inQuote: '"' | "'" | null = null;
41
+ for (const ch of trimmed) {
42
+ if (inQuote) {
43
+ if (ch === inQuote) {
44
+ inQuote = null;
45
+ } else {
46
+ current += ch;
47
+ }
48
+ } else if (ch === '"' || ch === "'") {
49
+ inQuote = ch;
50
+ } else if (/\s/.test(ch)) {
51
+ if (current) {
52
+ tokens.push(current);
53
+ current = "";
54
+ }
55
+ } else {
56
+ current += ch;
57
+ }
58
+ }
59
+ if (current) tokens.push(current);
60
+ return tokens;
61
+ }
62
+ return trimmed.split(/\s+/).filter(Boolean);
63
+ }
64
+
65
+ export function sanitizeCommand(command: string): string[] {
66
+ let sanitized = command;
67
+
68
+ // Strip /dev/null redirects: 2>/dev/null, >/dev/null, >>/dev/null, &>/dev/null
69
+ sanitized = sanitized.replace(/\d*>>?\s*\/dev\/null/g, "");
70
+ sanitized = sanitized.replace(/&>\s*\/dev\/null/g, "");
71
+ // Strip Windows nul redirects: 2>nul, >nul, >>nul, &>nul
72
+ sanitized = sanitized.replace(/\d*>>?\s*nul\b/g, "");
73
+ sanitized = sanitized.replace(/&>\s*nul\b/g, "");
74
+ // Strip fd redirections: 2>&1, 1>&2, etc.
75
+ sanitized = sanitized.replace(/\s*\d*>&\d+\s*/g, " ");
76
+
77
+ // Strip cd <path> && / cd <path> ; prefix
78
+ sanitized = sanitized.replace(/^cd\s+(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s*(?:&&|;)\s*/i, "").trim();
79
+
80
+ // Split on && and ; (chaining operators) to validate each segment independently.
81
+ // || remains blocked because it introduces conditional/fallback execution paths.
82
+ const chainSegments = sanitized.split(/\s*&&\s*|\s*;\s*/).map((s) => s.trim()).filter(Boolean);
83
+ if (chainSegments.length === 0) return [];
84
+
85
+ // Within each chain segment, further split on pipes for per-segment validation
86
+ const allSegments: string[] = [];
87
+ for (const segment of chainSegments) {
88
+ if (segment.includes("|")) {
89
+ allSegments.push(...segment.split("|").map((s) => s.trim()).filter(Boolean));
90
+ } else {
91
+ allSegments.push(segment);
92
+ }
93
+ }
94
+
95
+ return allSegments;
96
+ }
97
+
98
+ function hasOptionValue(tokens: string[], index: number): boolean {
99
+ return index + 1 < tokens.length && !tokens[index + 1].startsWith("-");
100
+ }
101
+
102
+ function isAllowedNpmMetadataCommand(tokens: string[]): boolean {
103
+ if (tokens[0] !== "npm" || !["view", "info"].includes(tokens[1])) return false;
104
+ let hasSpec = false;
105
+ for (let index = 2; index < tokens.length; index += 1) {
106
+ const token = tokens[index];
107
+ if (["--registry", "--tag"].includes(token)) {
108
+ if (!hasOptionValue(tokens, index)) return false;
109
+ index += 1;
110
+ continue;
111
+ }
112
+ if (token.startsWith("--registry=") || token.startsWith("--tag=") || token === "--json" || token === "--parseable" || token === "--silent") continue;
113
+ if (token.startsWith("-")) return false;
114
+ hasSpec = true;
115
+ }
116
+ return hasSpec;
117
+ }
118
+
119
+ function isAllowedGitCommand(tokens: string[]): boolean {
120
+ if (tokens[0] !== "git" || !tokens[1]) return false;
121
+ const subcommand = tokens[1];
122
+ // -- is a standard POSIX argument separator; filter it out before subcommand-specific checks
123
+ const args = tokens.slice(2).filter((arg) => arg !== "--");
124
+ if (["add", "commit", "push", "pull", "merge", "rebase", "reset", "checkout", "switch", "restore", "stash", "cherry-pick", "revert", "tag", "init", "clone", "fetch", "remote", "config", "worktree"].includes(subcommand)) return false;
125
+
126
+ if (subcommand === "status") {
127
+ // Flags must match the allowlist; positional args (paths) are always read-only
128
+ const flags = args.filter((arg) => arg.startsWith("-"));
129
+ return flags.every((arg) => ["--short", "-s", "--porcelain", "--porcelain=v1", "--porcelain=v2", "--branch", "-b", "--ignored", "--ignored=matching", "--ignored=traditional", "--ignored=no", "--untracked-files", "--untracked-files=no", "--untracked-files=normal", "--untracked-files=all", "-uno", "-unormal", "-uall"].includes(arg));
130
+ }
131
+ if (subcommand === "diff") return !args.some((arg) => arg === "--output" || arg.startsWith("--output=") || arg === "--ext-diff" || arg === "--textconv");
132
+ if (["show", "log", "rev-parse", "ls-files"].includes(subcommand)) return true;
133
+ if (subcommand === "branch") {
134
+ const mutating = new Set(["-d", "-D", "-m", "-M", "-c", "-C", "--delete", "--move", "--copy", "--set-upstream-to", "--track", "--unset-upstream", "--edit-description"]);
135
+ return !args.some((arg) => mutating.has(arg) || arg.startsWith("--set-upstream-to=") || arg === "-u");
136
+ }
137
+ return false;
138
+ }
139
+
140
+ export function isReadOnlyBash(command: string): boolean {
141
+ const segments = sanitizeCommand(command);
142
+ if (segments.length === 0) return true;
143
+
144
+ return segments.every((segment) => {
145
+ const tokens = tokenizeSimpleCommand(segment);
146
+ if (!tokens) return false;
147
+ if (tokens.length === 0) return true;
148
+ const normalized = tokens[0] === "rtk" ? tokens.slice(1) : tokens;
149
+ if (normalized.length === 0) return false;
150
+ if (isAllowedGitCommand(normalized)) return true;
151
+ if (isAllowedNpmMetadataCommand(normalized)) return true;
152
+ return /^(rg|grep|find|fd|ls|pwd|cat|head|tail|sed|awk|wc|sort|uniq|cut|echo|read|where|which|findstr|type)$/.test(normalized[0]);
153
+ });
154
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-plan",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension that adds a plan mode with workspace markdown plans and thinking-level presets.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -24,15 +24,25 @@
24
24
  ],
25
25
  "files": [
26
26
  "README.md",
27
- "index.ts"
27
+ "index.ts",
28
+ "lib/"
28
29
  ],
29
30
  "pi": {
30
31
  "extensions": [
31
32
  "./index.ts"
32
33
  ]
33
34
  },
35
+ "scripts": {
36
+ "test": "mocha"
37
+ },
34
38
  "peerDependencies": {
35
39
  "@earendil-works/pi-coding-agent": "*",
36
40
  "typebox": "*"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^20.19.43",
44
+ "chai": "^4.5.0",
45
+ "mocha": "^10.8.2",
46
+ "tsx": "^4.22.4"
37
47
  }
38
48
  }