@narumitw/pi-plan-mode 0.16.0 → 0.17.1
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 +59 -6
- package/package.json +1 -1
- package/src/plan-mode.ts +27 -38
- package/src/settings.ts +65 -7
- package/src/tool-policy.ts +291 -51
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,
|
|
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
|
-
## ⚙️
|
|
86
|
+
## ⚙️ Settings
|
|
87
87
|
|
|
88
|
-
|
|
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": "
|
|
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
|
-
|
|
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
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(
|
|
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 (
|
|
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)
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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") {
|
package/src/tool-policy.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
+
}
|