Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions core/tools/implementations/runTerminalCommand.vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,7 +846,7 @@ describe("runTerminalCommandTool.evaluateToolCallPolicy", () => {
expect(result).toBe("disabled");
});

it("should require permission for high-risk network commands", () => {
it("should honor Automatic for high-risk network commands (#13035)", () => {
const basePolicy = "allowedWithoutPermission";
const args = { command: "curl http://example.com" };

Expand All @@ -855,6 +855,8 @@ describe("runTerminalCommandTool.evaluateToolCallPolicy", () => {
args,
);

expect(result).toBe("allowedWithPermission");
// Automatic mode no longer demotes to Ask First for non-critical commands.
// Only critical commands (rm -rf /, etc.) stay disabled under Automatic.
expect(result).toBe("allowedWithoutPermission");
});
});
119 changes: 119 additions & 0 deletions gui/src/redux/thunks/streamResponse_toolCalls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2021,6 +2021,125 @@ describe("streamResponseThunk - tool calls", () => {
);
});

it("should auto-execute run_terminal_command when Automatic and policy allows", async () => {
const initialState = getRootStateWithClaude();
initialState.session.history = [
{
message: { id: "1", role: "user", content: "Run npm test" },
contextItems: [],
},
];
initialState.ui.toolSettings = {
[terminalName]: "allowedWithoutPermission",
};
initialState.config.config.tools = [terminalTool];
initialState.session.id = "session-terminal-auto";
const mockStore = createMockStore(initialState);
const mockIdeMessenger = mockStore.mockIdeMessenger;
const requestSpy = vi.spyOn(mockIdeMessenger, "request");

// Simulate core evaluatePolicy after #13035 fix: Automatic is honored
// for non-critical commands (including previously "high risk" ones).
mockIdeMessenger.responseHandlers["tools/evaluatePolicy"] = async (
data,
) => {
return { policy: data.basePolicy };
};
mockIdeMessenger.responses["llm/compileChat"] = {
compiledChatMessages: [{ role: "user", content: "Run npm test" }],
didPrune: false,
contextPercentage: 0.5,
};
mockIdeMessenger.responses["tools/call"] = {
contextItems: [
{
name: "Terminal",
description: "Command output",
content: "tests passed",
icon: "terminal",
hidden: false,
},
],
errorMessage: undefined,
};

async function* mockStreamWithNpmTest(): AsyncGenerator<
AssistantChatMessage[],
PromptLog
> {
yield [{ role: "assistant", content: "I'll run npm test." }];
yield [
{
role: "assistant",
content: "",
toolCalls: [
{
id: "tool-npm-test",
type: "function",
function: {
name: terminalName,
arguments: JSON.stringify({ command: "npm test" }),
},
},
],
},
];
return {
prompt: "Run npm test",
completion: "I'll run npm test.",
modelProvider: "anthropic",
modelTitle: "Claude 3.5 Sonnet",
};
}

let streamCallCount = 0;
mockIdeMessenger.llmStreamChat = vi.fn().mockImplementation(() => {
streamCallCount++;
if (streamCallCount === 1) {
return mockStreamWithNpmTest();
}
async function* simpleGenerator(): AsyncGenerator<
AssistantChatMessage[],
PromptLog
> {
yield [{ role: "assistant", content: "Done." }];
return {
prompt: "continuing after tool",
completion: "Done.",
modelProvider: "anthropic",
modelTitle: "Claude 3.5 Sonnet",
};
}
return simpleGenerator();
});

await mockStore.dispatch(
streamResponseThunk({
editorState: mockEditorState,
modifiers: mockModifiers,
}) as any,
);

expect(requestSpy).toHaveBeenCalledWith(
"tools/evaluatePolicy",
expect.objectContaining({
toolName: terminalName,
basePolicy: "allowedWithoutPermission",
parsedArgs: { command: "npm test" },
}),
);
expect(requestSpy).toHaveBeenCalledWith(
"tools/call",
expect.objectContaining({
toolCall: expect.objectContaining({
function: expect.objectContaining({
name: terminalName,
}),
}),
}),
);
});

it("should respect disabled policy", async () => {
const initialState = getRootStateWithClaude();
initialState.session.history = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ type ParsedToken = string | ShellOperator | GlobPattern | CommentToken;
* This function uses shell-quote for proper tokenization, then implements
* defense-in-depth security validation for terminal commands.
*
* When the user selects Automatic (`allowedWithoutPermission`), only critical
* commands are hard-disabled. Non-critical commands are not demoted back to
* Ask First — that previously made Automatic appear broken for
* `run_terminal_command` (#13035, #10512).
*
* @param basePolicy The base policy configured for the tool
* @param command The command string to evaluate
* @returns The security policy to apply: 'disabled', 'allowedWithPermission', or 'allowedWithoutPermission'
Expand All @@ -49,6 +54,27 @@ export function evaluateTerminalCommandSecurity(
return basePolicy;
}

const evaluated = evaluateCommandSecurity(basePolicy, normalizedCommand);

// Automatic mode: never re-prompt. Only critical commands stay disabled.
if (
basePolicy === "allowedWithoutPermission" &&
evaluated === "allowedWithPermission"
) {
return "allowedWithoutPermission";
}

return evaluated;
}

/**
* Internal evaluation that may tighten Automatic to Ask First for non-safe
* commands. Callers that honor Automatic apply that preference after this.
*/
function evaluateCommandSecurity(
basePolicy: ToolPolicy,
normalizedCommand: string,
): ToolPolicy {
try {
// Split on line breaks to handle multi-line commands
// Newlines are command separators in shells, similar to semicolons
Expand Down
Loading
Loading