diff --git a/.changeset/dangerous-command-ask.md b/.changeset/dangerous-command-ask.md new file mode 100644 index 00000000000..c9b6c616719 --- /dev/null +++ b/.changeset/dangerous-command-ask.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Block dangerous shell commands such as shutdown, reboot, or rm -rf in Auto mode, and always ask before running them in Manual and YOLO modes; disable the guard with `[permission] dangerous_command_guard = false` or `KIMI_CODE_DANGEROUS_COMMAND_GUARD=false`. diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 299e926caa6..8f735e9b246 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -148,6 +148,7 @@ export async function runV2Print( clientIdentity: identity, args: { requestHeaders: hostHeaders, + nonInteractive: true, // `--skillsDir` (v1 print parity): explicit skill dirs replace default // user / project discovery for this process. skillDirs: opts.skillsDirs, diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 67e7cd74d94..cf0b47e3d1b 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -155,7 +155,7 @@ export const BUILTIN_SLASH_COMMANDS = [ { name: 'auto', aliases: [], - description: 'Toggle Auto mode: fully autonomous, agent decides everything without asking.', + description: 'Toggle Auto mode: fully autonomous, but dangerous commands are blocked.', priority: 99, availability: 'always', }, diff --git a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts index c1309878350..c1672454cff 100644 --- a/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/permission-selector.ts @@ -16,7 +16,7 @@ const PERMISSION_OPTIONS: readonly ChoiceOption[] = [ { value: 'auto', label: 'Auto', - description: 'Fully autonomous — agent decides everything without asking.', + description: 'Fully autonomous, but dangerous commands are blocked.', }, ]; diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index d4867efbae5..ae9a2025e1b 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -98,7 +98,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d | Field | Type | Default | Description | | --- | --- | --- | --- | | `default_model` | `string` | — | Default model alias; must be defined in `models` | -| `default_permission_mode` | `string` | `manual` | Default permission mode for new sessions; one of `manual` (prompt each time), `yolo` (auto-approve tool actions, but the agent may still ask questions), or `auto` (fully autonomous — the agent decides everything without asking) | +| `default_permission_mode` | `string` | `manual` | Default permission mode for new sessions; one of `manual` (prompt each time), `yolo` (auto-approve tool actions, but the agent may still ask questions), or `auto` (fully autonomous, but dangerous commands are always blocked) | | `default_plan_mode` | `boolean` | `false` | Whether new sessions start in Plan mode (produce a plan before executing) by default | | `merge_all_available_skills` | `boolean` | `true` | Whether to merge Agent Skills from all available directories | | `extra_skill_dirs` | `array` | — | Extra skill search directories, layered on top of the default directories | @@ -479,6 +479,8 @@ api_key = "sk-xxx" `permission` sets permission rules that are automatically loaded when a session starts, controlling whether the Agent needs user confirmation before calling a tool. Rules are written as a `[[permission.rules]]` array of tables, matched in order — the first matching rule takes effect. +You can also set `dangerous_command_guard = false` under `[permission]` to turn off the built-in dangerous-command policy entirely (no dangerous-command ask or auto-mode deny); the default is `true`. An environment variable `KIMI_CODE_DANGEROUS_COMMAND_GUARD=false` overrides the file setting and restores the behavior before the policy was introduced. Use this switch only for environments that already gate commands outside the agent. + | Field | Type | Required | Description | | --- | --- | --- | --- | | `decision` | `string` | Yes | Action on match: `allow` (permit immediately), `deny` (reject immediately), `ask` (prompt each time) | diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index 35e0c07b43c..4c058434347 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -62,9 +62,9 @@ Approvals are not triggered for regular tool calls in YOLO mode, nor for writes ### YOLO / Auto mode -**YOLO mode** (`/yolo`) auto-approves regular tool calls, making it suitable for batch tasks you know are safe. It still asks before sensitive actions — accessing sensitive files such as `.env` or SSH keys, or exiting Plan mode — and the agent can still ask you questions. +**YOLO mode** (`/yolo`) auto-approves regular tool calls, making it suitable for batch tasks you know are safe. It still asks before sensitive actions — accessing sensitive files such as `.env` or SSH keys, running dangerous commands such as `shutdown` or `rm -rf`, or exiting Plan mode — and the agent can still ask you questions. -**Auto mode** (`/auto`) is the fully unattended mode: every tool approval is handled automatically, including sensitive files and plan exits, and the agent never asks you questions — it decides everything on its own. +**Auto mode** (`/auto`) is the fully unattended mode: every tool approval is handled automatically, including sensitive files and plan exits, and the agent never asks you questions — it decides everything on its own. The only exception is the built-in dangerous-command guard: commands such as `shutdown`, `reboot`, or `rm -rf` are always blocked in Auto mode, and always require your confirmation in Manual and YOLO mode. ## Mode switching diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 4b13c72d7aa..e32e3d8a1c4 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -98,7 +98,7 @@ timeout = 5 | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `default_model` | `string` | — | 默认模型别名,必须在 `models` 中定义 | -| `default_permission_mode` | `string` | `manual` | 新会话的默认权限模式,可选 `manual`(逐次询问)、`yolo`(自动批准工具操作,Agent 仍可能提问)、`auto`(完全自主,Agent 自己做决定,不再提问) | +| `default_permission_mode` | `string` | `manual` | 新会话的默认权限模式,可选 `manual`(逐次询问)、`yolo`(自动批准工具操作,Agent 仍可能提问)、`auto`(完全自主,但危险命令始终被拒绝) | | `default_plan_mode` | `boolean` | `false` | 新会话是否默认以 Plan 模式(先出计划再执行)启动 | | `merge_all_available_skills` | `boolean` | `true` | 是否合并所有目录中的 Agent Skills | | `extra_skill_dirs` | `array` | — | 额外 Skill 搜索目录,叠加到默认目录之上 | @@ -478,6 +478,8 @@ api_key = "sk-xxx" `permission` 设置会话启动时自动加载的权限规则,控制 Agent 调用工具时是否需要用户确认。规则用 `[[permission.rules]]` 数组表写出,按顺序匹配,第一条命中即生效。 +也可以在 `[permission]` 下设置 `dangerous_command_guard = false` 完全关闭内置危险命令策略(不再触发危险命令审批或 auto 模式拒绝),默认 `true`。环境变量 `KIMI_CODE_DANGEROUS_COMMAND_GUARD=false` 会覆盖文件设置并恢复策略引入前的行为。此开关只适用于已经在 Agent 之外统一命令限权的环境。 + | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `decision` | `string` | 是 | 匹配后的处置:`allow`(直接放行)、`deny`(直接拒绝)、`ask`(每次询问) | diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index 22de82f2052..1200832065a 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -62,9 +62,9 @@ YOLO 模式下的普通工具调用、Plan 模式下对计划文件的写入, ### YOLO / Auto 模式 -**YOLO 模式**(`/yolo`)自动批准普通工具调用,适合已知安全的批处理任务。敏感操作仍会询问——例如访问 `.env`、SSH 私钥等敏感文件,或退出 Plan 模式——Agent 也仍可能向你提问。 +**YOLO 模式**(`/yolo`)自动批准普通工具调用,适合已知安全的批处理任务。敏感操作仍会询问——例如访问 `.env`、SSH 私钥等敏感文件、执行 `shutdown`、`rm -rf` 这类危险命令,或退出 Plan 模式——Agent 也仍可能向你提问。 -**Auto 模式**(`/auto`)是完全无人值守模式:所有工具审批自动处理,包括敏感文件和计划退出,且 Agent 不会向你提问,完全由它自己做决定。 +**Auto 模式**(`/auto`)是完全无人值守模式:所有工具审批自动处理,包括敏感文件和计划退出,且 Agent 不会向你提问,完全由它自己做决定。唯一的例外是内置的危险命令拦截:`shutdown`、`reboot`、`rm -rf` 这类命令在 Auto 模式下会被直接拒绝,在 Manual 和 YOLO 模式下必须经你确认。 ## 模式切换 diff --git a/packages/acp-adapter/src/modes.ts b/packages/acp-adapter/src/modes.ts index 48e2771ca65..afb4392df68 100644 --- a/packages/acp-adapter/src/modes.ts +++ b/packages/acp-adapter/src/modes.ts @@ -39,7 +39,7 @@ export const ACP_MODES = [ { id: 'auto', name: 'Auto', - description: 'Fully autonomous — agent decides everything without asking.', + description: 'Fully autonomous, but dangerous commands are blocked.', }, { id: 'yolo', diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index 695e6b17692..bfcbea245bf 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -278,7 +278,9 @@ merge_all_available_skills = true # permission # owner: src/agent/permissionRules/configSection.ts # scope: core -# hooks: custom fromToml · custom toToml +# hooks: custom fromToml · custom toToml · stripEnv +# env: +# dangerous_command_guard <- KIMI_CODE_DANGEROUS_COMMAND_GUARD (custom parse) # ########################################################################## [permission] @@ -287,6 +289,7 @@ merge_all_available_skills = true # scope: "turn-override" | "session-runtime" | "project" | "user" (default: "user") # pattern: string # reason: string +# dangerous_command_guard: boolean # ########################################################################## # providers diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts index bec50a29324..1956a2bae35 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts @@ -1,8 +1,10 @@ import { IInstantiationService } from "#/_base/di/instantiation"; import { Service } from "#/_base/di/service"; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; import { AutoModeApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/auto-mode-approve'; import { AutoModeAskUserQuestionDenyPermissionPolicyService } from '#/agent/permissionPolicy/policies/auto-mode-ask-user-question-deny'; +import { DangerousCommandAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/dangerous-command-ask'; import { DefaultToolApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/default-tool-approve'; import { FallbackAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/fallback-ask'; import { GitControlPathAccessAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/git-control-path-access-ask'; @@ -31,11 +33,15 @@ export class AgentPermissionPolicyService constructor( @IInstantiationService private readonly instantiation: IInstantiationService, + @IBootstrapService bootstrap: IBootstrapService, ) { super(); this.policies = [ this.instantiation.createInstance(AutoModeAskUserQuestionDenyPermissionPolicyService), this.instantiation.createInstance(UserConfiguredDenyPermissionPolicyService), + ...(bootstrap.args.nonInteractive + ? [] + : [this.instantiation.createInstance(DangerousCommandAskPermissionPolicyService)]), this.instantiation.createInstance(AutoModeApprovePermissionPolicyService), this.instantiation.createInstance(SessionApprovalHistoryPermissionPolicyService), this.instantiation.createInstance(UserConfiguredAskPermissionPolicyService), diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/dangerous-command-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/dangerous-command-ask.ts new file mode 100644 index 00000000000..d657541101b --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/dangerous-command-ask.ts @@ -0,0 +1,366 @@ +import { + IBashParserService, + type BashParseResult, + type BashSyntaxNode, +} from '#/app/bashParser/bashParser'; +import { IConfigService } from '#/app/config/config'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import { isDangerousCommandGuardEnabled } from '#/agent/permissionRules/configSection'; +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import type { + PermissionPolicy, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; + +const PARSE_OPTIONS = { timeoutMs: 20, maxNodes: 10_000 } as const; + +const MAX_NESTED_SHELL_DEPTH = 4; + +const UNSAFE_OPERAND = /[$`*?[\]~]/; + +const SKIPPED_COMMAND_CHILDREN: ReadonlySet = new Set([ + 'variable_assignment', + 'file_redirect', + 'heredoc_redirect', +]); + +const SIMPLE_DANGEROUS_COMMANDS: ReadonlySet = new Set([ + 'shutdown', + 'halt', + 'poweroff', + 'reboot', + 'bcdedit', + 'diskpart', + 'format', + 'restart-computer', + 'stop-computer', + 'mkfs', + 'wipefs', +]); + +const PRIVILEGE_WRAPPERS: ReadonlySet = new Set(['sudo', 'doas']); + +const PRIVILEGE_VALUE_OPTIONS: ReadonlySet = new Set([ + '-u', + '--user', + '-g', + '--group', + '-h', + '--host', + '-p', + '--prompt', + '-C', + '--close-from', + '-T', + '--command-timeout', + '-U', + '--other-user', + '-r', + '--role', + '-t', + '--type', +]); + +const NESTED_SHELLS: ReadonlySet = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh', 'ash']); + +const LAUNCH_WRAPPERS: ReadonlySet = new Set([ + 'env', + 'command', + 'exec', + 'nohup', + 'builtin', + 'nice', +]); + +const WRAPPER_VALUE_OPTIONS: ReadonlySet = new Set([ + '-u', + '--unset', + '-C', + '--chdir', + '-S', + '--split-string', + '-a', + '-n', + '--adjustment', +]); + +const SYSTEMCTL_DANGEROUS_SUBCOMMANDS: ReadonlySet = new Set([ + 'poweroff', + 'reboot', + 'halt', + 'kexec', +]); + +const SYSTEMCTL_VALUE_OPTIONS: ReadonlySet = new Set(['-H', '--host', '-M', '--machine']); + +const DD_SAFE_DEVICE_TARGETS: ReadonlySet = new Set([ + '/dev/null', + '/dev/zero', + '/dev/full', + '/dev/random', + '/dev/urandom', + '/dev/stdin', + '/dev/stdout', + '/dev/stderr', +]); + +type DangerousVerdict = + | { readonly kind: 'dangerous'; readonly command: string } + | { readonly kind: 'unanalyzable' }; + +export class DangerousCommandAskPermissionPolicyService implements PermissionPolicy { + readonly name = 'dangerous-command-ask'; + + constructor( + @IBashParserService private readonly bashParser: IBashParserService, + @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, + @IConfigService private readonly config: IConfigService, + ) {} + + evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { + if (!isDangerousCommandGuardEnabled(this.config)) return undefined; + if (context.toolCall.name !== 'Bash') return undefined; + const command = bashCommandText(context.args); + const verdict = + command === undefined + ? ({ kind: 'unanalyzable' } as const) + : analyzeSource(command, 0, (source) => + this.bashParser.parse(source, PARSE_OPTIONS), + ); + if (verdict === undefined) return undefined; + const auto = this.modeService.mode === 'auto'; + if (verdict.kind === 'dangerous') { + if (auto) { + return { + kind: 'deny', + reason: { dangerous_command: verdict.command }, + message: `Bash command '${verdict.command}' is blocked in auto permission mode because it is considered dangerous. Ask the user to switch permission mode or run it themselves.`, + }; + } + return { kind: 'ask', reason: { dangerous_command: verdict.command } }; + } + if (auto) { + return { + kind: 'deny', + reason: { unanalyzable_command: true }, + message: + 'This Bash command could not be analyzed and is blocked in auto permission mode. Rewrite it with a literal command name and arguments, or ask the user to run it themselves.', + }; + } + return { kind: 'ask', reason: { unanalyzable_command: true } }; + } +} + +function bashCommandText(args: unknown): string | undefined { + if (typeof args !== 'object' || args === null) return undefined; + const command = (args as { readonly command?: unknown }).command; + return typeof command === 'string' ? command : undefined; +} + +function analyzeSource( + source: string, + depth: number, + parse: (source: string) => BashParseResult, +): DangerousVerdict | undefined { + const parsed = parse(source); + if (!parsed.ok || parsed.hasError) return { kind: 'unanalyzable' }; + const commands: BashSyntaxNode[] = []; + collectCommands(parsed.root, commands); + for (const command of commands) { + const verdict = analyzeCommand(command, depth, parse); + if (verdict !== undefined) return verdict; + } + return undefined; +} + +function collectCommands(node: BashSyntaxNode, out: BashSyntaxNode[]): void { + if (node.type === 'command') out.push(node); + for (const child of node.children) collectCommands(child, out); +} + +function analyzeCommand( + command: BashSyntaxNode, + depth: number, + parse: (source: string) => BashParseResult, +): DangerousVerdict | undefined { + const nameIndex = command.children.findIndex((child) => child.type === 'command_name'); + const nameNode = nameIndex >= 0 ? command.children[nameIndex] : undefined; + const nameWord = nameNode?.children.find((child) => child.isNamed); + const rawName = nameWord === undefined ? undefined : literalText(nameWord); + if (rawName === undefined || rawName.length === 0) return { kind: 'unanalyzable' }; + const args: string[] = []; + let dropped = false; + for (const child of command.children.slice(nameIndex + 1)) { + if (SKIPPED_COMMAND_CHILDREN.has(child.type)) continue; + const value = literalText(child); + if (value === undefined) { + dropped = true; + } else if (value.length > 0) { + args.push(value); + } + } + return analyzeInvocation(normalizeCommandName(rawName), args, dropped, depth, parse); +} + +function analyzeInvocation( + name: string, + args: readonly string[], + dropped: boolean, + depth: number, + parse: (source: string) => BashParseResult, +): DangerousVerdict | undefined { + if (PRIVILEGE_WRAPPERS.has(name)) { + const rest = dropLeadingOptions(args, PRIVILEGE_VALUE_OPTIONS); + const inner = rest[0]; + if (inner === undefined) return dropped ? { kind: 'unanalyzable' } : undefined; + return analyzeInvocation(normalizeCommandName(inner), rest.slice(1), dropped, depth, parse); + } + if (LAUNCH_WRAPPERS.has(name)) { + if (name === 'command') { + for (const arg of args) { + if (arg === '--') break; + if (arg === '-') continue; + if (!arg.startsWith('-')) break; + if (/[vV]/.test(arg)) return undefined; + } + } + const rest = dropLaunchWrapperOperands(name, args); + const inner = rest[0]; + if (inner === undefined) return dropped ? { kind: 'unanalyzable' } : undefined; + return analyzeInvocation(normalizeCommandName(inner), rest.slice(1), dropped, depth, parse); + } + if (NESTED_SHELLS.has(name)) { + let payloadIndex = -1; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (arg === '--') break; + if (/^-[a-zA-Z]+$/.test(arg)) { + if (arg.includes('c')) payloadIndex = i + 1; + } else { + break; + } + } + if (payloadIndex < 0) return dropped ? { kind: 'unanalyzable' } : undefined; + const payload = args[payloadIndex]; + if (payload === undefined || depth >= MAX_NESTED_SHELL_DEPTH) { + return { kind: 'unanalyzable' }; + } + return analyzeSource(payload, depth + 1, parse); + } + if (name === 'eval') { + if (args.length === 0) return dropped ? { kind: 'unanalyzable' } : undefined; + if (dropped || depth >= MAX_NESTED_SHELL_DEPTH) return { kind: 'unanalyzable' }; + return analyzeSource(args.join(' '), depth + 1, parse); + } + if (name === 'busybox') { + const applet = args[0]; + if (applet === undefined || applet.startsWith('-')) { + return dropped ? { kind: 'unanalyzable' } : undefined; + } + return analyzeInvocation(normalizeCommandName(applet), args.slice(1), dropped, depth, parse); + } + if (SIMPLE_DANGEROUS_COMMANDS.has(name) || name.startsWith('mkfs.')) { + return { kind: 'dangerous', command: name }; + } + if (name === 'init' || name === 'telinit') { + if (args.some((arg) => arg === '0' || arg === '6')) { + return { kind: 'dangerous', command: name }; + } + return dropped ? { kind: 'unanalyzable' } : undefined; + } + if (name === 'systemctl') { + const subcommand = dropLeadingOptions(args, SYSTEMCTL_VALUE_OPTIONS)[0]; + if (subcommand !== undefined && SYSTEMCTL_DANGEROUS_SUBCOMMANDS.has(subcommand)) { + return { kind: 'dangerous', command: `systemctl ${subcommand}` }; + } + return dropped ? { kind: 'unanalyzable' } : undefined; + } + if (name === 'dd') { + for (const arg of args) { + if (!arg.startsWith('of=')) continue; + const target = arg.slice('of='.length); + if (target.startsWith('/dev/') && !DD_SAFE_DEVICE_TARGETS.has(target)) { + return { kind: 'dangerous', command: 'dd' }; + } + } + return dropped ? { kind: 'unanalyzable' } : undefined; + } + if (name === 'rm') { + let recursive = false; + let force = false; + for (const arg of args) { + if (arg === '--') break; + if (arg === '--recursive') { + recursive = true; + } else if (arg === '--force') { + force = true; + } else if (/^-[a-zA-Z]+$/.test(arg)) { + if (/[rR]/.test(arg)) recursive = true; + if (arg.includes('f')) force = true; + } + } + if (recursive && force) return { kind: 'dangerous', command: 'rm -rf' }; + return dropped ? { kind: 'unanalyzable' } : undefined; + } + return undefined; +} + +function normalizeCommandName(raw: string): string { + let name = raw; + const separator = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')); + if (separator >= 0) name = name.slice(separator + 1); + name = name.toLowerCase(); + if (name.endsWith('.exe')) name = name.slice(0, -'.exe'.length); + return name; +} + +function dropLeadingOptions(args: readonly string[], valueOptions: ReadonlySet): string[] { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (arg === '--') return args.slice(i + 1); + if (arg === '-' || !arg.startsWith('-')) return args.slice(i); + if (!arg.includes('=') && valueOptions.has(arg)) i += 1; + } + return []; +} + +function dropLaunchWrapperOperands(name: string, args: readonly string[]): string[] { + let rest = dropLeadingOptions(args, WRAPPER_VALUE_OPTIONS); + if (name === 'env') { + let i = rest[0] === '-' ? 1 : 0; + while (i < rest.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(rest[i]!)) i += 1; + rest = rest.slice(i); + } + return rest; +} + +function literalText(node: BashSyntaxNode): string | undefined { + switch (node.type) { + case 'word': { + const raw = node.text; + if (UNSAFE_OPERAND.test(raw)) return undefined; + const unescaped = raw.replaceAll(/\\(.)/gs, '$1'); + return UNSAFE_OPERAND.test(unescaped) ? undefined : unescaped; + } + case 'number': + return node.text; + case 'raw_string': { + if (node.text.length < 2) return undefined; + const value = node.text.slice(1, -1); + return UNSAFE_OPERAND.test(value) ? undefined : value; + } + case 'string': { + let value = ''; + for (const child of node.children) { + if (child.type === 'string_content') { + value += child.text; + } else if (child.isNamed) { + return undefined; + } + } + return UNSAFE_OPERAND.test(value) ? undefined : value; + } + default: + return undefined; + } +} diff --git a/packages/agent-core-v2/src/agent/permissionRules/configSection.ts b/packages/agent-core-v2/src/agent/permissionRules/configSection.ts index 8598901b4f9..f37a0311068 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/configSection.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/configSection.ts @@ -1,5 +1,11 @@ import { z } from 'zod'; +import { + type EnvBindings, + envBindings, + stripEnvBoundFields, + type IConfigService, +} from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; import { cloneRecord, @@ -31,10 +37,37 @@ export const PermissionRuleSchema = z.object({ export const PermissionConfigSchema = z.object({ rules: z.array(PermissionRuleSchema).optional(), + dangerousCommandGuard: z.boolean().optional(), }); export type PermissionConfig = z.infer; +export const DANGEROUS_COMMAND_GUARD_ENV = 'KIMI_CODE_DANGEROUS_COMMAND_GUARD'; + +function parseDangerousCommandGuardEnv(raw: string): boolean | undefined { + if (raw === 'true') return true; + if (raw === 'false') return false; + return undefined; +} + +export const permissionEnvBindings: EnvBindings = envBindings( + PermissionConfigSchema, + { + dangerousCommandGuard: { + env: DANGEROUS_COMMAND_GUARD_ENV, + parse: parseDangerousCommandGuardEnv, + }, + }, +); + +export const stripPermissionEnv = stripEnvBoundFields(permissionEnvBindings); + +export function isDangerousCommandGuardEnabled(config: IConfigService): boolean { + return ( + config.get(PERMISSION_SECTION)?.dangerousCommandGuard ?? true + ); +} + function isValidPermissionPattern(pattern: string): boolean { try { parsePermissionPattern(pattern); @@ -52,7 +85,12 @@ export const permissionFromToml = (rawSnake: unknown): unknown => { appendPermissionRules(rules, raw['deny'], 'deny'); appendPermissionRules(rules, raw['allow'], 'allow'); appendPermissionRules(rules, raw['ask'], 'ask'); - return rules.length > 0 ? { rules } : {}; + const out: Record = {}; + if (rules.length > 0) out['rules'] = rules; + if (raw['dangerousCommandGuard'] !== undefined) { + out['dangerousCommandGuard'] = raw['dangerousCommandGuard']; + } + return out; }; function appendPermissionRules( @@ -107,4 +145,6 @@ export const permissionToToml = (value: unknown, rawSnake: unknown): unknown => registerConfigSection(PERMISSION_SECTION, PermissionConfigSchema, { fromToml: permissionFromToml, toToml: permissionToToml, + env: permissionEnvBindings, + stripEnv: stripPermissionEnv, }); diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index 9b43c978c87..809daa5eaac 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -21,6 +21,7 @@ export interface HostArgs { readonly requestHeaders: Readonly>; readonly displayName?: string; readonly replyStyleGuide?: string; + readonly nonInteractive?: boolean; } export interface HostArgsInput { @@ -29,6 +30,7 @@ export interface HostArgsInput { readonly requestHeaders?: Readonly>; readonly displayName?: string; readonly replyStyleGuide?: string; + readonly nonInteractive?: boolean; } export function resolveHostArgs(input: HostArgsInput | undefined): HostArgs { @@ -38,6 +40,7 @@ export function resolveHostArgs(input: HostArgsInput | undefined): HostArgs { requestHeaders: input?.requestHeaders ?? {}, displayName: input?.displayName, replyStyleGuide: input?.replyStyleGuide, + nonInteractive: input?.nonInteractive, }; } diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts index 67fbde98848..9491bc626bd 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts @@ -26,6 +26,11 @@ import { } from '#/agent/permissionRules/permissionRules'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; +import { IConfigService } from '#/app/config/config'; +import { PERMISSION_SECTION } from '#/agent/permissionRules/configSection'; +import { IBashParserService } from '#/app/bashParser/bashParser'; +import { BashParserService } from '#/app/bashParser/bashParserService'; +import { IBootstrapService, type HostArgs } from '#/app/bootstrap/bootstrap'; import { IGitService } from '#/app/git/git'; import { findGitWorkTree } from '#/app/git/workTree'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -47,6 +52,8 @@ describe('AgentPermissionPolicyService chain', () => { let rules: PermissionRule[]; let sessionApprovalRulePatterns: string[]; let workspace: ReturnType; + let hostArgs: HostArgs; + let dangerousCommandGuardEnabled: boolean; beforeEach(() => { disposables = new DisposableStore(); @@ -54,9 +61,23 @@ describe('AgentPermissionPolicyService chain', () => { rules = []; sessionApprovalRulePatterns = []; workspace = workspaceStub('/workspace'); + hostArgs = { requestHeaders: {}, nonInteractive: false }; + dangerousCommandGuardEnabled = true; ix = createServices(disposables, { additionalServices: (reg) => { reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode)); + reg.definePartialInstance(IBootstrapService, { + get args() { + return hostArgs; + }, + }); + reg.definePartialInstance(IConfigService, { + get: ((section: string) => + section === PERMISSION_SECTION && !dangerousCommandGuardEnabled + ? { dangerousCommandGuard: false } + : undefined) as IConfigService['get'], + onDidSectionChange: (() => ({ dispose: () => {} })) as IConfigService['onDidSectionChange'], + }); reg.defineInstance( IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' }), @@ -98,6 +119,7 @@ describe('AgentPermissionPolicyService chain', () => { }); reg.defineInstance(ITelemetryService, recordingTelemetry([])); reg.definePartialInstance(IGitService, { findWorkTree: async () => null }); + reg.define(IBashParserService, BashParserService); reg.define(IAgentPermissionPolicyService, AgentPermissionPolicyService); }, strict: true, @@ -198,6 +220,212 @@ describe('AgentPermissionPolicyService chain', () => { }); }); + it.each(['manual', 'yolo'] as const)( + 'asks for shutdown in %s mode', + async (currentMode) => { + mode = currentMode; + + await expect(evaluate({ + toolName: 'Bash', + args: { command: 'shutdown -h now', timeout: 60 }, + })).resolves.toMatchObject({ + policyName: 'dangerous-command-ask', + result: { kind: 'ask', reason: { dangerous_command: 'shutdown' } }, + }); + }, + ); + + it.each([ + ['shutdown -h now', 'shutdown'], + ['reboot', 'reboot'], + ['rm -rf /tmp/build', 'rm -rf'], + ['dd if=/dev/zero of=/dev/sda bs=1M', 'dd'], + ] as const)('denies `%s` in auto mode', async (command, matched) => { + mode = 'auto'; + + await expect(evaluate({ + toolName: 'Bash', + args: { command, timeout: 60 }, + })).resolves.toMatchObject({ + policyName: 'dangerous-command-ask', + result: { kind: 'deny', reason: { dangerous_command: matched } }, + }); + }); + + it.each([ + ['sudo reboot', 'reboot'], + ['sudo -u root reboot', 'reboot'], + ['/sbin/poweroff', 'poweroff'], + ['echo ok && shutdown now', 'shutdown'], + ['if halt; then echo x; fi', 'halt'], + ['echo $(reboot)', 'reboot'], + ['init 0', 'init'], + ['telinit 6', 'telinit'], + ['mkfs.ext4 /dev/sda1', 'mkfs.ext4'], + ['wipefs -a /dev/sda', 'wipefs'], + ['dd if=/dev/zero of=/dev/sda bs=1M', 'dd'], + ['Restart-Computer -Force', 'restart-computer'], + ['Stop-Computer', 'stop-computer'], + ['bcdedit /set x y', 'bcdedit'], + ['diskpart /s script.txt', 'diskpart'], + ['format C:', 'format'], + ['SHUTDOWN /s /t 0', 'shutdown'], + ['shut\\down -h now', 'shutdown'], + ['systemctl poweroff', 'systemctl poweroff'], + ['systemctl --user reboot', 'systemctl reboot'], + ['bash -c "shutdown now"', 'shutdown'], + ['rm -rf /tmp/build', 'rm -rf'], + ['rm -fr dir', 'rm -rf'], + ['rm -r -f dir', 'rm -rf'], + ['rm -R --force dir', 'rm -rf'], + ['rm --recursive --force dir', 'rm -rf'], + ['rm -rfv dir', 'rm -rf'], + ['sudo rm -rf dir', 'rm -rf'], + ['sudo -u root rm --recursive --force dir', 'rm -rf'], + ['echo ok && rm -rf dir', 'rm -rf'], + ['env rm -rf dir', 'rm -rf'], + ['env FOO=bar rm -rf dir', 'rm -rf'], + ['env -i FOO=bar shutdown now', 'shutdown'], + ['nohup rm -rf dir', 'rm -rf'], + ['exec reboot', 'reboot'], + ['command reboot', 'reboot'], + ['builtin shutdown now', 'shutdown'], + ['nice -n 5 poweroff', 'poweroff'], + ['nice --adjustment=5 shutdown now', 'shutdown'], + ['busybox poweroff', 'poweroff'], + ['busybox rm -rf dir', 'rm -rf'], + ['eval "shutdown now"', 'shutdown'], + ['eval rm -rf dir', 'rm -rf'], + ['bash -lc "shutdown now"', 'shutdown'], + ['bash -c "env rm -rf dir"', 'rm -rf'], + ["bash -c 'eval \"shutdown now\"'", 'shutdown'], + ] as const)('asks for `%s` in yolo mode', async (command, matched) => { + mode = 'yolo'; + + await expect(evaluate({ + toolName: 'Bash', + args: { command, timeout: 60 }, + })).resolves.toMatchObject({ + policyName: 'dangerous-command-ask', + result: { kind: 'ask', reason: { dangerous_command: matched } }, + }); + }); + + it.each([ + 'init 3', + 'dd if=/dev/zero of=/dev/null bs=1M count=1', + 'echo shutdown', + 'systemctl status sshd', + 'bash -c "echo ok"', + 'rm -r dir', + 'rm -f file', + 'rm -i file', + 'rm --recursive dir', + 'rm --force file', + 'rm dir', + 'env FOO=bar echo ok', + 'command -v rm', + 'command echo ok', + 'nohup echo ok', + 'nice echo ok', + 'busybox --list', + 'eval "echo ok"', + ])('does not flag `%s` in auto mode', async (command) => { + mode = 'auto'; + + await expect(evaluate({ + toolName: 'Bash', + args: { command, timeout: 60 }, + })).resolves.toMatchObject({ + policyName: 'auto-mode-approve', + result: { kind: 'approve' }, + }); + }); + + it.each(['$CMD --force', 'bash -c "echo $HOME"', 'echo "unterminated'])( + 'asks for unanalyzable command `%s` in yolo mode', + async (command) => { + mode = 'yolo'; + + await expect(evaluate({ + toolName: 'Bash', + args: { command, timeout: 60 }, + })).resolves.toMatchObject({ + policyName: 'dangerous-command-ask', + result: { kind: 'ask', reason: { unanalyzable_command: true } }, + }); + }, + ); + + it.each(['$CMD --force', 'bash -c "echo $HOME"', 'env $FLAGS'])( + 'denies unanalyzable command `%s` in auto mode', + async (command) => { + mode = 'auto'; + + await expect(evaluate({ + toolName: 'Bash', + args: { command, timeout: 60 }, + })).resolves.toMatchObject({ + policyName: 'dangerous-command-ask', + result: { kind: 'deny', reason: { unanalyzable_command: true } }, + }); + }, + ); + + it('does not load the dangerous command policy for non-interactive hosts', async () => { + hostArgs = { ...hostArgs, nonInteractive: true }; + mode = 'auto'; + + await expect(evaluate({ + toolName: 'Bash', + args: { command: 'rm -rf /tmp/build', timeout: 60 }, + })).resolves.toMatchObject({ + policyName: 'auto-mode-approve', + result: { kind: 'approve' }, + }); + }); + + it('does not load the dangerous command policy when disabled by config', async () => { + dangerousCommandGuardEnabled = false; + mode = 'yolo'; + + await expect(evaluate({ + toolName: 'Bash', + args: { command: 'shutdown -h now', timeout: 60 }, + })).resolves.toMatchObject({ + policyName: 'yolo-mode-approve', + result: { kind: 'approve' }, + }); + }); + + it('does not let session approval history exempt dangerous commands', async () => { + sessionApprovalRulePatterns.push('Bash(shutdown -h now)'); + + await expect(evaluate({ + toolName: 'Bash', + args: { command: 'shutdown -h now', timeout: 60 }, + })).resolves.toMatchObject({ + policyName: 'dangerous-command-ask', + result: { kind: 'ask' }, + }); + }); + + it('keeps deny rules above dangerous command ask', async () => { + rules.push({ + decision: 'deny', + scope: 'user', + pattern: 'Bash(shutdown *)', + }); + + await expect(evaluate({ + toolName: 'Bash', + args: { command: 'shutdown -h now', timeout: 60 }, + })).resolves.toMatchObject({ + policyName: 'user-configured-deny', + result: { kind: 'deny' }, + }); + }); + it.each(['AgentSwarm', 'EnterPlanMode', 'ExitPlanMode', 'CreateGoal'] as const)( 'approves %s through the default tool allowlist in manual mode', async (toolName) => { @@ -227,6 +455,13 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { ix = createServices(disposables, { additionalServices: (reg) => { reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode)); + reg.definePartialInstance(IBootstrapService, { + args: { requestHeaders: {}, nonInteractive: false }, + }); + reg.definePartialInstance(IConfigService, { + get: (() => undefined) as IConfigService['get'], + onDidSectionChange: (() => ({ dispose: () => {} })) as IConfigService['onDidSectionChange'], + }); reg.defineInstance( IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' }), @@ -267,6 +502,7 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { reg.definePartialInstance(IGitService, { findWorkTree: (cwd: string) => findGitWorkTree(hostFs, cwd), }); + reg.define(IBashParserService, BashParserService); reg.define(IAgentPermissionPolicyService, AgentPermissionPolicyService); }, strict: true, diff --git a/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts b/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts index 0332dc1625a..e1f0fc23e2c 100644 --- a/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceMcp/workspaceMcp.test.ts @@ -701,7 +701,7 @@ describe('WorkspaceMcpService', () => { await oauthScheduler.advanceBy(30_000); expect(reconnectAndJoin).toHaveBeenCalledWith('notion'); expect(authServer.counts.refresh).toBe(1); - }); + }, 15000); it('ignores a failed proactive refresh for a needs-auth entry', async () => { const authServer = await startRefreshFailingServer(); @@ -723,7 +723,7 @@ describe('WorkspaceMcpService', () => { await oauthScheduler.advanceBy(30_000); expect(events.some((event) => event.type === 'refresh-failed')).toBe(true); expect(reconnectAndJoin).not.toHaveBeenCalled(); - }); + }, 15000); it('does not reconnect a disabled entry when tokens are saved', async () => { const service = createService(); diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index c4be746e495..817bae4d29f 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -97,7 +97,8 @@ async function makeTempDir(prefix: string): Promise { * defaultPlanMode/extraSkillDirs/mergeAllAvailableSkills — see the v2 * `configSection.ts` registrations) and the env-binding pass, which * materializes `{}` for every section that declares env bindings - * (thinking/services/loopControl/background/mcp) even with no env set. + * (thinking/services/loopControl/background/mcp/permission) even with no + * env set. */ const V2_INJECTED_SECTION_DEFAULTS: Record = { models: {}, @@ -107,6 +108,7 @@ const V2_INJECTED_SECTION_DEFAULTS: Record = { loopControl: {}, background: {}, mcp: {}, + permission: {}, defaultPlanMode: false, mergeAllAvailableSkills: true, extraSkillDirs: [],