@narumitw/pi-plan-mode 0.16.0 → 0.17.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/README.md CHANGED
@@ -58,7 +58,7 @@ When Plan mode is active, ask the agent to design the change. The agent may insp
58
58
 
59
59
  By default, Plan mode manages only Pi's built-in tools: `read`, limited `bash`, available read-only built-ins such as `grep`, `find`, and `ls`, plus the required `plan_mode_question` and `plan_mode_complete` tools. Built-in `edit` and `write` are blocked. `update_plan` is also blocked because it tracks execution progress rather than conversational planning. Extension and custom tools are disabled by default because Pi tools do not expose standardized mutability metadata; enable them from `/plan tools` only when you accept the risk for that session. For example, you can opt into `firecrawl_scrape`, `firecrawl_search`, or `lsp_diagnostics` if those extensions are loaded and you want to use them during planning.
60
60
 
61
- Limited `bash` uses a fail-closed policy. It accepts common inspection commands, read-only Git and npm queries, pipelines and command lists composed entirely of accepted commands, plus selected checks such as `npm test`, `npm run typecheck`, and `cargo test`. It rejects output/input redirects, command substitution, subshells, background jobs, mutating flags, dependency changes, editors, and unknown commands. Tests and builds may still write ignored caches or build artifacts and may execute project-defined hooks; enable or invoke them only when the repository is trusted. This is extension-level risk reduction, not an OS sandbox.
61
+ Limited `bash` uses a fail-closed policy. It accepts common inspection commands, read-only Git and npm queries, pipelines and command lists composed entirely of accepted commands, plus selected checks such as `npm test`, `npm run typecheck`, and `cargo test`. It rejects output/input redirects, shell expansion, substitutions, subshells, background jobs, mutating flags, dependency changes, editors, and unknown commands. Tests and builds may still write ignored caches or build artifacts and may execute project-defined hooks; enable or invoke them only when the repository is trusted. This is extension-level risk reduction, not an OS sandbox.
62
62
 
63
63
  `plan_mode_question` follows Codex's `request_user_input` pattern: the agent can ask 1-3 concise questions, each with meaningful options and a free-form Other path. If you cancel or no interactive UI is available, the agent should ask a concise plain-text question or proceed only with a clearly stated low-risk assumption instead of prematurely producing a final plan.
64
64
 
@@ -83,19 +83,72 @@ You can also exit directly. Direct exit discards the latest proposed plan instea
83
83
  /plan exit
84
84
  ```
85
85
 
86
- ## ⚙️ Thinking level
86
+ ## ⚙️ Settings
87
87
 
88
- Plan mode inherits Pi's current thinking level by default. To request a fixed level only while Plan mode is active, create `$PI_CODING_AGENT_DIR/pi-plan-mode.json` (normally `~/.pi/agent/pi-plan-mode.json`):
88
+ Create `$PI_CODING_AGENT_DIR/pi-plan-mode.json` (normally `~/.pi/agent/pi-plan-mode.json`) to configure Plan mode globally. The file is optional, is read at session start, and is never created automatically.
89
89
 
90
90
  ```json
91
91
  {
92
- "thinkingLevel": "medium"
92
+ "thinkingLevel": "inherit",
93
+ "defaultPlanTools": ["read", "bash", "grep", "find", "ls"],
94
+ "safeSubcommands": {
95
+ "git": ["status", "log", "rev-parse", "blame"],
96
+ "gh": ["pr view", "pr list", "issue view", "issue list"]
97
+ }
93
98
  }
94
99
  ```
95
100
 
96
- Supported values are `inherit`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. The extension snapshots the prior level and restores it on exit only if the level still matches the value it applied; a manual change made during Plan mode is preserved. The settings file is optional, is read at session start, and never changes Pi's default setting. Invalid settings produce a warning and fall back to `inherit`.
101
+ ### Default Plan tools
102
+
103
+ `defaultPlanTools` defines the initial tool selection when a session has no stored `/plan tools` selection. Omit it to keep the available safe built-ins as the default. An explicit empty array is valid and enables only the required `plan_mode_question` and `plan_mode_complete` tools.
104
+
105
+ Tool names must be non-empty strings; duplicates are removed in first-seen order. Unknown, unavailable, and Plan-mode-blocked names are ignored when tools are activated. A tool registered after Plan mode is already active is not added automatically; re-enter Plan mode or reopen `/plan tools` to reapply the selection. Non-built-in tools named in this global setting are an explicit user-risk opt-in, just like selecting them with `/plan tools`. Pi resolves tools by name, so if an extension overrides a built-in name, the effective extension tool is selected instead.
106
+
107
+ A selection made with `/plan tools` is stored in that Pi session and takes precedence over `defaultPlanTools` when the session resumes. The global setting remains the baseline for fresh sessions and sessions without an explicit selection.
108
+
109
+ ### Safe shell subcommands
110
+
111
+ `safeSubcommands` adds reviewed command validators to limited `bash`; it is not a raw shell allowlist. Only the following exact values are accepted:
112
+
113
+ - `git`: `status`, `log`, `diff`, `show`, `branch`, `remote`, `ls-files`, `grep`, `rev-parse`, `blame`, `describe`, `merge-base`, `ls-tree`, and `cat-file`.
114
+ - `gh`: `pr view`, `pr list`, `issue view`, and `issue list`.
115
+
116
+ The first eight Git validators are built in and remain enabled when omitted, so listing them is valid but redundant. The other six Git validators and every `gh` path require an explicit opt-in. Git entries select one exact subcommand; `gh` entries select one exact two-word path, so `"pr view"` never enables `pr merge`, `pr close`, or `pr edit`. Omitted `safeSubcommands`, an empty object, and empty arrays preserve the default policy. Duplicate values are removed in first-seen order.
117
+
118
+ With the example configuration above, commands such as these are accepted:
119
+
120
+ ```bash
121
+ git rev-parse --show-toplevel
122
+ git blame --no-textconv -- src/plan-mode.ts
123
+ gh pr view 218 --json number,title,state
124
+ gh issue list --state open --json number,title,state
125
+ ```
126
+
127
+ The command-specific validators still reject unsafe forms, including:
128
+
129
+ ```bash
130
+ git blame -- src/plan-mode.ts
131
+ git cat-file --filters HEAD
132
+ git diff
133
+ git log -Ssecret
134
+ git remote show origin
135
+ git show --ext-diff HEAD
136
+ gh pr merge 218
137
+ gh pr view 218
138
+ gh pr view 218 --web
139
+ gh pr view 218 > pr.txt
140
+ gh pr list --json number,title && gh pr merge 218
141
+ ```
142
+
143
+ Redirects, shell expansion and substitution, pagers or browsers, external diff/textconv/filter/signature helpers, output flags, malformed command layouts, and any chain containing an unsafe segment fail closed. Commands that can invoke configured Git helpers implicitly require explicit guards: use `--no-textconv` with `blame`, `show`, and patch-producing or pickaxe/searching `log`; use both `--no-ext-diff` and `--no-textconv` with content-producing `diff` (`git diff --check` remains accepted); and use `git remote show -n` to avoid invoking a transport helper. GitHub CLI read paths require `--json <fields>` output so Plan mode does not rely on `GH_PAGER`, `PAGER`, or gh pager configuration. Unknown `safeSubcommands` keys or values, non-array values, and non-string entries invalidate the entire settings file and trigger the normal warning/default fallback on session start.
144
+
145
+ Read-only does not mean private: Git inspection can expose repository history and tracked secrets, while `gh` queries can expose remote repository, pull request, and issue data available to your authenticated account. The policy reduces accidental mutation and helper execution; it is not a sandbox or a confidentiality boundary.
146
+
147
+ ### Thinking level
148
+
149
+ Plan mode inherits Pi's current thinking level by default. Set `thinkingLevel` to request a fixed level only while Plan mode is active. Supported values are `inherit`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. The extension snapshots the prior level and restores it on exit only if the level still matches the value it applied; a manual change made during Plan mode is preserved. The setting never changes Pi's default thinking level.
97
150
 
98
- Compatibility: a valid legacy `plan-mode.json` is migrated automatically to `pi-plan-mode.json`. If both files exist, the new filename takes precedence.
151
+ Invalid settings produce a warning and fall back to inherited thinking plus the available safe-built-in tool defaults. Compatibility: a valid legacy `plan-mode.json` is migrated automatically to `pi-plan-mode.json`. If both files exist, the new filename takes precedence.
99
152
 
100
153
  ## 🧠 Codex-like behavior
101
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-plan-mode",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Pi extension that adds a Codex-like read-only /plan collaboration mode.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/plan-mode.ts CHANGED
@@ -1,21 +1,16 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionContext,
4
- ToolInfo,
5
- } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@earendil-works/pi-coding-agent";
6
2
  import {
3
+ normalizePlanModeCompletion,
7
4
  PLAN_MODE_COMPLETE_PARAMS,
8
5
  PLAN_MODE_COMPLETE_TOOL_NAME,
9
- normalizePlanModeCompletion,
10
6
  planModeCompleted,
11
7
  } from "./completion-tool.js";
12
8
  import {
13
- extractProposedPlan,
14
9
  isEmptyAssistantMessage,
15
10
  latestAssistantText,
16
- parseProposedPlan,
17
11
  messageContainsInactivePlanModeArtifact,
18
12
  messageContainsLegacyPlanModeContextArtifact,
13
+ parseProposedPlan,
19
14
  stripPlanModeCompletionCallsFromMessage,
20
15
  stripProposedPlanBlocksFromMessage,
21
16
  } from "./message-transform.js";
@@ -28,6 +23,12 @@ import {
28
23
  planModeQuestionAnswered,
29
24
  planModeQuestionCancelled,
30
25
  } from "./question-tool.js";
26
+ import {
27
+ configuredThinkingLevel,
28
+ type PlanModeSettings,
29
+ readPlanModeSettings,
30
+ } from "./settings.js";
31
+ import { type PlanCompletionSource, type PlanModeState, restorePlanModeState } from "./state.js";
31
32
  import {
32
33
  canSelectToolInPlanMode,
33
34
  classifyPlanModeTool,
@@ -36,16 +37,6 @@ import {
36
37
  readCommand,
37
38
  SAFE_BUILTIN_PLAN_TOOLS,
38
39
  } from "./tool-policy.js";
39
- import {
40
- configuredThinkingLevel,
41
- readPlanModeSettings,
42
- type PlanModeSettings,
43
- } from "./settings.js";
44
- import {
45
- restorePlanModeState,
46
- type PlanCompletionSource,
47
- type PlanModeState,
48
- } from "./state.js";
49
40
 
50
41
  const STATE_ENTRY_TYPE = "plan-mode-state";
51
42
  const STATUS_KEY = "plan-mode";
@@ -67,11 +58,6 @@ interface ReadyPresentationIntent {
67
58
  source: PlanCompletionSource;
68
59
  }
69
60
 
70
- type TextBlock = {
71
- type?: string;
72
- text?: string;
73
- };
74
-
75
61
  const PLAN_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
76
62
  { value: "show", label: "show", description: "Show the completed plan" },
77
63
  { value: "finalize", label: "finalize", description: "Request a completed plan" },
@@ -273,7 +259,7 @@ export default function planMode(pi: ExtensionAPI) {
273
259
  if (event.toolName !== "bash" || !isBuiltinToolName(event.toolName)) return;
274
260
 
275
261
  const command = readCommand(event.input);
276
- if (!isSafeCommand(command)) {
262
+ if (!isSafeCommand(command, settings.safeSubcommands)) {
277
263
  return {
278
264
  block: true,
279
265
  reason: `Plan mode blocks mutating or non-allowlisted bash commands.\nCommand: ${command}`,
@@ -405,11 +391,7 @@ export default function planMode(pi: ExtensionAPI) {
405
391
  }
406
392
  }
407
393
 
408
- function acceptCompletedPlan(
409
- plan: string,
410
- source: PlanCompletionSource,
411
- ctx: ExtensionContext,
412
- ) {
394
+ function acceptCompletedPlan(plan: string, source: PlanCompletionSource, ctx: ExtensionContext) {
413
395
  if (
414
396
  state.enabled &&
415
397
  state.awaitingAction &&
@@ -449,7 +431,10 @@ export default function planMode(pi: ExtensionAPI) {
449
431
  function showStoredPlan(ctx: ExtensionContext) {
450
432
  const plan = state.latestPlan?.trim();
451
433
  if (!state.enabled || !plan) {
452
- ctx.ui.notify("No completed plan is available. Use /plan finalize when planning is complete.", "info");
434
+ ctx.ui.notify(
435
+ "No completed plan is available. Use /plan finalize when planning is complete.",
436
+ "info",
437
+ );
453
438
  return;
454
439
  }
455
440
  try {
@@ -514,12 +499,7 @@ export default function planMode(pi: ExtensionAPI) {
514
499
  "Stay in Plan mode",
515
500
  "Exit Plan mode",
516
501
  ]
517
- : [
518
- "Request final plan",
519
- "Configure Plan-mode tools",
520
- "Stay in Plan mode",
521
- "Exit Plan mode",
522
- ];
502
+ : ["Request final plan", "Configure Plan-mode tools", "Stay in Plan mode", "Exit Plan mode"];
523
503
  const choice = await ctx.ui.select(planStatusText(), choices);
524
504
  if (choice === "Show latest proposed plan") {
525
505
  showStoredPlan(ctx);
@@ -637,7 +617,12 @@ export default function planMode(pi: ExtensionAPI) {
637
617
 
638
618
  function planModeToolNames() {
639
619
  const tools = selectableTools();
640
- if (tools.length === 0) {
620
+ if (
621
+ tools.length === 0 &&
622
+ state.selectedToolNames === undefined &&
623
+ state.selectedToolKeys === undefined &&
624
+ settings.defaultPlanTools === undefined
625
+ ) {
641
626
  return ["read", "bash", PLAN_MODE_QUESTION_TOOL_NAME, PLAN_MODE_COMPLETE_TOOL_NAME];
642
627
  }
643
628
 
@@ -662,6 +647,9 @@ export default function planMode(pi: ExtensionAPI) {
662
647
  }
663
648
 
664
649
  function defaultPlanModeToolNames(tools: ToolInfo[]) {
650
+ if (settings.defaultPlanTools !== undefined) {
651
+ return filterAvailableSelectedNames(settings.defaultPlanTools, tools);
652
+ }
665
653
  return tools
666
654
  .filter((tool) => isBuiltinTool(tool) && SAFE_BUILTIN_PLAN_TOOLS.has(tool.name))
667
655
  .map((tool) => tool.name);
@@ -808,7 +796,8 @@ export default function planMode(pi: ExtensionAPI) {
808
796
 
809
797
  function planStatusText() {
810
798
  if (!state.enabled) return "Plan mode is off.";
811
- if (state.latestPlan) return `Plan mode is active and a proposed plan is ready. ${formatToolSummary()}`;
799
+ if (state.latestPlan)
800
+ return `Plan mode is active and a proposed plan is ready. ${formatToolSummary()}`;
812
801
  return `Plan mode is active. ${formatToolSummary()} Explore, ask, and finish with plan_mode_complete when decision-ready.`;
813
802
  }
814
803
 
package/src/settings.ts CHANGED
@@ -2,6 +2,13 @@ import { randomUUID } from "node:crypto";
2
2
  import { access, link, lstat, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ SAFE_GH_SUBCOMMAND_PATHS,
7
+ SAFE_GIT_SUBCOMMANDS,
8
+ type SafeGhSubcommandPath,
9
+ type SafeGitSubcommand,
10
+ type SafeSubcommands,
11
+ } from "./tool-policy.js";
5
12
 
6
13
  export const PLAN_MODE_SETTINGS_FILE = "pi-plan-mode.json";
7
14
  const LEGACY_PLAN_MODE_SETTINGS_FILE = "plan-mode.json";
@@ -20,6 +27,8 @@ export type PlanModeThinkingLevel = (typeof PLAN_MODE_THINKING_LEVELS)[number];
20
27
  export type PlanModeFixedThinkingLevel = Exclude<PlanModeThinkingLevel, "inherit">;
21
28
  export interface PlanModeSettings {
22
29
  thinkingLevel: PlanModeThinkingLevel;
30
+ defaultPlanTools?: string[];
31
+ safeSubcommands?: SafeSubcommands;
23
32
  }
24
33
  export type PlanModeSettingsLoadResult =
25
34
  | { kind: "missing"; notice?: string }
@@ -31,9 +40,61 @@ export function normalizePlanModeSettings(value: unknown): PlanModeSettings | un
31
40
  const thinkingLevel = Object.hasOwn(value, "thinkingLevel")
32
41
  ? Reflect.get(value, "thinkingLevel")
33
42
  : "inherit";
34
- return PLAN_MODE_THINKING_LEVELS.includes(thinkingLevel as PlanModeThinkingLevel)
35
- ? { thinkingLevel: thinkingLevel as PlanModeThinkingLevel }
36
- : undefined;
43
+ if (!PLAN_MODE_THINKING_LEVELS.includes(thinkingLevel as PlanModeThinkingLevel)) {
44
+ return undefined;
45
+ }
46
+ const settings: PlanModeSettings = {
47
+ thinkingLevel: thinkingLevel as PlanModeThinkingLevel,
48
+ };
49
+ if (Object.hasOwn(value, "defaultPlanTools")) {
50
+ const defaultPlanTools = normalizeToolNames(Reflect.get(value, "defaultPlanTools"));
51
+ if (!defaultPlanTools) return undefined;
52
+ settings.defaultPlanTools = defaultPlanTools;
53
+ }
54
+ if (Object.hasOwn(value, "safeSubcommands")) {
55
+ const safeSubcommands = normalizeSafeSubcommands(Reflect.get(value, "safeSubcommands"));
56
+ if (!safeSubcommands) return undefined;
57
+ settings.safeSubcommands = safeSubcommands;
58
+ }
59
+ return settings;
60
+ }
61
+
62
+ function normalizeToolNames(value: unknown) {
63
+ if (
64
+ !Array.isArray(value) ||
65
+ !value.every((item): item is string => typeof item === "string" && item.trim().length > 0)
66
+ ) {
67
+ return undefined;
68
+ }
69
+ return Array.from(new Set(value));
70
+ }
71
+
72
+ function normalizeSafeSubcommands(value: unknown): SafeSubcommands | undefined {
73
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
74
+ if (Object.keys(value).some((key) => key !== "git" && key !== "gh")) return undefined;
75
+
76
+ const safeSubcommands: SafeSubcommands = {};
77
+ if (Object.hasOwn(value, "git")) {
78
+ const git = normalizeKnownValues(Reflect.get(value, "git"), SAFE_GIT_SUBCOMMANDS);
79
+ if (!git) return undefined;
80
+ safeSubcommands.git = git as SafeGitSubcommand[];
81
+ }
82
+ if (Object.hasOwn(value, "gh")) {
83
+ const gh = normalizeKnownValues(Reflect.get(value, "gh"), SAFE_GH_SUBCOMMAND_PATHS);
84
+ if (!gh) return undefined;
85
+ safeSubcommands.gh = gh as SafeGhSubcommandPath[];
86
+ }
87
+ return safeSubcommands;
88
+ }
89
+
90
+ function normalizeKnownValues(value: unknown, supported: readonly string[]) {
91
+ if (
92
+ !Array.isArray(value) ||
93
+ !value.every((item): item is string => typeof item === "string" && supported.includes(item))
94
+ ) {
95
+ return undefined;
96
+ }
97
+ return Array.from(new Set(value));
37
98
  }
38
99
 
39
100
  export async function readPlanModeSettings(
@@ -59,10 +120,7 @@ export async function readPlanModeSettings(
59
120
  if (legacy.kind !== "loaded") return legacy;
60
121
  let installedIdentity: FileIdentity;
61
122
  try {
62
- installedIdentity = await installFileExclusively(
63
- canonicalPath,
64
- legacySnapshot.contents ?? "",
65
- );
123
+ installedIdentity = await installFileExclusively(canonicalPath, legacySnapshot.contents ?? "");
66
124
  } catch (error) {
67
125
  const created = await readSettingsFile(canonicalPath);
68
126
  if (created.kind !== "missing") {
@@ -1,5 +1,38 @@
1
1
  import type { ToolInfo } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ export const BUILTIN_SAFE_GIT_SUBCOMMANDS = [
4
+ "status",
5
+ "log",
6
+ "diff",
7
+ "show",
8
+ "branch",
9
+ "remote",
10
+ "ls-files",
11
+ "grep",
12
+ ] as const;
13
+ export const CONFIGURABLE_SAFE_GIT_SUBCOMMANDS = [
14
+ "rev-parse",
15
+ "blame",
16
+ "describe",
17
+ "merge-base",
18
+ "ls-tree",
19
+ "cat-file",
20
+ ] as const;
21
+ export const SAFE_GIT_SUBCOMMANDS = [
22
+ ...BUILTIN_SAFE_GIT_SUBCOMMANDS,
23
+ ...CONFIGURABLE_SAFE_GIT_SUBCOMMANDS,
24
+ ] as const;
25
+ export const SAFE_GH_SUBCOMMAND_PATHS = ["pr view", "pr list", "issue view", "issue list"] as const;
26
+
27
+ export type BuiltinSafeGitSubcommand = (typeof BUILTIN_SAFE_GIT_SUBCOMMANDS)[number];
28
+ export type ConfigurableSafeGitSubcommand = (typeof CONFIGURABLE_SAFE_GIT_SUBCOMMANDS)[number];
29
+ export type SafeGitSubcommand = (typeof SAFE_GIT_SUBCOMMANDS)[number];
30
+ export type SafeGhSubcommandPath = (typeof SAFE_GH_SUBCOMMAND_PATHS)[number];
31
+ export interface SafeSubcommands {
32
+ git?: SafeGitSubcommand[];
33
+ gh?: SafeGhSubcommandPath[];
34
+ }
35
+
3
36
  export const SAFE_BUILTIN_PLAN_TOOLS = new Set(["read", "bash", "grep", "find", "ls"]);
4
37
  export type PlanModeToolPolicy = "read-only" | "limited" | "user-opt-in" | "blocked";
5
38
 
@@ -88,9 +121,13 @@ export function readCommand(input: unknown) {
88
121
  return typeof command?.command === "string" ? command.command : "";
89
122
  }
90
123
 
91
- export function isSafeCommand(command: string) {
124
+ export function isSafeCommand(command: string, safeSubcommands: SafeSubcommands = {}) {
92
125
  const segments = splitShellSegments(command);
93
- return segments !== undefined && segments.length > 0 && segments.every(isSafeSegment);
126
+ return (
127
+ segments !== undefined &&
128
+ segments.length > 0 &&
129
+ segments.every((segment) => isSafeSegment(segment, safeSubcommands))
130
+ );
94
131
  }
95
132
 
96
133
  function splitShellSegments(command: string): string[] | undefined {
@@ -146,8 +183,10 @@ function splitShellSegments(command: string): string[] | undefined {
146
183
  return segments;
147
184
  }
148
185
 
149
- function isSafeSegment(segment: string) {
150
- if (/\$\(|\$\{|(^|\s)[A-Za-z_][A-Za-z0-9_]*=/.test(segment)) return false;
186
+ function isSafeSegment(segment: string, safeSubcommands: SafeSubcommands) {
187
+ if (hasShellExpansion(segment) || /(^|\s)[A-Za-z_][A-Za-z0-9_]*=/.test(segment)) {
188
+ return false;
189
+ }
151
190
  const tokens = shellWords(segment);
152
191
  if (!tokens || tokens.length === 0) return false;
153
192
  const command = tokens[0]?.toLowerCase();
@@ -155,7 +194,33 @@ function isSafeSegment(segment: string) {
155
194
  const args = tokens.slice(1);
156
195
  if (!hasSafeArguments(command, args)) return false;
157
196
  if (READ_ONLY_COMMANDS.has(command)) return true;
158
- return isSafeStructuredCommand(command, args);
197
+ return isSafeStructuredCommand(command, args, safeSubcommands);
198
+ }
199
+
200
+ function hasShellExpansion(segment: string) {
201
+ let quote: "'" | '"' | undefined;
202
+ let escaped = false;
203
+ for (const character of segment) {
204
+ if (escaped) {
205
+ escaped = false;
206
+ continue;
207
+ }
208
+ if (character === "\\" && quote !== "'") {
209
+ escaped = true;
210
+ continue;
211
+ }
212
+ if (quote) {
213
+ if (character === quote) quote = undefined;
214
+ else if (character === "$" && quote === '"') return true;
215
+ continue;
216
+ }
217
+ if (character === "'" || character === '"') {
218
+ quote = character;
219
+ continue;
220
+ }
221
+ if (["$", "*", "?", "[", "{"].includes(character)) return true;
222
+ }
223
+ return false;
159
224
  }
160
225
 
161
226
  function shellWords(segment: string): string[] | undefined {
@@ -212,7 +277,10 @@ function hasSafeArguments(command: string, args: string[]) {
212
277
  ) {
213
278
  return false;
214
279
  }
215
- if (command === "date" && args.some((argument) => argument === "-s" || argument.startsWith("--set"))) {
280
+ if (
281
+ command === "date" &&
282
+ args.some((argument) => argument === "-s" || argument.startsWith("--set"))
283
+ ) {
216
284
  return false;
217
285
  }
218
286
  if (
@@ -272,7 +340,41 @@ function hasSafeArguments(command: string, args: string[]) {
272
340
  return true;
273
341
  }
274
342
 
275
- function isSafeStructuredCommand(command: string, args: string[]) {
343
+ type ArgumentValidator = (args: string[]) => boolean;
344
+ const allowReadOnlyArguments: ArgumentValidator = () => true;
345
+ const BUILTIN_GIT_VALIDATORS: Record<BuiltinSafeGitSubcommand, ArgumentValidator> = {
346
+ status: allowReadOnlyArguments,
347
+ log: isSafeGitLogArguments,
348
+ diff: isSafeGitDiffArguments,
349
+ show: requiresNoTextconv,
350
+ branch: isSafeGitBranchArguments,
351
+ remote: isSafeGitRemoteArguments,
352
+ "ls-files": allowReadOnlyArguments,
353
+ grep: isSafeGitGrepArguments,
354
+ };
355
+ const CONFIGURABLE_GIT_VALIDATORS: Record<ConfigurableSafeGitSubcommand, ArgumentValidator> = {
356
+ "rev-parse": allowReadOnlyArguments,
357
+ blame: requiresNoTextconv,
358
+ describe: allowReadOnlyArguments,
359
+ "merge-base": allowReadOnlyArguments,
360
+ "ls-tree": allowReadOnlyArguments,
361
+ "cat-file": isSafeGitCatFileArguments,
362
+ };
363
+ const GH_VALIDATORS: Record<SafeGhSubcommandPath, ArgumentValidator> = {
364
+ "pr view": isSafeGhReadArguments,
365
+ "pr list": isSafeGhReadArguments,
366
+ "issue view": isSafeGhReadArguments,
367
+ "issue list": isSafeGhReadArguments,
368
+ };
369
+
370
+ function isSafeStructuredCommand(
371
+ command: string,
372
+ args: string[],
373
+ safeSubcommands: SafeSubcommands,
374
+ ) {
375
+ if (command === "git") return isSafeGitCommand(args, safeSubcommands);
376
+ if (command === "gh") return isSafeGhCommand(args, safeSubcommands);
377
+
276
378
  const subcommandIndex = args.findIndex((argument) => !argument.startsWith("-"));
277
379
  const subcommand = args[subcommandIndex]?.toLowerCase();
278
380
  const subcommandArgs = subcommandIndex >= 0 ? args.slice(subcommandIndex + 1) : [];
@@ -284,48 +386,6 @@ function isSafeStructuredCommand(command: string, args: string[]) {
284
386
  /^\d+(,\d+)?p$/.test(script ?? "")
285
387
  );
286
388
  }
287
- if (command === "git") {
288
- if (!subcommand || !["status", "log", "diff", "show", "branch", "remote", "ls-files", "grep"].includes(subcommand)) return false;
289
- if (subcommand === "branch" && subcommandArgs.some((argument) => !argument.startsWith("-"))) return false;
290
- if (
291
- subcommand === "branch" &&
292
- subcommandArgs.some(
293
- (argument) =>
294
- [
295
- "-d",
296
- "-D",
297
- "-m",
298
- "-M",
299
- "-c",
300
- "-C",
301
- "--delete",
302
- "--move",
303
- "--copy",
304
- "--edit-description",
305
- "--unset-upstream",
306
- ].includes(argument) || argument.startsWith("--set-upstream-to"),
307
- )
308
- )
309
- return false;
310
- if (subcommand === "remote") {
311
- const action = subcommandArgs.find((argument) => !argument.startsWith("-"));
312
- if (action && action !== "show" && action !== "get-url") return false;
313
- }
314
- if (
315
- args.some(
316
- (argument) =>
317
- argument === "--output" ||
318
- argument.startsWith("--output=") ||
319
- argument === "--ext-diff" ||
320
- argument === "--textconv" ||
321
- argument === "--open-files-in-pager" ||
322
- argument.startsWith("--open-files-in-pager=") ||
323
- (subcommand === "grep" && (argument === "-O" || argument.startsWith("-O"))),
324
- )
325
- )
326
- return false;
327
- return true;
328
- }
329
389
  if (["node", "python", "python3", "tsc", "biome", "ruff", "ty"].includes(command)) {
330
390
  if (args.includes("--version")) return true;
331
391
  return (
@@ -344,13 +404,193 @@ function isSafeStructuredCommand(command: string, args: string[]) {
344
404
  }
345
405
  if (command === "npm") {
346
406
  if (subcommand === "audit" && subcommandArgs.includes("fix")) return false;
347
- if (["list", "ls", "view", "info", "search", "outdated", "audit", "test"].includes(subcommand ?? "")) {
407
+ if (
408
+ ["list", "ls", "view", "info", "search", "outdated", "audit", "test"].includes(
409
+ subcommand ?? "",
410
+ )
411
+ ) {
348
412
  return true;
349
413
  }
350
414
  return subcommand === "run" && ["test", "check", "typecheck", "lint"].includes(args[1] ?? "");
351
415
  }
352
416
  if (["cargo", "go", "pytest", "vitest", "jest"].includes(command)) {
353
- return ["test", "check"].includes(subcommand ?? "") || ["pytest", "vitest", "jest"].includes(command);
417
+ return (
418
+ ["test", "check"].includes(subcommand ?? "") || ["pytest", "vitest", "jest"].includes(command)
419
+ );
354
420
  }
355
421
  return false;
356
422
  }
423
+
424
+ function isSafeGitCommand(args: string[], safeSubcommands: SafeSubcommands) {
425
+ let subcommandIndex = 0;
426
+ while (args[subcommandIndex] === "--no-pager") subcommandIndex += 1;
427
+ const subcommand = args[subcommandIndex]?.toLowerCase();
428
+ if (!subcommand || subcommand.startsWith("-")) return false;
429
+ const subcommandArgs = args.slice(subcommandIndex + 1);
430
+ const builtinValidator = (BUILTIN_GIT_VALIDATORS as Record<string, ArgumentValidator>)[
431
+ subcommand
432
+ ];
433
+ const configuredValidator = (CONFIGURABLE_GIT_VALIDATORS as Record<string, ArgumentValidator>)[
434
+ subcommand
435
+ ];
436
+ const configured = safeSubcommands.git?.includes(subcommand as SafeGitSubcommand) === true;
437
+ const validator = builtinValidator ?? (configured ? configuredValidator : undefined);
438
+ return (
439
+ validator !== undefined &&
440
+ hasSafeGitArguments(subcommand, subcommandArgs) &&
441
+ validator(subcommandArgs)
442
+ );
443
+ }
444
+
445
+ function hasSafeGitArguments(subcommand: string, args: string[]) {
446
+ return !args.some(
447
+ (argument) =>
448
+ argument === "--help" ||
449
+ argument === "--show-signature" ||
450
+ argument.startsWith("--show-signature=") ||
451
+ argument.includes("%G") ||
452
+ argument === "--output" ||
453
+ argument.startsWith("--output=") ||
454
+ argument === "--ext-diff" ||
455
+ argument.startsWith("--ext-diff=") ||
456
+ argument === "--textconv" ||
457
+ argument.startsWith("--textconv=") ||
458
+ argument === "--paginate" ||
459
+ argument === "--open-files-in-pager" ||
460
+ argument.startsWith("--open-files-in-pager=") ||
461
+ (subcommand === "grep" && (argument === "-O" || argument.startsWith("-O"))),
462
+ );
463
+ }
464
+
465
+ function isSafeGitCatFileArguments(args: string[]) {
466
+ return !args.some(
467
+ (argument) =>
468
+ matchesLongOptionPrefix(argument, "--filters", "--fi") ||
469
+ matchesLongOptionPrefix(argument, "--textconv", "--t"),
470
+ );
471
+ }
472
+
473
+ function isSafeGitGrepArguments(args: string[]) {
474
+ return !args.some(
475
+ (argument) =>
476
+ matchesLongOptionPrefix(argument, "--textconv", "--textc") ||
477
+ matchesLongOptionPrefix(argument, "--open-files-in-pager", "--op") ||
478
+ matchesLongOptionPrefix(argument, "--ext-grep", "--ext"),
479
+ );
480
+ }
481
+
482
+ function matchesLongOptionPrefix(argument: string, option: string, shortest: string) {
483
+ const optionName = argument.split("=", 1)[0] ?? "";
484
+ return optionName.length >= shortest.length && option.startsWith(optionName);
485
+ }
486
+
487
+ function isSafeGitDiffArguments(args: string[]) {
488
+ return (
489
+ args.includes("--check") || (args.includes("--no-ext-diff") && args.includes("--no-textconv"))
490
+ );
491
+ }
492
+
493
+ function isSafeGitLogArguments(args: string[]) {
494
+ if (args.includes("--no-textconv")) return true;
495
+ return !args.some(requiresTextconvGuardForGitLog);
496
+ }
497
+
498
+ function requiresTextconvGuardForGitLog(argument: string) {
499
+ return (
500
+ argument === "-p" ||
501
+ argument.startsWith("-p") ||
502
+ argument === "-u" ||
503
+ argument.startsWith("-U") ||
504
+ argument === "-c" ||
505
+ argument === "--patch" ||
506
+ argument.startsWith("--patch=") ||
507
+ argument.startsWith("--patch-with-") ||
508
+ argument === "--unified" ||
509
+ argument.startsWith("--unified=") ||
510
+ argument === "--binary" ||
511
+ argument === "--cc" ||
512
+ argument === "--remerge-diff" ||
513
+ argument.startsWith("-S") ||
514
+ argument.startsWith("-G") ||
515
+ argument === "--find-object" ||
516
+ argument.startsWith("--find-object=")
517
+ );
518
+ }
519
+
520
+ function requiresNoTextconv(args: string[]) {
521
+ return args.includes("--no-textconv");
522
+ }
523
+
524
+ function isSafeGitBranchArguments(args: string[]) {
525
+ if (args.some((argument) => !argument.startsWith("-"))) return false;
526
+ return !args.some(
527
+ (argument) =>
528
+ /^-[^-]*[dDmMcCu]/.test(argument) ||
529
+ matchesLongOptionPrefix(argument, "--delete", "--del") ||
530
+ matchesLongOptionPrefix(argument, "--move", "--mov") ||
531
+ matchesLongOptionPrefix(argument, "--copy", "--cop") ||
532
+ matchesLongOptionPrefix(argument, "--edit-description", "--e") ||
533
+ matchesLongOptionPrefix(argument, "--unset-upstream", "--u") ||
534
+ matchesLongOptionPrefix(argument, "--set-upstream-to", "--set-u") ||
535
+ matchesLongOptionPrefix(argument, "--create-reflog", "--creat"),
536
+ );
537
+ }
538
+
539
+ function isSafeGitRemoteArguments(args: string[]) {
540
+ const actionIndex = args.findIndex((argument) => !argument.startsWith("-"));
541
+ if (actionIndex < 0) return true;
542
+ const action = args[actionIndex];
543
+ if (action === "get-url") return true;
544
+ if (action !== "show") return false;
545
+
546
+ const showArgs = args.slice(actionIndex + 1);
547
+ if (showArgs.includes("--")) return false;
548
+ const remotes = showArgs.filter((argument) => !argument.startsWith("-"));
549
+ return remotes.length === 0 || (remotes.length === 1 && showArgs.includes("-n"));
550
+ }
551
+
552
+ function isSafeGhCommand(args: string[], safeSubcommands: SafeSubcommands) {
553
+ const group = args[0]?.toLowerCase();
554
+ const action = args[1]?.toLowerCase();
555
+ if (!group || !action || group.startsWith("-") || action.startsWith("-")) return false;
556
+ const path = `${group} ${action}` as SafeGhSubcommandPath;
557
+ if (!safeSubcommands.gh?.includes(path)) return false;
558
+ const validator = (GH_VALIDATORS as Record<string, ArgumentValidator>)[path];
559
+ return validator?.(args.slice(2)) ?? false;
560
+ }
561
+
562
+ function isSafeGhReadArguments(args: string[]) {
563
+ return !args.some(isUnsafeGhReadArgument) && hasGhJsonOutput(args);
564
+ }
565
+
566
+ function isUnsafeGhReadArgument(argument: string) {
567
+ return (
568
+ argument.startsWith("-w") ||
569
+ argument === "--web" ||
570
+ argument.startsWith("--web=") ||
571
+ argument === "--browser" ||
572
+ argument.startsWith("--browser=") ||
573
+ argument === "--paginate" ||
574
+ argument === "--pager" ||
575
+ argument.startsWith("--pager=") ||
576
+ argument === "--output" ||
577
+ argument.startsWith("--output=")
578
+ );
579
+ }
580
+
581
+ function hasGhJsonOutput(args: string[]) {
582
+ let hasJson = false;
583
+ for (let index = 0; index < args.length; index += 1) {
584
+ const argument = args[index];
585
+ if (argument === "--json") {
586
+ const value = args[index + 1];
587
+ if (!value || value.startsWith("-")) return false;
588
+ hasJson = true;
589
+ index += 1;
590
+ } else if (argument.startsWith("--json=")) {
591
+ if (argument === "--json=") return false;
592
+ hasJson = true;
593
+ }
594
+ }
595
+ return hasJson;
596
+ }