@bacnh85/pi-plan 0.1.5 → 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.
Files changed (3) hide show
  1. package/README.md +17 -0
  2. package/index.ts +53 -4
  3. 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,11 +152,60 @@ function isDestructiveBash(command: string): boolean {
152
152
  return DESTRUCTIVE_BASH_PATTERNS.some((pattern) => pattern.test(command));
153
153
  }
154
154
 
155
- function isReadOnlyBash(command: string): boolean {
155
+ function tokenizeSimpleCommand(command: string): string[] | undefined {
156
156
  const trimmed = command.trim();
157
- if (!trimmed) return true;
158
- if (/[;&|`$(){}]/.test(trimmed) || /\b(python|python3|node|ruby|perl|php|sh|bash|zsh|fish)\b/i.test(trimmed)) return false;
159
- return /^(git\s+(status|branch|rev-parse|diff|show|log|ls-files)\b|(?:rg|grep|find|fd|ls|pwd|cat|head|tail|sed|awk|wc|sort|uniq|cut)\b)/i.test(trimmed);
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]);
160
209
  }
161
210
 
162
211
  export default function piPlanExtension(pi: ExtensionAPI): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-plan",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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": {