@narumitw/pi-plan-mode 0.13.1 → 0.14.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 +22 -5
- package/package.json +2 -2
- package/src/message-transform.ts +26 -4
- package/src/plan-mode.ts +207 -34
- package/src/prompt.ts +4 -1
- package/src/settings.ts +68 -0
- package/src/tool-policy.ts +336 -47
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Pi core intentionally does not ship a built-in plan mode; this package provides
|
|
|
12
12
|
- Adds `--plan` to start a session in Plan mode.
|
|
13
13
|
- Enables built-in read-only tools by default while Plan mode is active.
|
|
14
14
|
- Disables extension and custom tools by default, with a `/plan tools` selector for explicit user-risk opt-in.
|
|
15
|
-
- Blocks mutating built-in tools and
|
|
15
|
+
- Blocks `update_plan`, mutating built-in tools, and unsafe shell forms such as writes, substitutions, background jobs, dependency installs, and mutating Git commands.
|
|
16
16
|
- Injects Codex-like Plan mode instructions: explore first, ask decision questions for high-impact ambiguity, do not mutate files, and finish with `<proposed_plan>` only when decision-complete.
|
|
17
17
|
- Adds a required `plan_mode_question` tool so the agent can ask structured Plan-mode questions before finalizing a plan.
|
|
18
18
|
- Detects proposed plan blocks and prompts you to implement, stay in Plan mode, or exit and discard the plan.
|
|
@@ -49,7 +49,9 @@ Use `/plan` to enter Plan mode before writing your planning prompt. Use `/plan <
|
|
|
49
49
|
|
|
50
50
|
When Plan mode is active, ask the agent to design the change. The agent may inspect files and run read-only commands, but it should not edit files or execute the implementation. It should explore first, then use structured questions when your preference or a tradeoff materially changes the plan.
|
|
51
51
|
|
|
52
|
-
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` tool. Built-in `edit` and `write` are blocked. 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 `
|
|
52
|
+
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` tool. 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.
|
|
53
|
+
|
|
54
|
+
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.
|
|
53
55
|
|
|
54
56
|
`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.
|
|
55
57
|
|
|
@@ -75,7 +77,9 @@ A complete Plan mode answer should appear only after the agent has resolved disc
|
|
|
75
77
|
</proposed_plan>
|
|
76
78
|
```
|
|
77
79
|
|
|
78
|
-
|
|
80
|
+
The tags must be on their own lines and the block must be non-empty, complete, and unique within the response. Empty, malformed, unclosed, or multiple blocks keep Plan mode active and produce a warning so the plan can be corrected.
|
|
81
|
+
|
|
82
|
+
After a valid proposed plan is detected, `/plan` lets you choose whether to implement the plan, stay in Plan mode, or exit Plan mode. Choosing implementation disables Plan mode, restores full tool access, and immediately starts an implementation turn with the proposed plan. Choosing Stay keeps the plan ready while you decide what to do next; to revise the plan, choose Stay and type your revision feedback in the normal prompt. When that next Plan-mode turn starts, the previous plan is no longer treated as the latest implementable plan unless the agent produces an updated `<proposed_plan>`. Choosing exit/off disables Plan mode and discards the proposed plan so it is not carried into later non-plan turns.
|
|
79
83
|
|
|
80
84
|
While Plan mode is enabled, the extension also publishes a compact status for Pi statuslines. With `@narumitw/pi-statusline`, this appears in the extension status area:
|
|
81
85
|
|
|
@@ -88,6 +92,18 @@ You can also exit directly. Direct exit discards the latest proposed plan instea
|
|
|
88
92
|
/plan exit
|
|
89
93
|
```
|
|
90
94
|
|
|
95
|
+
## ⚙️ Thinking level
|
|
96
|
+
|
|
97
|
+
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/plan-mode.json` (normally `~/.pi/agent/plan-mode.json`):
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"thinkingLevel": "medium"
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Supported values are `inherit`, `off`, `minimal`, `low`, `medium`, `high`, and `xhigh`. 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`.
|
|
106
|
+
|
|
91
107
|
## 🧠 Codex-like behavior
|
|
92
108
|
|
|
93
109
|
This extension maps Codex's `ModeKind::Plan` behavior onto Pi's extension API:
|
|
@@ -95,9 +111,10 @@ This extension maps Codex's `ModeKind::Plan` behavior onto Pi's extension API:
|
|
|
95
111
|
- Plan mode is a conversational collaboration mode, not TODO/progress tracking.
|
|
96
112
|
- `/plan <prompt>` follows Codex behavior by switching to Plan mode before submitting the inline prompt.
|
|
97
113
|
- The agent should use `plan_mode_question` for important non-discoverable preferences or tradeoffs before finalizing.
|
|
98
|
-
- `update_plan
|
|
114
|
+
- `update_plan` checklist use is blocked while Plan mode is active.
|
|
99
115
|
- The implementation boundary is explicit: Plan mode restores tools before starting implementation, choosing implementation immediately triggers a normal agent turn with full tool access, and plain exit/off discards the proposed plan.
|
|
100
|
-
- Pi extension safety is approximated with
|
|
116
|
+
- Pi extension safety is approximated with tool classification and fail-closed bash filtering; non-built-in tools remain user-selected at user risk because Pi does not expose standardized tool mutability metadata.
|
|
117
|
+
- Unlike native Codex, Pi detects complete plan blocks after the turn rather than streaming protocol-level Plan items, and it cannot provide sandbox-level enforcement.
|
|
101
118
|
|
|
102
119
|
## 🗂️ Package layout
|
|
103
120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-plan-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Pi extension that adds a Codex-like read-only /plan collaboration mode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"typecheck": "tsc --noEmit"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@biomejs/biome": "2.5.
|
|
31
|
+
"@biomejs/biome": "2.5.3",
|
|
32
32
|
"@earendil-works/pi-coding-agent": "0.80.3",
|
|
33
33
|
"typescript": "6.0.3"
|
|
34
34
|
},
|
package/src/message-transform.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context";
|
|
2
2
|
const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan";
|
|
3
|
-
const PROPOSED_PLAN_PATTERN =
|
|
4
|
-
const PROPOSED_PLAN_BLOCK_PATTERN =
|
|
3
|
+
const PROPOSED_PLAN_PATTERN = /^<proposed_plan>[\t ]*\r?\n([\s\S]*?)\r?\n<\/proposed_plan>[\t ]*$/gm;
|
|
4
|
+
const PROPOSED_PLAN_BLOCK_PATTERN = /^<proposed_plan>[\t ]*\r?\n[\s\S]*?\r?\n<\/proposed_plan>[\t ]*$/gm;
|
|
5
|
+
|
|
6
|
+
export type ProposedPlanParseResult =
|
|
7
|
+
| { kind: "absent" }
|
|
8
|
+
| { kind: "valid"; plan: string }
|
|
9
|
+
| { kind: "empty" }
|
|
10
|
+
| { kind: "multiple" }
|
|
11
|
+
| { kind: "malformed" }
|
|
12
|
+
| { kind: "unclosed" };
|
|
5
13
|
|
|
6
14
|
type SessionMessage = {
|
|
7
15
|
role?: string;
|
|
@@ -13,9 +21,23 @@ type TextBlock = {
|
|
|
13
21
|
text?: string;
|
|
14
22
|
};
|
|
15
23
|
|
|
24
|
+
export function parseProposedPlan(text: string): ProposedPlanParseResult {
|
|
25
|
+
const openingCount = text.match(/<proposed_plan>/gi)?.length ?? 0;
|
|
26
|
+
const closingCount = text.match(/<\/proposed_plan>/gi)?.length ?? 0;
|
|
27
|
+
if (openingCount === 0 && closingCount === 0) return { kind: "absent" };
|
|
28
|
+
if (openingCount > 1 || closingCount > 1) return { kind: "multiple" };
|
|
29
|
+
if (openingCount === 1 && closingCount === 0) return { kind: "unclosed" };
|
|
30
|
+
if (openingCount !== 1 || closingCount !== 1) return { kind: "malformed" };
|
|
31
|
+
|
|
32
|
+
const matches = Array.from(text.matchAll(PROPOSED_PLAN_PATTERN));
|
|
33
|
+
if (matches.length !== 1) return { kind: "malformed" };
|
|
34
|
+
const plan = matches[0]?.[1]?.trim() ?? "";
|
|
35
|
+
return plan ? { kind: "valid", plan } : { kind: "empty" };
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
export function extractProposedPlan(text: string) {
|
|
17
|
-
const
|
|
18
|
-
return
|
|
39
|
+
const result = parseProposedPlan(text);
|
|
40
|
+
return result.kind === "valid" ? result.plan : undefined;
|
|
19
41
|
}
|
|
20
42
|
|
|
21
43
|
export function latestAssistantText(messages: unknown) {
|
package/src/plan-mode.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
ToolInfo,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
6
|
import {
|
|
3
7
|
extractProposedPlan,
|
|
4
8
|
latestAssistantText,
|
|
9
|
+
parseProposedPlan,
|
|
5
10
|
messageContainsInactivePlanModeArtifact,
|
|
6
11
|
messageContainsLegacyPlanModeContextArtifact,
|
|
7
12
|
stripProposedPlanBlocksFromMessage,
|
|
@@ -17,11 +22,19 @@ import {
|
|
|
17
22
|
} from "./question-tool.js";
|
|
18
23
|
import {
|
|
19
24
|
canSelectToolInPlanMode,
|
|
25
|
+
classifyPlanModeTool,
|
|
20
26
|
isBuiltinTool,
|
|
21
27
|
isSafeCommand,
|
|
22
28
|
readCommand,
|
|
23
29
|
SAFE_BUILTIN_PLAN_TOOLS,
|
|
24
30
|
} from "./tool-policy.js";
|
|
31
|
+
import {
|
|
32
|
+
configuredThinkingLevel,
|
|
33
|
+
PLAN_MODE_THINKING_LEVELS,
|
|
34
|
+
readPlanModeSettings,
|
|
35
|
+
type PlanModeFixedThinkingLevel,
|
|
36
|
+
type PlanModeSettings,
|
|
37
|
+
} from "./settings.js";
|
|
25
38
|
|
|
26
39
|
const STATE_ENTRY_TYPE = "plan-mode-state";
|
|
27
40
|
const STATUS_KEY = "plan-mode";
|
|
@@ -43,6 +56,9 @@ interface PlanModeState {
|
|
|
43
56
|
awaitingAction: boolean;
|
|
44
57
|
selectedToolNames?: string[];
|
|
45
58
|
selectedToolKeys?: string[];
|
|
59
|
+
previousThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
60
|
+
appliedThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
61
|
+
manualThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
type SessionEntry = {
|
|
@@ -70,6 +86,7 @@ const PLAN_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
|
|
|
70
86
|
|
|
71
87
|
export default function planMode(pi: ExtensionAPI) {
|
|
72
88
|
let state: PlanModeState = { enabled: false, awaitingAction: false };
|
|
89
|
+
let settings: PlanModeSettings = { thinkingLevel: "inherit" };
|
|
73
90
|
let previousTools: string[] | undefined;
|
|
74
91
|
|
|
75
92
|
pi.registerFlag("plan", {
|
|
@@ -152,25 +169,65 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
152
169
|
},
|
|
153
170
|
});
|
|
154
171
|
|
|
155
|
-
pi.on("session_start", (_event, ctx) => {
|
|
172
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
173
|
+
settings = { thinkingLevel: "inherit" };
|
|
174
|
+
const loadedSettings = await readPlanModeSettings();
|
|
175
|
+
if (loadedSettings.kind === "loaded") settings = loadedSettings.settings;
|
|
176
|
+
else if (loadedSettings.kind === "invalid") {
|
|
177
|
+
ctx.ui.notify(`pi-plan-mode settings ignored: ${loadedSettings.reason}`, "warning");
|
|
178
|
+
}
|
|
156
179
|
restoreState(ctx);
|
|
157
180
|
if (pi.getFlag("plan") === true) state.enabled = true;
|
|
158
|
-
if (state.enabled)
|
|
159
|
-
|
|
181
|
+
if (state.enabled) {
|
|
182
|
+
activatePlanModeTools();
|
|
183
|
+
applyPlanThinkingLevel();
|
|
184
|
+
} else deactivatePlanModeQuestionTool();
|
|
160
185
|
updateUi(ctx);
|
|
161
186
|
});
|
|
162
187
|
|
|
188
|
+
pi.on("thinking_level_select", (event) => {
|
|
189
|
+
if (!state.enabled || !state.appliedThinkingLevel) return;
|
|
190
|
+
if (event.level !== state.appliedThinkingLevel) {
|
|
191
|
+
state = {
|
|
192
|
+
...state,
|
|
193
|
+
manualThinkingLevel: event.level,
|
|
194
|
+
previousThinkingLevel: undefined,
|
|
195
|
+
appliedThinkingLevel: undefined,
|
|
196
|
+
};
|
|
197
|
+
persistState();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
163
201
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
202
|
+
captureManualThinkingLevel();
|
|
164
203
|
persistState();
|
|
204
|
+
if (state.enabled) {
|
|
205
|
+
restoreTools();
|
|
206
|
+
restoreThinkingLevel();
|
|
207
|
+
}
|
|
165
208
|
clearUi(ctx);
|
|
166
209
|
});
|
|
167
210
|
|
|
168
211
|
pi.on("tool_call", async (event) => {
|
|
169
212
|
if (!state.enabled) return;
|
|
170
|
-
if (
|
|
213
|
+
if (event.toolName === "update_plan") {
|
|
214
|
+
return {
|
|
215
|
+
block: true,
|
|
216
|
+
reason:
|
|
217
|
+
"Plan mode blocks update_plan because it tracks execution progress rather than conversational planning.",
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const calledTool = toolByName(event.toolName);
|
|
221
|
+
if (calledTool && classifyPlanModeTool(calledTool) === "blocked") {
|
|
171
222
|
return {
|
|
172
223
|
block: true,
|
|
173
|
-
reason: `Plan mode blocks built-in
|
|
224
|
+
reason: `Plan mode blocks built-in tool '${event.toolName}' because its policy class is blocked.`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (!calledTool && BLOCKED_BUILTIN_TOOLS.has(event.toolName)) {
|
|
228
|
+
return {
|
|
229
|
+
block: true,
|
|
230
|
+
reason: `Plan mode blocks built-in tool '${event.toolName}' because its metadata is unavailable.`,
|
|
174
231
|
};
|
|
175
232
|
}
|
|
176
233
|
if (event.toolName !== "bash" || !isBuiltinToolName(event.toolName)) return;
|
|
@@ -213,12 +270,16 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
213
270
|
if (!state.enabled) return;
|
|
214
271
|
|
|
215
272
|
const text = latestAssistantText(event.messages);
|
|
216
|
-
const
|
|
217
|
-
if (
|
|
273
|
+
const parsedPlan = parseProposedPlan(text);
|
|
274
|
+
if (parsedPlan.kind !== "valid") {
|
|
275
|
+
if (parsedPlan.kind !== "absent") {
|
|
276
|
+
ctx.ui.notify(invalidPlanMessage(parsedPlan.kind), "warning");
|
|
277
|
+
}
|
|
218
278
|
persistState();
|
|
219
279
|
updateUi(ctx);
|
|
220
280
|
return;
|
|
221
281
|
}
|
|
282
|
+
const proposedPlan = parsedPlan.plan;
|
|
222
283
|
|
|
223
284
|
state = { ...state, latestPlan: proposedPlan, awaitingAction: true };
|
|
224
285
|
persistState();
|
|
@@ -244,6 +305,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
244
305
|
if (!state.enabled) previousTools = withoutPlanModeQuestionTool(safeGetActiveTools());
|
|
245
306
|
state = { ...state, enabled: true, awaitingAction: false };
|
|
246
307
|
activatePlanModeTools();
|
|
308
|
+
applyPlanThinkingLevel();
|
|
247
309
|
persistState();
|
|
248
310
|
updateUi(ctx);
|
|
249
311
|
}
|
|
@@ -254,20 +316,37 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
254
316
|
if (!wasEnabled) {
|
|
255
317
|
ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
|
|
256
318
|
}
|
|
257
|
-
sendPlanModeUserMessage(prompt, ctx);
|
|
319
|
+
if (!sendPlanModeUserMessage(prompt, ctx) && !wasEnabled) exitPlanMode(ctx);
|
|
258
320
|
}
|
|
259
321
|
|
|
260
322
|
function exitPlanMode(ctx: ExtensionContext) {
|
|
261
323
|
const wasEnabled = state.enabled;
|
|
262
|
-
state = {
|
|
263
|
-
|
|
324
|
+
state = {
|
|
325
|
+
...state,
|
|
326
|
+
enabled: false,
|
|
327
|
+
latestPlan: undefined,
|
|
328
|
+
awaitingAction: false,
|
|
329
|
+
manualThinkingLevel: undefined,
|
|
330
|
+
};
|
|
331
|
+
if (wasEnabled) {
|
|
332
|
+
restoreTools();
|
|
333
|
+
restoreThinkingLevel();
|
|
334
|
+
state = { ...state, manualThinkingLevel: undefined };
|
|
335
|
+
}
|
|
264
336
|
persistState();
|
|
265
337
|
updateUi(ctx);
|
|
266
338
|
}
|
|
267
339
|
|
|
268
340
|
function sendPlanModeUserMessage(message: string, ctx: ExtensionContext) {
|
|
269
|
-
|
|
270
|
-
|
|
341
|
+
try {
|
|
342
|
+
if (ctx.isIdle()) pi.sendUserMessage(message);
|
|
343
|
+
else pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
344
|
+
return true;
|
|
345
|
+
} catch (error: unknown) {
|
|
346
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
347
|
+
ctx.ui.notify(`Unable to send Plan-mode message: ${detail}`, "error");
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
271
350
|
}
|
|
272
351
|
|
|
273
352
|
function scheduleAfterCurrentAgentRun(task: () => Promise<void> | void) {
|
|
@@ -288,10 +367,16 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
288
367
|
return;
|
|
289
368
|
}
|
|
290
369
|
|
|
291
|
-
sendPlanModeUserMessage(
|
|
370
|
+
const sent = sendPlanModeUserMessage(
|
|
292
371
|
`Plan mode is now disabled. Full tool access is restored. Implement this proposed plan now:\n\n${plan}`,
|
|
293
372
|
ctx,
|
|
294
373
|
);
|
|
374
|
+
if (!sent) {
|
|
375
|
+
enterPlanMode(ctx);
|
|
376
|
+
state = { ...state, latestPlan: plan, awaitingAction: true };
|
|
377
|
+
persistState();
|
|
378
|
+
updateUi(ctx);
|
|
379
|
+
}
|
|
295
380
|
}
|
|
296
381
|
|
|
297
382
|
async function showPlanMenu(ctx: ExtensionContext) {
|
|
@@ -481,11 +566,58 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
481
566
|
}
|
|
482
567
|
|
|
483
568
|
function restoreTools() {
|
|
484
|
-
const restoredTools = previousTools
|
|
569
|
+
const restoredTools = previousTools ?? DEFAULT_TOOLS;
|
|
485
570
|
pi.setActiveTools(withoutPlanModeQuestionTool(restoredTools));
|
|
486
571
|
previousTools = undefined;
|
|
487
572
|
}
|
|
488
573
|
|
|
574
|
+
function applyPlanThinkingLevel() {
|
|
575
|
+
if (state.manualThinkingLevel) {
|
|
576
|
+
if (pi.getThinkingLevel() !== state.manualThinkingLevel) {
|
|
577
|
+
pi.setThinkingLevel(state.manualThinkingLevel);
|
|
578
|
+
}
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const configured = configuredThinkingLevel(settings);
|
|
582
|
+
if (!configured) {
|
|
583
|
+
state = {
|
|
584
|
+
...state,
|
|
585
|
+
previousThinkingLevel: undefined,
|
|
586
|
+
appliedThinkingLevel: undefined,
|
|
587
|
+
};
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const current = pi.getThinkingLevel();
|
|
591
|
+
if (!state.appliedThinkingLevel) state.previousThinkingLevel = current;
|
|
592
|
+
if (current !== configured) pi.setThinkingLevel(configured);
|
|
593
|
+
state.appliedThinkingLevel = pi.getThinkingLevel();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function captureManualThinkingLevel() {
|
|
597
|
+
if (!state.appliedThinkingLevel) return;
|
|
598
|
+
const current = pi.getThinkingLevel();
|
|
599
|
+
if (current === state.appliedThinkingLevel) return;
|
|
600
|
+
state = {
|
|
601
|
+
...state,
|
|
602
|
+
manualThinkingLevel: current,
|
|
603
|
+
previousThinkingLevel: undefined,
|
|
604
|
+
appliedThinkingLevel: undefined,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function restoreThinkingLevel() {
|
|
609
|
+
captureManualThinkingLevel();
|
|
610
|
+
const { appliedThinkingLevel, previousThinkingLevel } = state;
|
|
611
|
+
if (
|
|
612
|
+
appliedThinkingLevel &&
|
|
613
|
+
previousThinkingLevel &&
|
|
614
|
+
pi.getThinkingLevel() === appliedThinkingLevel
|
|
615
|
+
) {
|
|
616
|
+
pi.setThinkingLevel(previousThinkingLevel);
|
|
617
|
+
}
|
|
618
|
+
state = { ...state, appliedThinkingLevel: undefined, previousThinkingLevel: undefined };
|
|
619
|
+
}
|
|
620
|
+
|
|
489
621
|
function deactivatePlanModeQuestionTool() {
|
|
490
622
|
const activeTools = safeGetActiveTools();
|
|
491
623
|
const filteredTools = withoutPlanModeQuestionTool(activeTools);
|
|
@@ -507,18 +639,29 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
507
639
|
}
|
|
508
640
|
|
|
509
641
|
function restoreState(ctx: ExtensionContext) {
|
|
642
|
+
state = { enabled: false, awaitingAction: false };
|
|
510
643
|
const entries = ctx.sessionManager.getEntries() as SessionEntry[];
|
|
511
644
|
const entry = entries
|
|
512
645
|
.filter((candidate) => candidate.type === "custom" && candidate.customType === STATE_ENTRY_TYPE)
|
|
513
646
|
.pop();
|
|
514
|
-
if (!entry?.data) return;
|
|
515
|
-
const enabled = entry.data.enabled
|
|
647
|
+
if (!isRecord(entry?.data)) return;
|
|
648
|
+
const enabled = entry.data.enabled === true;
|
|
516
649
|
state = {
|
|
517
650
|
enabled,
|
|
518
|
-
latestPlan:
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
651
|
+
latestPlan:
|
|
652
|
+
enabled && typeof entry.data.latestPlan === "string" ? entry.data.latestPlan : undefined,
|
|
653
|
+
awaitingAction: enabled && entry.data.awaitingAction === true,
|
|
654
|
+
selectedToolNames: stringArray(entry.data.selectedToolNames),
|
|
655
|
+
selectedToolKeys: stringArray(entry.data.selectedToolKeys),
|
|
656
|
+
previousThinkingLevel: enabled
|
|
657
|
+
? fixedThinkingLevel(entry.data.previousThinkingLevel)
|
|
658
|
+
: undefined,
|
|
659
|
+
appliedThinkingLevel: enabled
|
|
660
|
+
? fixedThinkingLevel(entry.data.appliedThinkingLevel)
|
|
661
|
+
: undefined,
|
|
662
|
+
manualThinkingLevel: enabled
|
|
663
|
+
? fixedThinkingLevel(entry.data.manualThinkingLevel)
|
|
664
|
+
: undefined,
|
|
522
665
|
};
|
|
523
666
|
}
|
|
524
667
|
|
|
@@ -562,12 +705,6 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
562
705
|
return `Tools: ${names.length > 0 ? names.join(", ") : "none"}`;
|
|
563
706
|
}
|
|
564
707
|
|
|
565
|
-
function isBlockedBuiltinToolName(toolName: string) {
|
|
566
|
-
if (!BLOCKED_BUILTIN_TOOLS.has(toolName)) return false;
|
|
567
|
-
const tool = toolByName(toolName);
|
|
568
|
-
return tool ? isBuiltinTool(tool) : true;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
708
|
function isBuiltinToolName(toolName: string) {
|
|
572
709
|
const tool = toolByName(toolName);
|
|
573
710
|
return tool ? isBuiltinTool(tool) : toolName === "bash";
|
|
@@ -607,11 +744,11 @@ function formatToolChoice(tool: ToolInfo, selected: boolean, index: number) {
|
|
|
607
744
|
}
|
|
608
745
|
|
|
609
746
|
function toolPolicyLabel(tool: ToolInfo) {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
return `user
|
|
747
|
+
const policy = classifyPlanModeTool(tool);
|
|
748
|
+
if (policy === "read-only") return "built-in read-only";
|
|
749
|
+
if (policy === "limited") return "built-in limited";
|
|
750
|
+
if (policy === "blocked") return "built-in blocked";
|
|
751
|
+
return `user opt-in: ${toolSourceLabel(tool)}`;
|
|
615
752
|
}
|
|
616
753
|
|
|
617
754
|
function toolSourceLabel(tool: ToolInfo) {
|
|
@@ -624,6 +761,24 @@ function unique(values: string[]) {
|
|
|
624
761
|
return Array.from(new Set(values));
|
|
625
762
|
}
|
|
626
763
|
|
|
764
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
765
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function stringArray(value: unknown) {
|
|
769
|
+
return Array.isArray(value) && value.every((item): item is string => typeof item === "string")
|
|
770
|
+
? unique(value)
|
|
771
|
+
: undefined;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function fixedThinkingLevel(value: unknown): PlanModeFixedThinkingLevel | undefined {
|
|
775
|
+
return typeof value === "string" &&
|
|
776
|
+
value !== "inherit" &&
|
|
777
|
+
PLAN_MODE_THINKING_LEVELS.includes(value as (typeof PLAN_MODE_THINKING_LEVELS)[number])
|
|
778
|
+
? (value as PlanModeFixedThinkingLevel)
|
|
779
|
+
: undefined;
|
|
780
|
+
}
|
|
781
|
+
|
|
627
782
|
export function withRequiredPlanModeTools(toolNames: string[]) {
|
|
628
783
|
return unique([...withoutPlanModeQuestionTool(toolNames), PLAN_MODE_QUESTION_TOOL_NAME]);
|
|
629
784
|
}
|
|
@@ -632,6 +787,24 @@ export function withoutPlanModeQuestionTool(toolNames: string[]) {
|
|
|
632
787
|
return toolNames.filter((toolName) => toolName !== PLAN_MODE_QUESTION_TOOL_NAME);
|
|
633
788
|
}
|
|
634
789
|
|
|
635
|
-
|
|
790
|
+
function invalidPlanMessage(kind: "empty" | "multiple" | "malformed" | "unclosed") {
|
|
791
|
+
const detail = {
|
|
792
|
+
empty: "the block is empty",
|
|
793
|
+
multiple: "more than one plan block was produced",
|
|
794
|
+
malformed: "the tags must be on their own lines",
|
|
795
|
+
unclosed: "the closing tag is missing",
|
|
796
|
+
}[kind];
|
|
797
|
+
return `Proposed plan is not ready: ${detail}. Continue Plan mode and produce one complete non-empty <proposed_plan> block.`;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
export {
|
|
801
|
+
extractProposedPlan,
|
|
802
|
+
latestAssistantText,
|
|
803
|
+
parseProposedPlan,
|
|
804
|
+
stripProposedPlanBlocks,
|
|
805
|
+
stripProposedPlanBlocksFromMessage,
|
|
806
|
+
} from "./message-transform.js";
|
|
807
|
+
export { buildPlanModePrompt } from "./prompt.js";
|
|
636
808
|
export { normalizePlanModeQuestionParams } from "./question-tool.js";
|
|
637
|
-
export {
|
|
809
|
+
export { normalizePlanModeSettings, readPlanModeSettings } from "./settings.js";
|
|
810
|
+
export { canSelectToolInPlanMode, classifyPlanModeTool, isSafeCommand } from "./tool-policy.js";
|
package/src/prompt.ts
CHANGED
|
@@ -24,6 +24,7 @@ You are in Plan Mode, a Codex-like collaboration mode for producing a decision-c
|
|
|
24
24
|
|
|
25
25
|
- Keep asking until you can clearly state the goal, success criteria, in/out of scope, constraints, current state, and key preferences/tradeoffs.
|
|
26
26
|
- Bias toward questions over guessing: if a high-impact ambiguity remains, do not produce a proposed plan yet.
|
|
27
|
+
- For an unanswered preference or tradeoff, use the recommended option only when it is low risk and record that default as an explicit assumption in the final plan.
|
|
27
28
|
|
|
28
29
|
## Phase 3 — Implementation chat
|
|
29
30
|
|
|
@@ -51,5 +52,7 @@ Only output the final plan when it is decision-complete and leaves no decisions
|
|
|
51
52
|
...
|
|
52
53
|
</proposed_plan>
|
|
53
54
|
|
|
54
|
-
Keep the proposed plan concise, human and agent digestible, and free of open decisions. Do not ask "should I proceed?" in the final output; the Plan-mode ready menu handles implementation, staying in Plan mode, or exit
|
|
55
|
+
Keep the proposed plan concise, human and agent digestible, and free of open decisions. Prefer grouped behavior-level changes over file-by-file or symbol-by-symbol inventories. Do not ask "should I proceed?" in the final output; the Plan-mode ready menu handles implementation, staying in Plan mode, or exit.
|
|
56
|
+
|
|
57
|
+
Produce at most one <proposed_plan> block per turn. If the user requests revisions after a prior proposed plan, any new block must be a complete replacement. If there is not enough information for a complete replacement, continue planning without a block. If a follow-up only asks for clarification and does not change or challenge the plan, answer it and then reproduce the prior proposed plan unchanged.`;
|
|
55
58
|
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export const PLAN_MODE_SETTINGS_FILE = "plan-mode.json";
|
|
6
|
+
export const PLAN_MODE_THINKING_LEVELS = [
|
|
7
|
+
"inherit",
|
|
8
|
+
"off",
|
|
9
|
+
"minimal",
|
|
10
|
+
"low",
|
|
11
|
+
"medium",
|
|
12
|
+
"high",
|
|
13
|
+
"xhigh",
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export type PlanModeThinkingLevel = (typeof PLAN_MODE_THINKING_LEVELS)[number];
|
|
17
|
+
export type PlanModeFixedThinkingLevel = Exclude<PlanModeThinkingLevel, "inherit">;
|
|
18
|
+
export interface PlanModeSettings {
|
|
19
|
+
thinkingLevel: PlanModeThinkingLevel;
|
|
20
|
+
}
|
|
21
|
+
export type PlanModeSettingsLoadResult =
|
|
22
|
+
| { kind: "missing" }
|
|
23
|
+
| { kind: "invalid"; reason: string }
|
|
24
|
+
| { kind: "loaded"; settings: PlanModeSettings };
|
|
25
|
+
|
|
26
|
+
export function normalizePlanModeSettings(value: unknown): PlanModeSettings | undefined {
|
|
27
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
28
|
+
const thinkingLevel = Object.hasOwn(value, "thinkingLevel")
|
|
29
|
+
? Reflect.get(value, "thinkingLevel")
|
|
30
|
+
: "inherit";
|
|
31
|
+
return PLAN_MODE_THINKING_LEVELS.includes(thinkingLevel as PlanModeThinkingLevel)
|
|
32
|
+
? { thinkingLevel: thinkingLevel as PlanModeThinkingLevel }
|
|
33
|
+
: undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function readPlanModeSettings(
|
|
37
|
+
settingsPath = join(getAgentDir(), PLAN_MODE_SETTINGS_FILE),
|
|
38
|
+
): Promise<PlanModeSettingsLoadResult> {
|
|
39
|
+
let contents: string;
|
|
40
|
+
try {
|
|
41
|
+
contents = await readFile(settingsPath, "utf8");
|
|
42
|
+
} catch (error: unknown) {
|
|
43
|
+
if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
|
|
44
|
+
return { kind: "invalid", reason: formatError(error) };
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const settings = normalizePlanModeSettings(JSON.parse(contents) as unknown);
|
|
48
|
+
return settings
|
|
49
|
+
? { kind: "loaded", settings }
|
|
50
|
+
: { kind: "invalid", reason: "invalid settings shape" };
|
|
51
|
+
} catch (error: unknown) {
|
|
52
|
+
return { kind: "invalid", reason: formatError(error) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function configuredThinkingLevel(
|
|
57
|
+
settings: PlanModeSettings,
|
|
58
|
+
): PlanModeFixedThinkingLevel | undefined {
|
|
59
|
+
return settings.thinkingLevel === "inherit" ? undefined : settings.thinkingLevel;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
63
|
+
return error instanceof Error && "code" in error;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatError(error: unknown) {
|
|
67
|
+
return error instanceof Error ? error.message : String(error);
|
|
68
|
+
}
|
package/src/tool-policy.ts
CHANGED
|
@@ -1,57 +1,86 @@
|
|
|
1
1
|
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
export const SAFE_BUILTIN_PLAN_TOOLS = new Set(["read", "bash", "grep", "find", "ls"]);
|
|
4
|
+
export type PlanModeToolPolicy = "read-only" | "limited" | "user-opt-in" | "blocked";
|
|
4
5
|
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
6
|
+
const BLOCKED_BUILTIN_TOOLS = new Set(["edit", "write"]);
|
|
7
|
+
const MUTATING_COMMANDS = new Set([
|
|
8
|
+
"rm",
|
|
9
|
+
"rmdir",
|
|
10
|
+
"mv",
|
|
11
|
+
"cp",
|
|
12
|
+
"mkdir",
|
|
13
|
+
"touch",
|
|
14
|
+
"chmod",
|
|
15
|
+
"chown",
|
|
16
|
+
"chgrp",
|
|
17
|
+
"ln",
|
|
18
|
+
"tee",
|
|
19
|
+
"truncate",
|
|
20
|
+
"dd",
|
|
21
|
+
"sudo",
|
|
22
|
+
"su",
|
|
23
|
+
"kill",
|
|
24
|
+
"pkill",
|
|
25
|
+
"killall",
|
|
26
|
+
"reboot",
|
|
27
|
+
"shutdown",
|
|
28
|
+
"vim",
|
|
29
|
+
"vi",
|
|
30
|
+
"nano",
|
|
31
|
+
"emacs",
|
|
32
|
+
"code",
|
|
33
|
+
"subl",
|
|
34
|
+
]);
|
|
35
|
+
const READ_ONLY_COMMANDS = new Set([
|
|
36
|
+
"cat",
|
|
37
|
+
"head",
|
|
38
|
+
"tail",
|
|
39
|
+
"grep",
|
|
40
|
+
"find",
|
|
41
|
+
"ls",
|
|
42
|
+
"pwd",
|
|
43
|
+
"echo",
|
|
44
|
+
"printf",
|
|
45
|
+
"wc",
|
|
46
|
+
"sort",
|
|
47
|
+
"uniq",
|
|
48
|
+
"diff",
|
|
49
|
+
"file",
|
|
50
|
+
"stat",
|
|
51
|
+
"du",
|
|
52
|
+
"df",
|
|
53
|
+
"tree",
|
|
54
|
+
"which",
|
|
55
|
+
"whereis",
|
|
56
|
+
"type",
|
|
57
|
+
"printenv",
|
|
58
|
+
"uname",
|
|
59
|
+
"whoami",
|
|
60
|
+
"id",
|
|
61
|
+
"date",
|
|
62
|
+
"uptime",
|
|
63
|
+
"ps",
|
|
64
|
+
"jq",
|
|
65
|
+
"rg",
|
|
66
|
+
"fd",
|
|
67
|
+
"bat",
|
|
68
|
+
"eza",
|
|
69
|
+
]);
|
|
47
70
|
|
|
48
71
|
export function isBuiltinTool(tool: ToolInfo) {
|
|
49
72
|
return tool.sourceInfo.source === "builtin";
|
|
50
73
|
}
|
|
51
74
|
|
|
75
|
+
export function classifyPlanModeTool(tool: ToolInfo): PlanModeToolPolicy {
|
|
76
|
+
if (!isBuiltinTool(tool)) return "user-opt-in";
|
|
77
|
+
if (BLOCKED_BUILTIN_TOOLS.has(tool.name)) return "blocked";
|
|
78
|
+
if (tool.name === "bash") return "limited";
|
|
79
|
+
return SAFE_BUILTIN_PLAN_TOOLS.has(tool.name) ? "read-only" : "blocked";
|
|
80
|
+
}
|
|
81
|
+
|
|
52
82
|
export function canSelectToolInPlanMode(tool: ToolInfo) {
|
|
53
|
-
|
|
54
|
-
return true;
|
|
83
|
+
return classifyPlanModeTool(tool) !== "blocked";
|
|
55
84
|
}
|
|
56
85
|
|
|
57
86
|
export function readCommand(input: unknown) {
|
|
@@ -60,8 +89,268 @@ export function readCommand(input: unknown) {
|
|
|
60
89
|
}
|
|
61
90
|
|
|
62
91
|
export function isSafeCommand(command: string) {
|
|
92
|
+
const segments = splitShellSegments(command);
|
|
93
|
+
return segments !== undefined && segments.length > 0 && segments.every(isSafeSegment);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function splitShellSegments(command: string): string[] | undefined {
|
|
63
97
|
const trimmed = command.trim();
|
|
64
|
-
if (!trimmed) return
|
|
65
|
-
|
|
66
|
-
|
|
98
|
+
if (!trimmed || /[\n\r`]/.test(trimmed)) return undefined;
|
|
99
|
+
|
|
100
|
+
const segments: string[] = [];
|
|
101
|
+
let quote: "'" | '"' | undefined;
|
|
102
|
+
let escaped = false;
|
|
103
|
+
let start = 0;
|
|
104
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
105
|
+
const character = trimmed[index];
|
|
106
|
+
if (escaped) {
|
|
107
|
+
escaped = false;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (character === "\\" && quote !== "'") {
|
|
111
|
+
escaped = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (quote) {
|
|
115
|
+
if (character === quote) quote = undefined;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (character === "'" || character === '"') {
|
|
119
|
+
quote = character;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (character === ">" || character === "<" || character === "(" || character === ")") {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const next = trimmed[index + 1];
|
|
126
|
+
if (character === "&" && next !== "&") return undefined;
|
|
127
|
+
const separatorLength =
|
|
128
|
+
character === ";" || character === "|"
|
|
129
|
+
? next === character
|
|
130
|
+
? 2
|
|
131
|
+
: 1
|
|
132
|
+
: character === "&" && next === "&"
|
|
133
|
+
? 2
|
|
134
|
+
: 0;
|
|
135
|
+
if (separatorLength === 0) continue;
|
|
136
|
+
const segment = trimmed.slice(start, index).trim();
|
|
137
|
+
if (!segment) return undefined;
|
|
138
|
+
segments.push(segment);
|
|
139
|
+
index += separatorLength - 1;
|
|
140
|
+
start = index + 1;
|
|
141
|
+
}
|
|
142
|
+
if (quote || escaped) return undefined;
|
|
143
|
+
const finalSegment = trimmed.slice(start).trim();
|
|
144
|
+
if (!finalSegment) return undefined;
|
|
145
|
+
segments.push(finalSegment);
|
|
146
|
+
return segments;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isSafeSegment(segment: string) {
|
|
150
|
+
if (/\$\(|\$\{|(^|\s)[A-Za-z_][A-Za-z0-9_]*=/.test(segment)) return false;
|
|
151
|
+
const tokens = shellWords(segment);
|
|
152
|
+
if (!tokens || tokens.length === 0) return false;
|
|
153
|
+
const command = tokens[0]?.toLowerCase();
|
|
154
|
+
if (!command || MUTATING_COMMANDS.has(command)) return false;
|
|
155
|
+
const args = tokens.slice(1);
|
|
156
|
+
if (!hasSafeArguments(command, args)) return false;
|
|
157
|
+
if (READ_ONLY_COMMANDS.has(command)) return true;
|
|
158
|
+
return isSafeStructuredCommand(command, args);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function shellWords(segment: string): string[] | undefined {
|
|
162
|
+
const words: string[] = [];
|
|
163
|
+
let word = "";
|
|
164
|
+
let quote: "'" | '"' | undefined;
|
|
165
|
+
let escaped = false;
|
|
166
|
+
for (const character of segment) {
|
|
167
|
+
if (escaped) {
|
|
168
|
+
word += character;
|
|
169
|
+
escaped = false;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (character === "\\" && quote !== "'") {
|
|
173
|
+
escaped = true;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (quote) {
|
|
177
|
+
if (character === quote) quote = undefined;
|
|
178
|
+
else word += character;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (character === "'" || character === '"') quote = character;
|
|
182
|
+
else if (/\s/.test(character)) {
|
|
183
|
+
if (word) words.push(word);
|
|
184
|
+
word = "";
|
|
185
|
+
} else word += character;
|
|
186
|
+
}
|
|
187
|
+
if (quote || escaped) return undefined;
|
|
188
|
+
if (word) words.push(word);
|
|
189
|
+
return words;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function hasSafeArguments(command: string, args: string[]) {
|
|
193
|
+
const forbidden = new Set(["-i", "--in-place", "--fix", "--write", "-delete", "--delete"]);
|
|
194
|
+
if (args.some((argument) => forbidden.has(argument))) return false;
|
|
195
|
+
if (
|
|
196
|
+
command === "sed" &&
|
|
197
|
+
args.some(
|
|
198
|
+
(argument) =>
|
|
199
|
+
argument.startsWith("--in-place=") ||
|
|
200
|
+
(/^-[^-]+/.test(argument) && argument.slice(1).includes("i")),
|
|
201
|
+
)
|
|
202
|
+
) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
if (
|
|
206
|
+
command === "find" &&
|
|
207
|
+
args.some((argument) =>
|
|
208
|
+
["-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"].includes(
|
|
209
|
+
argument,
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if (command === "date" && args.some((argument) => argument === "-s" || argument.startsWith("--set"))) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
if (
|
|
219
|
+
(command === "sort" || command === "tree") &&
|
|
220
|
+
args.some(
|
|
221
|
+
(argument) =>
|
|
222
|
+
argument === "-o" ||
|
|
223
|
+
(argument.startsWith("-o") && !argument.startsWith("--")) ||
|
|
224
|
+
argument.startsWith("--output"),
|
|
225
|
+
)
|
|
226
|
+
) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if (
|
|
230
|
+
command === "sort" &&
|
|
231
|
+
args.some(
|
|
232
|
+
(argument) =>
|
|
233
|
+
argument === "-T" ||
|
|
234
|
+
(argument.startsWith("-T") && argument.length > 2) ||
|
|
235
|
+
argument.startsWith("--temporary-directory") ||
|
|
236
|
+
argument.startsWith("--compress-program"),
|
|
237
|
+
)
|
|
238
|
+
) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
if (
|
|
242
|
+
command === "diff" &&
|
|
243
|
+
args.some((argument) => argument === "--output" || argument.startsWith("--output="))
|
|
244
|
+
) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
if (command === "uniq" && args.filter((argument) => !argument.startsWith("-")).length > 1) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
if (
|
|
251
|
+
command === "fd" &&
|
|
252
|
+
args.some((argument) =>
|
|
253
|
+
["-x", "-X", "--exec", "--exec-batch"].some(
|
|
254
|
+
(flag) => argument === flag || argument.startsWith(`${flag}=`),
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
) {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
if (
|
|
261
|
+
command === "rg" &&
|
|
262
|
+
args.some((argument) => argument === "--pre" || argument.startsWith("--pre="))
|
|
263
|
+
) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
if (
|
|
267
|
+
command === "bat" &&
|
|
268
|
+
args.some((argument) => argument === "--pager" || argument.startsWith("--pager="))
|
|
269
|
+
) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isSafeStructuredCommand(command: string, args: string[]) {
|
|
276
|
+
const subcommandIndex = args.findIndex((argument) => !argument.startsWith("-"));
|
|
277
|
+
const subcommand = args[subcommandIndex]?.toLowerCase();
|
|
278
|
+
const subcommandArgs = subcommandIndex >= 0 ? args.slice(subcommandIndex + 1) : [];
|
|
279
|
+
if (command === "sed") {
|
|
280
|
+
const script = args.find((argument) => !argument.startsWith("-"));
|
|
281
|
+
return (
|
|
282
|
+
Boolean(script) &&
|
|
283
|
+
(args.includes("-n") || args.some((argument) => /^-[^-]*n[^-]*$/.test(argument))) &&
|
|
284
|
+
/^\d+(,\d+)?p$/.test(script ?? "")
|
|
285
|
+
);
|
|
286
|
+
}
|
|
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
|
+
if (["node", "python", "python3", "tsc", "biome", "ruff", "ty"].includes(command)) {
|
|
330
|
+
if (args.includes("--version")) return true;
|
|
331
|
+
return (
|
|
332
|
+
command === "tsc" &&
|
|
333
|
+
args.includes("--noEmit") &&
|
|
334
|
+
!args.some(
|
|
335
|
+
(argument) =>
|
|
336
|
+
argument === "--incremental" ||
|
|
337
|
+
argument.startsWith("--incremental=") ||
|
|
338
|
+
argument === "--tsBuildInfoFile" ||
|
|
339
|
+
argument.startsWith("--tsBuildInfoFile=") ||
|
|
340
|
+
argument === "--generateTrace" ||
|
|
341
|
+
argument.startsWith("--generateTrace="),
|
|
342
|
+
)
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (command === "npm") {
|
|
346
|
+
if (subcommand === "audit" && subcommandArgs.includes("fix")) return false;
|
|
347
|
+
if (["list", "ls", "view", "info", "search", "outdated", "audit", "test"].includes(subcommand ?? "")) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
return subcommand === "run" && ["test", "check", "typecheck", "lint"].includes(args[1] ?? "");
|
|
351
|
+
}
|
|
352
|
+
if (["cargo", "go", "pytest", "vitest", "jest"].includes(command)) {
|
|
353
|
+
return ["test", "check"].includes(subcommand ?? "") || ["pytest", "vitest", "jest"].includes(command);
|
|
354
|
+
}
|
|
355
|
+
return false;
|
|
67
356
|
}
|