@bacnh85/pi-plan 0.1.8 → 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.
- package/index.ts +24 -83
- package/lib/bash-gating.ts +154 -0
- 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
|
|
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,66 +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 hasOptionValue(tokens: string[], index: number): boolean {
|
|
185
|
-
return index + 1 < tokens.length && !tokens[index + 1].startsWith("-");
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function isAllowedNpmMetadataCommand(tokens: string[]): boolean {
|
|
189
|
-
if (tokens[0] !== "npm" || !["view", "info"].includes(tokens[1])) return false;
|
|
190
|
-
let hasSpec = false;
|
|
191
|
-
for (let index = 2; index < tokens.length; index += 1) {
|
|
192
|
-
const token = tokens[index];
|
|
193
|
-
if (["--registry", "--tag"].includes(token)) {
|
|
194
|
-
if (!hasOptionValue(tokens, index)) return false;
|
|
195
|
-
index += 1;
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
if (token.startsWith("--registry=") || token.startsWith("--tag=") || token === "--json" || token === "--parseable" || token === "--silent") continue;
|
|
199
|
-
if (token.startsWith("-")) return false;
|
|
200
|
-
hasSpec = true;
|
|
201
|
-
}
|
|
202
|
-
return hasSpec;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function isAllowedGitCommand(tokens: string[]): boolean {
|
|
206
|
-
if (tokens[0] !== "git" || !tokens[1]) return false;
|
|
207
|
-
const subcommand = tokens[1];
|
|
208
|
-
const args = tokens.slice(2);
|
|
209
|
-
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;
|
|
210
|
-
|
|
211
|
-
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));
|
|
212
|
-
if (subcommand === "diff") return !args.some((arg) => arg === "--output" || arg.startsWith("--output=") || arg === "--ext-diff" || arg === "--textconv");
|
|
213
|
-
if (["show", "log", "rev-parse", "ls-files"].includes(subcommand)) return true;
|
|
214
|
-
if (subcommand === "branch") {
|
|
215
|
-
const mutating = new Set(["-d", "-D", "-m", "-M", "-c", "-C", "--delete", "--move", "--copy", "--set-upstream-to", "--track", "--unset-upstream", "--edit-description"]);
|
|
216
|
-
return !args.some((arg) => mutating.has(arg) || arg.startsWith("--set-upstream-to=") || arg === "-u");
|
|
217
|
-
}
|
|
218
|
-
return false;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export function isReadOnlyBash(command: string): boolean {
|
|
222
|
-
const tokens = tokenizeSimpleCommand(command);
|
|
223
|
-
if (!tokens) return false;
|
|
224
|
-
if (tokens.length === 0) return true;
|
|
225
|
-
const normalized = tokens[0] === "rtk" ? tokens.slice(1) : tokens;
|
|
226
|
-
if (normalized.length === 0) return false;
|
|
227
|
-
if (isAllowedGitCommand(normalized)) return true;
|
|
228
|
-
if (isAllowedNpmMetadataCommand(normalized)) return true;
|
|
229
|
-
return /^(rg|grep|find|fd|ls|pwd|cat|head|tail|sed|awk|wc|sort|uniq|cut)$/.test(normalized[0]);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
173
|
export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
233
174
|
let planModeEnabled = false;
|
|
234
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.
|
|
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
|
}
|