Skip to content

Commit afcbc50

Browse files
committed
feat: add permission restricting in Plan Mode
1 parent 6f0f3ec commit afcbc50

7 files changed

Lines changed: 209 additions & 9 deletions

File tree

packages/cli/src/tests/prompt-input-keys.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
buildPromptDraftFromSessionMessage,
2525
extractProposedPlan,
2626
getImplementationPrompt,
27+
getPlanImplementationChoice,
2728
disableTerminalExtendedKeys,
2829
enableTerminalExtendedKeys,
2930
EMPTY_BUFFER,
@@ -160,6 +161,10 @@ test("getImplementationPrompt uses Chinese only above five full-width punctuatio
160161
assert.equal(getImplementationPrompt(",、;。;。"), "实现此方案。");
161162
});
162163

164+
test("getPlanImplementationChoice treats escape as staying in Plan Mode", () => {
165+
assert.equal(getPlanImplementationChoice("", { escape: true, return: false }, 0), "stay");
166+
});
167+
163168
test("prompt return key action submits on plain enter", () => {
164169
const { key } = parseTerminalInput("\r");
165170
assert.equal(getPromptReturnKeyAction(key), "submit");

packages/cli/src/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export {
1919
PlanImplementationPrompt,
2020
extractProposedPlan,
2121
getImplementationPrompt,
22+
getPlanImplementationChoice,
2223
} from "./views/PlanImplementationPrompt";
2324
export { MessageView } from "./components";
2425
export { parseDiffPreview } from "./components/MessageView/utils";

packages/cli/src/ui/views/PlanImplementationPrompt.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useEffect, useState } from "react";
22
import { Box, Text } from "ink";
33
import { useTerminalInput } from "../hooks";
4+
import type { InputKey } from "../hooks";
45

56
type PlanImplementationChoice = "implement" | "stay" | "default";
67

@@ -28,6 +29,20 @@ export function getImplementationPrompt(plan: string): string {
2829
return fullWidthPunctuationCount > 5 ? "实现此方案。" : "Implement the plan.";
2930
}
3031

32+
export function getPlanImplementationChoice(
33+
input: string,
34+
key: Pick<InputKey, "escape" | "return">,
35+
cursor: number
36+
): PlanImplementationChoice | null {
37+
if (key.escape) {
38+
return "stay";
39+
}
40+
if (input && /^[1-3]$/.test(input)) {
41+
return CHOICES[Number(input) - 1]!.value;
42+
}
43+
return key.return ? CHOICES[cursor]!.value : null;
44+
}
45+
3146
export function PlanImplementationPrompt({ onSelect }: Props): React.ReactElement {
3247
const [cursor, setCursor] = useState(0);
3348

@@ -36,6 +51,11 @@ export function PlanImplementationPrompt({ onSelect }: Props): React.ReactElemen
3651
}, []);
3752

3853
useTerminalInput((input, key) => {
54+
const choice = getPlanImplementationChoice(input, key, cursor);
55+
if (choice) {
56+
onSelect(choice);
57+
return;
58+
}
3959
if (key.upArrow) {
4060
setCursor((value) => Math.max(0, value - 1));
4161
return;
@@ -44,13 +64,6 @@ export function PlanImplementationPrompt({ onSelect }: Props): React.ReactElemen
4464
setCursor((value) => Math.min(CHOICES.length - 1, value + 1));
4565
return;
4666
}
47-
if (input && /^[1-3]$/.test(input)) {
48-
onSelect(CHOICES[Number(input) - 1]!.value);
49-
return;
50-
}
51-
if (key.return) {
52-
onSelect(CHOICES[cursor]!.value);
53-
}
5467
});
5568

5669
return (

packages/core/src/common/permissions.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export type ComputeToolCallPermissionsOptions = {
6060
projectRoot: string;
6161
toolCalls: unknown[];
6262
settings?: Required<PermissionSettings>;
63+
forceAskScopes?: readonly PermissionScope[];
6364
readPermissionExemptPaths?: string[];
6465
resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined;
6566
};
@@ -163,10 +164,18 @@ export function computeToolCallPermissions(options: ComputeToolCallPermissionsOp
163164
readPermissionExemptPaths: options.readPermissionExemptPaths,
164165
resolveSnippetPath: options.resolveSnippetPath,
165166
});
166-
const permission = evaluatePermissionScopes(request.scopes, options.settings);
167+
const evaluatedPermission = evaluatePermissionScopes(request.scopes, options.settings);
168+
const forcedAskScopes =
169+
evaluatedPermission === "deny"
170+
? []
171+
: getAllowedForcedAskScopes(request.scopes, options.settings, options.forceAskScopes);
172+
const permission = forcedAskScopes.length > 0 ? "ask" : evaluatedPermission;
167173
permissions.push({ toolCallId: toolCall.id, permission });
168174
if (permission === "ask") {
169-
const askScopes = getPermissionScopesRequiringAsk(request.scopes, options.settings);
175+
const askScopes = mergeAskScopes(
176+
getPermissionScopesRequiringAsk(request.scopes, options.settings),
177+
forcedAskScopes
178+
);
170179
askPermissions.push({
171180
toolCallId: toolCall.id,
172181
scopes: askScopes.length > 0 ? askScopes : request.scopes,
@@ -180,6 +189,25 @@ export function computeToolCallPermissions(options: ComputeToolCallPermissionsOp
180189
return { permissions, askPermissions };
181190
}
182191

192+
function getAllowedForcedAskScopes(
193+
scopes: AskPermissionScope[],
194+
settings: Required<PermissionSettings> | undefined,
195+
forceAskScopes: readonly PermissionScope[] | undefined
196+
): PermissionScope[] {
197+
if (!forceAskScopes?.length) {
198+
return [];
199+
}
200+
201+
return scopes.filter(
202+
(scope): scope is PermissionScope =>
203+
scope !== "unknown" && forceAskScopes.includes(scope) && evaluatePermissionScopes([scope], settings) === "allow"
204+
);
205+
}
206+
207+
function mergeAskScopes(existing: AskPermissionScope[], forced: PermissionScope[]): AskPermissionScope[] {
208+
return [...existing, ...forced.filter((scope) => !existing.includes(scope))];
209+
}
210+
183211
export function describeToolPermissionRequest(options: {
184212
sessionId: string;
185213
projectRoot: string;

packages/core/src/session.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024;
6969
const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024;
7070
const PLAN_MODE_ON_STATUS_MESSAGE = " └ Set Plan Mode on. Awaiting <proposed_plan>.";
7171
const PLAN_MODE_OFF_STATUS_MESSAGE = " └ Set Plan Mode off.";
72+
const PLAN_MODE_FORCE_ASK_SCOPES = [
73+
"write-in-cwd",
74+
"write-out-cwd",
75+
"delete-in-cwd",
76+
"delete-out-cwd",
77+
"mutate-git-log",
78+
] as const satisfies readonly PermissionScope[];
7279

7380
type ChatCompletionDebugOptions = {
7481
enabled?: boolean;
@@ -1416,6 +1423,7 @@ ${agentInstructions}
14161423
projectRoot: this.projectRoot,
14171424
toolCalls,
14181425
settings: this.getResolvedSettings().permissions,
1426+
forceAskScopes: this.getSession(sessionId)?.planMode ? PLAN_MODE_FORCE_ASK_SCOPES : undefined,
14191427
readPermissionExemptPaths: this.getSkillScanRoots().map((entry) => entry.root),
14201428
resolveSnippetPath: (id, snippetId) => getSnippet(id, snippetId)?.filePath,
14211429
})

packages/core/src/tests/permissions.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,103 @@ test("computeToolCallPermissions only asks for scopes not already allowed", () =
170170
);
171171
});
172172

173+
test("computeToolCallPermissions temporarily upgrades allowed forced scopes to ask", () => {
174+
const projectRoot = createTempDir("deepcode-permissions-force-ask-workspace-");
175+
const forcedScopes: PermissionScope[] = [
176+
"write-in-cwd",
177+
"write-out-cwd",
178+
"delete-in-cwd",
179+
"delete-out-cwd",
180+
"mutate-git-log",
181+
];
182+
const plan = computeToolCallPermissions({
183+
sessionId: "session-1",
184+
projectRoot,
185+
forceAskScopes: forcedScopes,
186+
settings: {
187+
allow: ["write-in-cwd", "write-out-cwd", "delete-out-cwd", "mutate-git-log"] as PermissionScope[],
188+
deny: ["delete-in-cwd"] as PermissionScope[],
189+
ask: [] as PermissionScope[],
190+
defaultMode: "allowAll" as const,
191+
},
192+
toolCalls: [
193+
{
194+
id: "call-write-in",
195+
type: "function",
196+
function: { name: "write", arguments: JSON.stringify({ file_path: path.join(projectRoot, "file.txt") }) },
197+
},
198+
{
199+
id: "call-write-out",
200+
type: "function",
201+
function: { name: "write", arguments: JSON.stringify({ file_path: "/tmp/file.txt" }) },
202+
},
203+
{
204+
id: "call-delete-out",
205+
type: "function",
206+
function: {
207+
name: "bash",
208+
arguments: JSON.stringify({
209+
command: "rm /tmp/file.txt",
210+
sideEffects: ["delete-out-cwd"],
211+
}),
212+
},
213+
},
214+
{
215+
id: "call-mutate-git",
216+
type: "function",
217+
function: {
218+
name: "bash",
219+
arguments: JSON.stringify({ command: "git commit --allow-empty -m test", sideEffects: ["mutate-git-log"] }),
220+
},
221+
},
222+
{
223+
id: "call-delete-in",
224+
type: "function",
225+
function: {
226+
name: "bash",
227+
arguments: JSON.stringify({ command: "rm file.txt", sideEffects: ["delete-in-cwd"] }),
228+
},
229+
},
230+
],
231+
});
232+
233+
assert.deepEqual(plan.permissions, [
234+
{ toolCallId: "call-write-in", permission: "ask" },
235+
{ toolCallId: "call-write-out", permission: "ask" },
236+
{ toolCallId: "call-delete-out", permission: "ask" },
237+
{ toolCallId: "call-mutate-git", permission: "ask" },
238+
{ toolCallId: "call-delete-in", permission: "deny" },
239+
]);
240+
assert.deepEqual(
241+
plan.askPermissions.map((item) => ({ id: item.toolCallId, scopes: item.scopes })),
242+
[
243+
{ id: "call-write-in", scopes: ["write-in-cwd"] },
244+
{ id: "call-write-out", scopes: ["write-out-cwd"] },
245+
{ id: "call-delete-out", scopes: ["delete-out-cwd"] },
246+
{ id: "call-mutate-git", scopes: ["mutate-git-log"] },
247+
]
248+
);
249+
250+
const defaultPlan = computeToolCallPermissions({
251+
sessionId: "session-1",
252+
projectRoot,
253+
settings: {
254+
allow: ["write-in-cwd"] as PermissionScope[],
255+
deny: [] as PermissionScope[],
256+
ask: [] as PermissionScope[],
257+
defaultMode: "allowAll" as const,
258+
},
259+
toolCalls: [
260+
{
261+
id: "call-default",
262+
type: "function",
263+
function: { name: "write", arguments: JSON.stringify({ file_path: path.join(projectRoot, "file.txt") }) },
264+
},
265+
],
266+
});
267+
assert.deepEqual(defaultPlan.permissions, [{ toolCallId: "call-default", permission: "allow" }]);
268+
});
269+
173270
test("computeToolCallPermissions allows read tool calls under skill scan paths", () => {
174271
const projectRoot = createTempDir("deepcode-permissions-skill-read-workspace-");
175272
const home = createTempDir("deepcode-permissions-skill-read-home-");

packages/core/src/tests/session.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2294,6 +2294,54 @@ test("activateSession pauses for permission when a tool call requires ask", asyn
22942294
);
22952295
});
22962296

2297+
test("activateSession temporarily asks before allowed writes in Plan Mode", async () => {
2298+
const workspace = createTempDir("deepcode-plan-permission-workspace-");
2299+
const home = createTempDir("deepcode-plan-permission-home-");
2300+
setHomeDir(home);
2301+
2302+
const manager = createPermissionSessionManager(
2303+
workspace,
2304+
[
2305+
{
2306+
choices: [
2307+
{
2308+
message: {
2309+
content: "",
2310+
tool_calls: [
2311+
{
2312+
id: "call-write",
2313+
type: "function",
2314+
function: {
2315+
name: "write",
2316+
arguments: JSON.stringify({ file_path: path.join(workspace, "plan.txt"), content: "planned" }),
2317+
},
2318+
},
2319+
],
2320+
},
2321+
},
2322+
],
2323+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
2324+
},
2325+
],
2326+
{
2327+
allow: ["write-in-cwd"],
2328+
deny: [],
2329+
ask: [],
2330+
defaultMode: "allowAll",
2331+
}
2332+
);
2333+
2334+
const sessionId = await manager.createSession({ text: "Plan this change", planMode: true });
2335+
const session = manager.getSession(sessionId);
2336+
const assistant = manager
2337+
.listSessionMessages(sessionId)
2338+
.find((message) => message.role === "assistant" && (message.messageParams as any)?.tool_calls);
2339+
2340+
assert.equal(session?.status, "ask_permission");
2341+
assert.deepEqual(session?.askPermissions?.[0]?.scopes, ["write-in-cwd"]);
2342+
assert.deepEqual(assistant?.meta?.permissions, [{ toolCallId: "call-write", permission: "ask" }]);
2343+
});
2344+
22972345
test("SessionManager preserves permission_denied status when sessions are reloaded", async () => {
22982346
const workspace = createTempDir("deepcode-permission-denied-workspace-");
22992347
const home = createTempDir("deepcode-permission-denied-home-");

0 commit comments

Comments
 (0)