@bacnh85/pi-plan 0.1.4 → 0.1.6
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/README.md +17 -0
- package/index.ts +58 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,6 +58,23 @@ pi --plan
|
|
|
58
58
|
|
|
59
59
|
Agents should ask blocking, user-answerable planning questions with `ask_plan_question` before finalizing a plan. Final plans may still list non-blocking uncertainties or implementation-time checks, but should not leave consequential user decisions unresolved.
|
|
60
60
|
|
|
61
|
+
## Plan-mode bash allowlist
|
|
62
|
+
|
|
63
|
+
Plan mode allows only a narrow read-only subset of shell commands. Examples include:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git status --short
|
|
67
|
+
git diff
|
|
68
|
+
git show HEAD -- package.json
|
|
69
|
+
git log --oneline -5
|
|
70
|
+
git branch --show-current
|
|
71
|
+
git rev-parse --show-toplevel
|
|
72
|
+
npm view <package> --json
|
|
73
|
+
npm info <package> version
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`npm view` and `npm info` are allowed only for npm registry metadata lookup. They may contact the network and disclose the queried package name, but pi-plan does not allow npm install/update/publish/run/exec/auth/config style commands in plan mode.
|
|
77
|
+
|
|
61
78
|
## Reasoning levels
|
|
62
79
|
|
|
63
80
|
pi-plan remembers two reasoning levels:
|
package/index.ts
CHANGED
|
@@ -152,6 +152,62 @@ function isDestructiveBash(command: string): boolean {
|
|
|
152
152
|
return DESTRUCTIVE_BASH_PATTERNS.some((pattern) => pattern.test(command));
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
function tokenizeSimpleCommand(command: string): string[] | undefined {
|
|
156
|
+
const trimmed = command.trim();
|
|
157
|
+
if (!trimmed) return [];
|
|
158
|
+
if (/[;&|`$(){}<>]/.test(trimmed) || /\b(python|python3|node|ruby|perl|php|sh|bash|zsh|fish)\b/i.test(trimmed)) return undefined;
|
|
159
|
+
if (/['"]/.test(trimmed)) return undefined;
|
|
160
|
+
return trimmed.split(/\s+/).filter(Boolean);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function hasOptionValue(tokens: string[], index: number): boolean {
|
|
164
|
+
return index + 1 < tokens.length && !tokens[index + 1].startsWith("-");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isAllowedNpmMetadataCommand(tokens: string[]): boolean {
|
|
168
|
+
if (tokens[0] !== "npm" || !["view", "info"].includes(tokens[1])) return false;
|
|
169
|
+
let hasSpec = false;
|
|
170
|
+
for (let index = 2; index < tokens.length; index += 1) {
|
|
171
|
+
const token = tokens[index];
|
|
172
|
+
if (["--registry", "--tag"].includes(token)) {
|
|
173
|
+
if (!hasOptionValue(tokens, index)) return false;
|
|
174
|
+
index += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (token.startsWith("--registry=") || token.startsWith("--tag=") || token === "--json" || token === "--parseable" || token === "--silent") continue;
|
|
178
|
+
if (token.startsWith("-")) return false;
|
|
179
|
+
hasSpec = true;
|
|
180
|
+
}
|
|
181
|
+
return hasSpec;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isAllowedGitCommand(tokens: string[]): boolean {
|
|
185
|
+
if (tokens[0] !== "git" || !tokens[1]) return false;
|
|
186
|
+
const subcommand = tokens[1];
|
|
187
|
+
const args = tokens.slice(2);
|
|
188
|
+
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;
|
|
189
|
+
|
|
190
|
+
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));
|
|
191
|
+
if (subcommand === "diff") return !args.some((arg) => arg === "--output" || arg.startsWith("--output=") || arg === "--ext-diff" || arg === "--textconv");
|
|
192
|
+
if (["show", "log", "rev-parse", "ls-files"].includes(subcommand)) return true;
|
|
193
|
+
if (subcommand === "branch") {
|
|
194
|
+
const mutating = new Set(["-d", "-D", "-m", "-M", "-c", "-C", "--delete", "--move", "--copy", "--set-upstream-to", "--track", "--unset-upstream", "--edit-description"]);
|
|
195
|
+
return !args.some((arg) => mutating.has(arg) || arg.startsWith("--set-upstream-to=") || arg === "-u");
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function isReadOnlyBash(command: string): boolean {
|
|
201
|
+
const tokens = tokenizeSimpleCommand(command);
|
|
202
|
+
if (!tokens) return false;
|
|
203
|
+
if (tokens.length === 0) return true;
|
|
204
|
+
const normalized = tokens[0] === "rtk" ? tokens.slice(1) : tokens;
|
|
205
|
+
if (normalized.length === 0) return false;
|
|
206
|
+
if (isAllowedGitCommand(normalized)) return true;
|
|
207
|
+
if (isAllowedNpmMetadataCommand(normalized)) return true;
|
|
208
|
+
return /^(rg|grep|find|fd|ls|pwd|cat|head|tail|sed|awk|wc|sort|uniq|cut)$/.test(normalized[0]);
|
|
209
|
+
}
|
|
210
|
+
|
|
155
211
|
export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
156
212
|
let planModeEnabled = false;
|
|
157
213
|
let executionMode = false;
|
|
@@ -488,8 +544,8 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
488
544
|
return { block: true, reason: `pi-plan: ${event.toolName} is disabled in read-only plan mode. Use ${PLAN_TOOL} to write the plan file.` };
|
|
489
545
|
}
|
|
490
546
|
if (!isToolCallEventType("bash", event)) return;
|
|
491
|
-
if (isDestructiveBash(event.input.command)) {
|
|
492
|
-
return { block: true, reason: `pi-plan: bash command blocked in plan mode because
|
|
547
|
+
if (isDestructiveBash(event.input.command) || !isReadOnlyBash(event.input.command)) {
|
|
548
|
+
return { block: true, reason: `pi-plan: bash command blocked in plan mode because only simple read-only inspection commands are allowed.\nCommand: ${event.input.command}` };
|
|
493
549
|
}
|
|
494
550
|
});
|
|
495
551
|
|