feat: Implement Plan Mode for Safe Code Planning (#658)

This commit is contained in:
tanzhenxin
2025-09-24 14:26:17 +08:00
committed by GitHub
parent 8379bc4d81
commit 4e7a7e2656
43 changed files with 2895 additions and 281 deletions

View File

@@ -11,6 +11,7 @@ import {
getCommandRoots,
getShellConfiguration,
isCommandAllowed,
isCommandNeedsPermission,
stripShellWrapper,
} from './shell-utils.js';
import type { Config } from '../config/config.js';
@@ -27,8 +28,10 @@ vi.mock('os', () => ({
}));
const mockQuote = vi.hoisted(() => vi.fn());
const mockParse = vi.hoisted(() => vi.fn());
vi.mock('shell-quote', () => ({
quote: mockQuote,
parse: mockParse,
}));
let config: Config;
@@ -38,6 +41,7 @@ beforeEach(() => {
mockQuote.mockImplementation((args: string[]) =>
args.map((arg) => `'${arg}'`).join(' '),
);
mockParse.mockImplementation((cmd: string) => cmd.split(' '));
config = {
getCoreTools: () => [],
getExcludeTools: () => [],
@@ -436,3 +440,16 @@ describe('getShellConfiguration', () => {
});
});
});
describe('isCommandNeedPermission', () => {
it('returns false for read-only commands', () => {
const result = isCommandNeedsPermission('ls');
expect(result.requiresPermission).toBe(false);
});
it('returns true for mutating commands with reason', () => {
const result = isCommandNeedsPermission('rm -rf temp');
expect(result.requiresPermission).toBe(true);
expect(result.reason).toContain('requires permission to execute');
});
});

View File

@@ -9,6 +9,7 @@ import type { Config } from '../config/config.js';
import os from 'node:os';
import { quote } from 'shell-quote';
import { doesToolInvocationMatch } from './tool-utils.js';
import { isShellCommandReadOnly } from './shellReadOnlyChecker.js';
const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool'];
@@ -469,3 +470,19 @@ export function isCommandAllowed(
}
return { allowed: false, reason: blockReason };
}
export function isCommandNeedsPermission(command: string): {
requiresPermission: boolean;
reason?: string;
} {
const isAllowed = isShellCommandReadOnly(command);
if (isAllowed) {
return { requiresPermission: false };
}
return {
requiresPermission: true,
reason: 'Command requires permission to execute.',
};
}

View File

@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { isShellCommandReadOnly } from './shellReadOnlyChecker.js';
describe('evaluateShellCommandReadOnly', () => {
it('allows simple read-only command', () => {
const result = isShellCommandReadOnly('ls -la');
expect(result).toBe(true);
});
it('rejects mutating commands like rm', () => {
const result = isShellCommandReadOnly('rm -rf temp');
expect(result).toBe(false);
});
it('rejects redirection output', () => {
const result = isShellCommandReadOnly('ls > out.txt');
expect(result).toBe(false);
});
it('rejects command substitution', () => {
const result = isShellCommandReadOnly('echo $(touch file)');
expect(result).toBe(false);
});
it('allows git status but rejects git commit', () => {
expect(isShellCommandReadOnly('git status')).toBe(true);
const commitResult = isShellCommandReadOnly('git commit -am "msg"');
expect(commitResult).toBe(false);
});
it('rejects find with exec', () => {
const result = isShellCommandReadOnly('find . -exec rm {} \\;');
expect(result).toBe(false);
});
it('rejects sed in-place', () => {
const result = isShellCommandReadOnly("sed -i 's/foo/bar/' file");
expect(result).toBe(false);
});
it('rejects empty command', () => {
const result = isShellCommandReadOnly(' ');
expect(result).toBe(false);
});
it('respects environment prefix followed by allowed command', () => {
const result = isShellCommandReadOnly('FOO=bar ls');
expect(result).toBe(true);
});
});

View File

@@ -0,0 +1,300 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { parse } from 'shell-quote';
import {
detectCommandSubstitution,
splitCommands,
stripShellWrapper,
} from './shell-utils.js';
const READ_ONLY_ROOT_COMMANDS = new Set([
'awk',
'basename',
'cat',
'cd',
'column',
'cut',
'df',
'dirname',
'du',
'echo',
'env',
'find',
'git',
'grep',
'head',
'less',
'ls',
'more',
'printenv',
'printf',
'ps',
'pwd',
'rg',
'ripgrep',
'sed',
'sort',
'stat',
'tail',
'tree',
'uniq',
'wc',
'which',
'where',
'whoami',
]);
const BLOCKED_FIND_FLAGS = new Set([
'-delete',
'-exec',
'-execdir',
'-ok',
'-okdir',
]);
const BLOCKED_FIND_PREFIXES = ['-fprint', '-fprintf'];
const READ_ONLY_GIT_SUBCOMMANDS = new Set([
'blame',
'branch',
'cat-file',
'diff',
'grep',
'log',
'ls-files',
'remote',
'rev-parse',
'show',
'status',
'describe',
]);
const BLOCKED_GIT_REMOTE_ACTIONS = new Set([
'add',
'remove',
'rename',
'set-url',
'prune',
'update',
]);
const BLOCKED_GIT_BRANCH_FLAGS = new Set([
'-d',
'-D',
'--delete',
'--move',
'-m',
]);
const BLOCKED_SED_PREFIXES = ['-i'];
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/;
function containsWriteRedirection(command: string): boolean {
let inSingleQuotes = false;
let inDoubleQuotes = false;
let escapeNext = false;
for (const char of command) {
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\' && !inSingleQuotes) {
escapeNext = true;
continue;
}
if (char === "'" && !inDoubleQuotes) {
inSingleQuotes = !inSingleQuotes;
continue;
}
if (char === '"' && !inSingleQuotes) {
inDoubleQuotes = !inDoubleQuotes;
continue;
}
if (!inSingleQuotes && !inDoubleQuotes && char === '>') {
return true;
}
}
return false;
}
function normalizeTokens(segment: string): string[] {
const parsed = parse(segment);
const tokens: string[] = [];
for (const token of parsed) {
if (typeof token === 'string') {
tokens.push(token);
}
}
return tokens;
}
function skipEnvironmentAssignments(tokens: string[]): {
root?: string;
args: string[];
} {
let index = 0;
while (index < tokens.length && ENV_ASSIGNMENT_REGEX.test(tokens[index]!)) {
index++;
}
if (index >= tokens.length) {
return { args: [] };
}
return {
root: tokens[index],
args: tokens.slice(index + 1),
};
}
function evaluateFindCommand(tokens: string[]): boolean {
const [, ...rest] = tokens;
for (const token of rest) {
const lower = token.toLowerCase();
if (BLOCKED_FIND_FLAGS.has(lower)) {
return false;
}
if (BLOCKED_FIND_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
return false;
}
}
return true;
}
function evaluateSedCommand(tokens: string[]): boolean {
const [, ...rest] = tokens;
for (const token of rest) {
if (
BLOCKED_SED_PREFIXES.some((prefix) => token.startsWith(prefix)) ||
token === '--in-place'
) {
return false;
}
}
return true;
}
function evaluateGitRemoteArgs(args: string[]): boolean {
for (const arg of args) {
if (BLOCKED_GIT_REMOTE_ACTIONS.has(arg.toLowerCase())) {
return false;
}
}
return true;
}
function evaluateGitBranchArgs(args: string[]): boolean {
for (const arg of args) {
if (BLOCKED_GIT_BRANCH_FLAGS.has(arg)) {
return false;
}
}
return true;
}
function evaluateGitCommand(tokens: string[]): boolean {
let index = 1;
while (index < tokens.length && tokens[index]!.startsWith('-')) {
const flag = tokens[index]!.toLowerCase();
if (flag === '--version' || flag === '--help') {
return true;
}
index++;
}
if (index >= tokens.length) {
return true;
}
const subcommand = tokens[index]!.toLowerCase();
if (!READ_ONLY_GIT_SUBCOMMANDS.has(subcommand)) {
return false;
}
const args = tokens.slice(index + 1);
if (subcommand === 'remote') {
return evaluateGitRemoteArgs(args);
}
if (subcommand === 'branch') {
return evaluateGitBranchArgs(args);
}
return true;
}
function evaluateShellSegment(segment: string): boolean {
if (!segment.trim()) {
return true;
}
const stripped = stripShellWrapper(segment);
if (!stripped) {
return true;
}
if (detectCommandSubstitution(stripped)) {
return false;
}
if (containsWriteRedirection(stripped)) {
return false;
}
const tokens = normalizeTokens(stripped);
if (tokens.length === 0) {
return true;
}
const { root, args } = skipEnvironmentAssignments(tokens);
if (!root) {
return true;
}
const normalizedRoot = root.toLowerCase();
if (!READ_ONLY_ROOT_COMMANDS.has(normalizedRoot)) {
return false;
}
if (normalizedRoot === 'find') {
return evaluateFindCommand([normalizedRoot, ...args]);
}
if (normalizedRoot === 'sed') {
return evaluateSedCommand([normalizedRoot, ...args]);
}
if (normalizedRoot === 'git') {
return evaluateGitCommand([normalizedRoot, ...args]);
}
return true;
}
export function isShellCommandReadOnly(command: string): boolean {
if (typeof command !== 'string' || !command.trim()) {
return false;
}
const segments = splitCommands(command);
for (const segment of segments) {
const isAllowed = evaluateShellSegment(segment);
if (!isAllowed) {
return false;
}
}
return true;
}