@narumitw/pi-plan-mode 0.13.0 → 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 +25 -7
- package/package.json +2 -2
- package/src/message-transform.ts +120 -0
- package/src/plan-mode.ts +228 -472
- package/src/prompt.ts +58 -0
- package/src/question-tool.ts +207 -0
- package/src/settings.ts +68 -0
- package/src/tool-policy.ts +356 -0
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const PLAN_CONTEXT_MARKER = "[CODEX-LIKE PLAN MODE ACTIVE]";
|
|
2
|
+
|
|
3
|
+
export function buildPlanModePrompt() {
|
|
4
|
+
return `${PLAN_CONTEXT_MARKER}
|
|
5
|
+
# Plan Mode (Conversational)
|
|
6
|
+
|
|
7
|
+
You are in Plan Mode, a Codex-like collaboration mode for producing a decision-complete implementation plan. Chat your way to the plan before finalizing it. A final plan must leave no implementation decisions unresolved.
|
|
8
|
+
|
|
9
|
+
## Mode rules
|
|
10
|
+
|
|
11
|
+
- Stay in Plan Mode until a developer or extension explicitly exits it.
|
|
12
|
+
- Treat requests to implement as requests to plan the implementation; do not edit files or carry out the plan.
|
|
13
|
+
- Do not use update_plan/TODO tooling in Plan Mode; Plan Mode is conversational planning, not execution progress tracking.
|
|
14
|
+
- Plan Mode manages built-in tool safety only. Non-built-in tools are disabled by default and may be enabled by the user at their own risk.
|
|
15
|
+
- Do not perform mutating actions: no edit/write tools, no patching, no formatting that rewrites files, no dependency installation, no commits, no migrations.
|
|
16
|
+
|
|
17
|
+
## Phase 1 — Ground in the environment
|
|
18
|
+
|
|
19
|
+
- Explore first and ask second. Use non-mutating exploration to read files, search, inspect configuration, run read-only checks, and resolve discoverable facts.
|
|
20
|
+
- Before asking the user any question, perform at least one targeted non-mutating exploration pass unless no local environment or repository is available.
|
|
21
|
+
- Do not ask questions that can be answered from repository or system truth. Ask only when multiple plausible choices remain, a needed identifier/context is missing, or the ambiguity is product intent.
|
|
22
|
+
|
|
23
|
+
## Phase 2 — Intent chat
|
|
24
|
+
|
|
25
|
+
- Keep asking until you can clearly state the goal, success criteria, in/out of scope, constraints, current state, and key preferences/tradeoffs.
|
|
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.
|
|
28
|
+
|
|
29
|
+
## Phase 3 — Implementation chat
|
|
30
|
+
|
|
31
|
+
- Once intent is stable, keep asking until the spec is decision-complete: approach, interfaces, data flow, edge cases/failure modes, testing and acceptance criteria, and any migration or compatibility constraints.
|
|
32
|
+
- Use plan_mode_question for important preferences, tradeoffs, or assumption locks that cannot be discovered by non-mutating exploration. Ask 1-3 concise questions with 2-4 meaningful options. Do not include filler options.
|
|
33
|
+
- If plan_mode_question returns cancelled or ui_unavailable, do not jump straight to a final plan when the missing answer is high impact. Ask one concise plain-text question or proceed only with a clearly stated low-risk assumption.
|
|
34
|
+
|
|
35
|
+
## Finalization rule
|
|
36
|
+
|
|
37
|
+
Only output the final plan when it is decision-complete and leaves no decisions to the implementer. When presenting the official plan, output exactly one proposed plan block and keep the tags exactly as shown:
|
|
38
|
+
|
|
39
|
+
<proposed_plan>
|
|
40
|
+
# Title
|
|
41
|
+
|
|
42
|
+
## Summary
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
## Key Changes
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
## Test Plan
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
## Assumptions
|
|
52
|
+
...
|
|
53
|
+
</proposed_plan>
|
|
54
|
+
|
|
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.`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const PLAN_MODE_QUESTION_TOOL_NAME = "plan_mode_question";
|
|
4
|
+
|
|
5
|
+
export type PlanModeQuestionOption = {
|
|
6
|
+
label: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type PlanModeQuestion = {
|
|
11
|
+
id: string;
|
|
12
|
+
header: string;
|
|
13
|
+
question: string;
|
|
14
|
+
options: PlanModeQuestionOption[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type PlanModeQuestionAnswer = {
|
|
18
|
+
id: string;
|
|
19
|
+
header: string;
|
|
20
|
+
question: string;
|
|
21
|
+
answer: string;
|
|
22
|
+
wasCustom: boolean;
|
|
23
|
+
optionIndex?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type PlanModeQuestionReason = "cancelled" | "ui_unavailable" | "plan_mode_inactive" | "invalid_input";
|
|
27
|
+
|
|
28
|
+
type PlanModeQuestionDetails = {
|
|
29
|
+
cancelled: boolean;
|
|
30
|
+
reason?: PlanModeQuestionReason;
|
|
31
|
+
questions: PlanModeQuestion[];
|
|
32
|
+
answers?: PlanModeQuestionAnswer[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const PLAN_MODE_QUESTION_PARAMS = {
|
|
36
|
+
type: "object",
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
required: ["questions"],
|
|
39
|
+
properties: {
|
|
40
|
+
questions: {
|
|
41
|
+
type: "array",
|
|
42
|
+
minItems: 1,
|
|
43
|
+
maxItems: 3,
|
|
44
|
+
description: "Questions to show the user. Prefer 1 and do not exceed 3.",
|
|
45
|
+
items: {
|
|
46
|
+
type: "object",
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
required: ["id", "header", "question", "options"],
|
|
49
|
+
properties: {
|
|
50
|
+
id: { type: "string", description: "Stable identifier for mapping answers (snake_case)." },
|
|
51
|
+
header: { type: "string", description: "Short header label shown in the UI (12 or fewer chars)." },
|
|
52
|
+
question: { type: "string", description: "Single-sentence prompt shown to the user." },
|
|
53
|
+
options: {
|
|
54
|
+
type: "array",
|
|
55
|
+
minItems: 2,
|
|
56
|
+
maxItems: 4,
|
|
57
|
+
description:
|
|
58
|
+
"Provide 2-4 mutually exclusive choices. Put the recommended option first when there is a clear default.",
|
|
59
|
+
items: {
|
|
60
|
+
type: "object",
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
required: ["label", "description"],
|
|
63
|
+
properties: {
|
|
64
|
+
label: { type: "string", description: "User-facing label (1-5 words)." },
|
|
65
|
+
description: {
|
|
66
|
+
type: "string",
|
|
67
|
+
description: "One short sentence explaining impact/tradeoff if selected.",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
type NormalizePlanModeQuestionParamsResult =
|
|
79
|
+
| { ok: true; questions: PlanModeQuestion[] }
|
|
80
|
+
| { ok: false; error: string };
|
|
81
|
+
|
|
82
|
+
export function normalizePlanModeQuestionParams(input: unknown): NormalizePlanModeQuestionParamsResult {
|
|
83
|
+
if (!isRecord(input) || !Array.isArray(input.questions)) {
|
|
84
|
+
return { ok: false, error: "questions must be an array" };
|
|
85
|
+
}
|
|
86
|
+
if (input.questions.length < 1 || input.questions.length > 3) {
|
|
87
|
+
return { ok: false, error: "questions must contain 1-3 items" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const questions: PlanModeQuestion[] = [];
|
|
91
|
+
for (const [questionIndex, rawQuestion] of input.questions.entries()) {
|
|
92
|
+
if (!isRecord(rawQuestion)) {
|
|
93
|
+
return { ok: false, error: `question ${questionIndex + 1} must be an object` };
|
|
94
|
+
}
|
|
95
|
+
const id = stringField(rawQuestion.id);
|
|
96
|
+
const header = stringField(rawQuestion.header);
|
|
97
|
+
const question = stringField(rawQuestion.question);
|
|
98
|
+
if (!id || !header || !question) {
|
|
99
|
+
return { ok: false, error: `question ${questionIndex + 1} requires non-empty id, header, and question` };
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(rawQuestion.options)) {
|
|
102
|
+
return { ok: false, error: `question ${questionIndex + 1} options must be an array` };
|
|
103
|
+
}
|
|
104
|
+
if (rawQuestion.options.length < 2 || rawQuestion.options.length > 4) {
|
|
105
|
+
return { ok: false, error: `question ${questionIndex + 1} options must contain 2-4 items` };
|
|
106
|
+
}
|
|
107
|
+
const options: PlanModeQuestionOption[] = [];
|
|
108
|
+
for (const [optionIndex, rawOption] of rawQuestion.options.entries()) {
|
|
109
|
+
if (!isRecord(rawOption)) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
error: `question ${questionIndex + 1} option ${optionIndex + 1} must be an object`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const label = stringField(rawOption.label);
|
|
116
|
+
if (!label) {
|
|
117
|
+
return { ok: false, error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a label` };
|
|
118
|
+
}
|
|
119
|
+
const description = stringField(rawOption.description);
|
|
120
|
+
if (!description) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a description`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
options.push({ label, description });
|
|
127
|
+
}
|
|
128
|
+
questions.push({ id, header, question, options });
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, questions };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function askPlanModeQuestions(
|
|
134
|
+
questions: PlanModeQuestion[],
|
|
135
|
+
ctx: ExtensionContext,
|
|
136
|
+
): Promise<PlanModeQuestionAnswer[] | undefined> {
|
|
137
|
+
const answers: PlanModeQuestionAnswer[] = [];
|
|
138
|
+
for (const question of questions) {
|
|
139
|
+
const choices = question.options.map(formatPlanModeQuestionChoice);
|
|
140
|
+
const otherChoice = `${question.options.length + 1}. Other (free-form)`;
|
|
141
|
+
const choice = await ctx.ui.select(`${question.header}: ${question.question}`, [...choices, otherChoice]);
|
|
142
|
+
if (!choice) return undefined;
|
|
143
|
+
if (choice === otherChoice) {
|
|
144
|
+
const customAnswer = (await ctx.ui.editor(question.question, ""))?.trim();
|
|
145
|
+
if (!customAnswer) return undefined;
|
|
146
|
+
answers.push({
|
|
147
|
+
id: question.id,
|
|
148
|
+
header: question.header,
|
|
149
|
+
question: question.question,
|
|
150
|
+
answer: customAnswer,
|
|
151
|
+
wasCustom: true,
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const optionIndex = choices.indexOf(choice);
|
|
156
|
+
const option = question.options[optionIndex];
|
|
157
|
+
if (!option) return undefined;
|
|
158
|
+
answers.push({
|
|
159
|
+
id: question.id,
|
|
160
|
+
header: question.header,
|
|
161
|
+
question: question.question,
|
|
162
|
+
answer: option.label,
|
|
163
|
+
wasCustom: false,
|
|
164
|
+
optionIndex: optionIndex + 1,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return answers;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: number) {
|
|
171
|
+
return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function planModeQuestionAnswered(questions: PlanModeQuestion[], answers: PlanModeQuestionAnswer[]) {
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: false, answers }) }],
|
|
177
|
+
details: { cancelled: false, questions, answers } satisfies PlanModeQuestionDetails,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function planModeQuestionCancelled(
|
|
182
|
+
questions: PlanModeQuestion[],
|
|
183
|
+
reason: PlanModeQuestionReason,
|
|
184
|
+
message: string,
|
|
185
|
+
) {
|
|
186
|
+
return {
|
|
187
|
+
content: [{ type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: true, reason, message }) }],
|
|
188
|
+
details: { cancelled: true, reason, questions } satisfies PlanModeQuestionDetails,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function formatPlanModeQuestionPayload(payload: {
|
|
193
|
+
cancelled: boolean;
|
|
194
|
+
reason?: PlanModeQuestionReason;
|
|
195
|
+
message?: string;
|
|
196
|
+
answers?: PlanModeQuestionAnswer[];
|
|
197
|
+
}) {
|
|
198
|
+
return JSON.stringify(payload, null, 2);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
202
|
+
return typeof value === "object" && value !== null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function stringField(value: unknown) {
|
|
206
|
+
return typeof value === "string" ? value.trim() : undefined;
|
|
207
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
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";
|
|
5
|
+
|
|
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
|
+
]);
|
|
70
|
+
|
|
71
|
+
export function isBuiltinTool(tool: ToolInfo) {
|
|
72
|
+
return tool.sourceInfo.source === "builtin";
|
|
73
|
+
}
|
|
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
|
+
|
|
82
|
+
export function canSelectToolInPlanMode(tool: ToolInfo) {
|
|
83
|
+
return classifyPlanModeTool(tool) !== "blocked";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readCommand(input: unknown) {
|
|
87
|
+
const command = input as { command?: unknown } | undefined;
|
|
88
|
+
return typeof command?.command === "string" ? command.command : "";
|
|
89
|
+
}
|
|
90
|
+
|
|
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 {
|
|
97
|
+
const trimmed = command.trim();
|
|
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;
|
|
356
|
+
}
|