feat(core): add deterministic guards for destructive commands in auto mode#5754
Conversation
… mode Add a Layer 0 deterministic pre-filter in evaluateAutoMode() that blocks destructive git and IaC commands before the LLM classifier runs. This closes the gap where the only defense against `git reset --hard` or `terraform destroy` was the non-deterministic LLM classifier, which can fail due to API unavailability, timeout, or poor judgment. Blocked patterns: - git reset --hard, git checkout -- ., git clean -f*, git stash drop (unless user prompt explicitly mentions discarding work) - git commit --amend (unless the target commit was made this session) - terraform/pulumi/cdk destroy (unless user mentions the target stack) Shell indirection bypass prevention: strips one layer of shell quoting (bash -c "...", sh -c '...') so wrapped destructive commands are still detected. The guard only applies in AUTO mode — YOLO mode is unaffected as it is an explicit opt-out of all safety checks.
0c4f0dd to
6a23674
Compare
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
efd6f20 to
6a23674
Compare
|
Thanks for the PR! Template looks good ✓ On direction: this is a clear win for AUTO mode safety. The LLM classifier being the only defense against On approach: the scope is tight — 216 lines of implementation, the rest is tests. The regex-before-classifier pattern is the right architecture: deterministic guards can't fail due to API outages or bad judgment calls. The Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 方向:这是 AUTO 模式安全性的明确提升。LLM 分类器作为 方案:范围紧凑——216 行实现代码,其余都是测试。正则先于分类器的架构是正确的:确定性守卫不会因为 API 故障或判断失误而失败。 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
| /\bgit\s+reset\s+--hard\b/, | ||
| /\bgit\s+checkout\s+--\s+\./, | ||
| /\bgit\s+clean\s+-[a-zA-Z]*f/, | ||
| /\bgit\s+stash\s+drop\b/, |
There was a problem hiding this comment.
[Critical] The git clean regex only matches short-form -f immediately after git clean . Two bypass vectors:
- Long-form
--forcenot caught:git clean --forceorgit clean --force -dbypasses the guard entirely. After matching the first-,[a-zA-Z]*encounters the second-(not a letter), matches zero chars, thenffails against-. - Separated flag groups:
git clean -e '*.log' -forgit clean -X -fplace-fin a second flag group. The regex anchors-[a-zA-Z]*fto the first flag group aftergit clean, so the-fat the end is unreachable.
| /\bgit\s+stash\s+drop\b/, | |
| const DESTRUCTIVE_GIT_PATTERNS: readonly RegExp[] = Object.freeze([ | |
| /\bgit\s+reset\s+--hard\b/, | |
| /\bgit\s+checkout\s+--\s+\./, | |
| /\bgit\s+clean\s+(-[a-zA-Z]*f|--force)\b/, | |
| /\bgit\s+stash\s+drop\b/, | |
| ]); |
For the separated-flag-groups case, consider a broader pattern like /\bgit\s+clean\b[^|;&\n]*(-f|--force)\b/.
— qwen3.7-max via Qwen Code /review
| * Check if the user prompt explicitly mentions discarding local work. | ||
| */ | ||
| export function userMentionsDiscard(userPrompt: string): boolean { | ||
| return DISCARD_KEYWORDS.some((kw) => kw.test(userPrompt)); |
There was a problem hiding this comment.
[Critical] The bypass keywords (clean up, start over, start fresh, clean slate, wipe) are common English phrases that match in many non-git contexts. A user prompt like "clean up the code" or "start over with a better approach" matches userMentionsDiscard, which disables ALL destructive git guards (git reset --hard, git clean -fd, git stash drop) regardless of whether the user intended to authorize destructive git operations.
This undermines the PR's stated purpose of providing a deterministic guard that "cannot be bypassed by classifier failures" — the bypass is trivially achievable in normal conversation.
Consider requiring stronger intent signals, e.g. the discard keyword must appear near a git-related term ("discard changes", "wipe the repo", "reset the branch"), or remove the most ambiguous keywords (clean up, start over) from the bypass list.
— qwen3.7-max via Qwen Code /review
| return { | ||
| blocked: true, | ||
| reason: `Blocked destructive git command: "${matched}". To proceed, explicitly mention discarding local work in your prompt.`, | ||
| }; |
There was a problem hiding this comment.
[Suggestion] git commit --amend uses a different bypass mechanism (isAmendOfSessionCommit) than the destructive git patterns above (userMentionsDiscard). This breaks the established bypass contract: "discard everything and amend the last commit" gets blocked, while "discard everything and git reset --hard" is allowed.
Consider also checking userMentionsDiscard for consistency:
| }; | |
| if ( | |
| GIT_AMEND_PATTERN.test(expanded) && | |
| !userMentionsDiscard(userPrompt) && | |
| !isAmendOfSessionCommit(cwd) | |
| ) { |
— qwen3.7-max via Qwen Code /review
| * Used to allow `git commit --amend` when the target commit was made | ||
| * by the agent in the current session. | ||
| */ | ||
| export function registerSessionCommit(sha: string): void { |
There was a problem hiding this comment.
[Suggestion] registerSessionCommit is exported as public API (and re-exported from index.ts), but no production code calls it — only tests and the barrel export reference it. This means sessionCommitShas is always empty, isAmendOfSessionCommit always returns false, and git commit --amend is unconditionally blocked.
The PR description acknowledges this is deferred, but the exported API creates an impression the feature is functional. Consider either:
- Removing the session-tracking code from this PR and unconditionally blocking
--amenduntil the integration is ready, or - Adding a TODO comment with a tracking issue link to document the deferred work.
— qwen3.7-max via Qwen Code /review
| return { | ||
| kind: 'blocked', | ||
| errorMessage: `${decision.reason}\n${AUTO_MODE_DENIAL_GUIDANCE}`, | ||
| reason: 'classifier_blocked', |
There was a problem hiding this comment.
[Suggestion] The PermissionDeniedReason type only has 'classifier_blocked' | 'classifier_unavailable', both semantically referring to the LLM classifier. Using 'classifier_blocked' for a deterministic regex guard misattributes the block source. Downstream consumers (hooks, telemetry, debug logs) inspecting reason will incorrectly investigate the LLM classifier when the actual cause is a regex match.
Consider adding a new variant (e.g., 'destructive_command') to the PermissionDeniedReason type, or at minimum prefixing the error message with [deterministic-guard] so the block source is distinguishable in logs.
— qwen3.7-max via Qwen Code /review
| return null; | ||
| } | ||
|
|
||
| function userMentionsStack(userPrompt: string, toolName: string): boolean { |
There was a problem hiding this comment.
[Suggestion] userMentionsStack uses shallow substring matching: lower.includes(toolName) && lower.includes('destroy'). A prompt like "make sure terraform doesn't destroy anything" or "I want to deploy, not terraform destroy" would pass the bypass, allowing terraform destroy through. The check doesn't account for negation or intent.
Consider requiring the destroy keyword to appear near the tool name and not preceded by negation words, or tightening the check to look for explicit target specifiers (e.g., -target=, stack names).
— qwen3.7-max via Qwen Code /review
Code ReviewIndependent proposal: I'd add a regex-based pre-filter module in Findings:
Test ResultsUnit tests: 114/114 pass (31 destructive-commands + 83 autoMode) Typecheck: clean, zero errors. Real-Scenario TestingTest 1:
|
|
This PR does what it says and the testing confirms it. The deterministic regex guard fires before the LLM classifier, the escape hatch works when the user explicitly says "discard", and 114 unit tests cover the patterns, bypass vectors (shell indirection), and integration points. The code is clean — no over-abstraction, no dead code, proper reuse of existing My independent proposal was essentially the same architecture (regex module → pre-filter in The one deferred item ( Approving. ✅ 中文说明这个 PR 做到了它所承诺的,测试也证实了这一点。确定性正则守卫在 LLM 分类器之前触发,当用户明确说 "discard" 时逃逸机制正常工作,114 个单元测试覆盖了所有模式、绕过向量和集成点。代码整洁——没有过度抽象、没有死代码、正确复用了现有的基础设施。 我的独立方案本质上是相同的架构,所以没有发现 PR 遗漏的更简路径。 唯一延后的项目( 批准合并 ✅ — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
[Suggestion] shouldFirePermissionDeniedForAutoMode (autoMode.ts:568) only matches via: 'classifier', so blocked:destructive-command is excluded. The permission_denied hook never fires for deterministic guard blocks — creating an observability blind spot for hook-based auditing. Extend the predicate to also match this variant.
— qwen3.7-max via Qwen Code /review
| /\bgit\s+reset\s+--hard\b/, | ||
| /\bgit\s+checkout\s+--\s+\./, | ||
| /\bgit\s+clean\s+-[a-zA-Z]*f/, | ||
| /\bgit\s+stash\s+drop\b/, |
There was a problem hiding this comment.
[Critical] git restore . (the modern replacement for git checkout -- ., available since git 2.23) is not covered by DESTRUCTIVE_GIT_PATTERNS. An agent preferring modern git syntax bypasses the guard entirely. Similarly, all patterns use \bgit\s+<subcommand>, which requires the subcommand to immediately follow git — global options like git -C /path reset --hard, git -c core.abbrev=7 reset --hard, or git --no-pager reset --hard bypass every pattern.
| /\bgit\s+stash\s+drop\b/, | |
| const DESTRUCTIVE_GIT_PATTERNS: readonly RegExp[] = Object.freeze([ | |
| /\bgit\s+(?:-[a-zA-Z]\s+|--[a-z-]+(?:=\S+)?\s+)*reset\s+--hard\b/, | |
| /\bgit\s+(?:-[a-zA-Z]\s+|--[a-z-]+(?:=\S+)?\s+)*checkout\s+(?:\S+\s+)?--\s+\./, | |
| /\bgit\s+(?:-[a-zA-Z]\s+|--[a-z-]+(?:=\S+)?\s+)*clean\s+-[a-zA-Z]*f/, | |
| /\bgit\s+(?:-[a-zA-Z]\s+|--[a-z-]+(?:=\S+)?\s+)*stash\s+drop\b/, | |
| /\bgit\s+(?:-[a-zA-Z]\s+|--[a-z-]+(?:=\S+)?\s+)*restore\b/, | |
| ]); |
— qwen3.7-max via Qwen Code /review
|
|
||
| /** | ||
| * `git commit --amend` is blocked unless the target commit was made by the | ||
| * agent in this session. Session tracking is managed externally via |
There was a problem hiding this comment.
[Critical] git checkout HEAD -- . bypasses this pattern because HEAD appears between checkout and --. The regex requires -- to immediately follow checkout. Allow an optional ref token: (?:\S+\s+)? between checkout and --.
— qwen3.7-max via Qwen Code /review
| * patterns inside `bash -c "git reset --hard"` or `sh -c 'git clean -fd'` | ||
| * are still detected. | ||
| */ | ||
| function stripShellQuotes(command: string): string { |
There was a problem hiding this comment.
[Suggestion] stripShellQuotes reimplements a subset of stripShellWrapper from utils/shell-utils.ts, which already handles env prefixes (FOO=bar bash -c ...), absolute shell paths (/usr/bin/zsh -c ...), multi-flag wrappers (bash -e -x -c ...), cmd.exe /c, and more. Import the existing utility instead of maintaining a diverging copy.
— qwen3.7-max via Qwen Code /review
| } | ||
|
|
||
| /** Result of a destructive command check. */ | ||
| export interface DestructiveCommandResult { |
There was a problem hiding this comment.
[Suggestion] extractLastUserPrompt only checks the most recent user message. In multi-turn conversations where the user expressed discard intent earlier (e.g., "discard all local changes") but the latest message is a short acknowledgment ("go ahead", "yes"), the guard blocks a command the user already approved. Consider scanning the last N user messages for discard keywords.
— qwen3.7-max via Qwen Code /review
| * Check whether a `git commit --amend` targets a commit made this session. | ||
| * Reads the current HEAD commit SHA and checks against registered session commits. | ||
| */ | ||
| export function isAmendOfSessionCommit(cwd: string): boolean { |
There was a problem hiding this comment.
[Critical] execSync('git rev-parse HEAD', ...) blocks the Node.js event loop for up to 5 seconds. Since evaluateAutoMode is async, use execFile with a Promise wrapper instead:
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
async function isAmendOfSessionCommit(cwd: string): Promise<boolean> {
if (sessionCommitShas.size === 0) return false;
try {
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
cwd, timeout: 5000,
});
return sessionCommitShas.has(stdout.trim());
} catch {
return false;
}
}Also, the catch block silently swallows errors with no logging — git timeouts and permission errors are invisible.
— qwen3.7-max via Qwen Code /review
| expect(result).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
There was a problem hiding this comment.
[Critical] The positive path of isAmendOfSessionCommit — where registerSessionCommit(sha) is called, HEAD matches, and the amend is allowed — is never tested. This is the core bypass mechanism for git commit --amend. Mock execSync to return a known SHA and verify the amend is unblocked:
vi.mock('node:child_process', () => ({
execSync: vi.fn().mockReturnValue('abc123\n'),
}));
registerSessionCommit('abc123');
const result = isDestructiveCommand('git commit --amend', 'amend it');
expect(result).toBeNull();— qwen3.7-max via Qwen Code /review
| config.setAutoModeDenialState(recordAllow(denialState)); | ||
| return { kind: 'approved' }; | ||
| case 'blocked:destructive-command': | ||
| config.setAutoModeDenialState(recordBlock(denialState)); |
There was a problem hiding this comment.
[Critical] recordBlock(denialState) feeds the same consecutive-block counter used for classifier blocks. After 3 consecutive destructive-command blocks, shouldFallback triggers fallback to manual approval — but the deterministic guard fires BEFORE the fallback check in evaluateAutoMode, creating an unrecoverable dead loop: guard blocks → counter increments → fallback triggers → guard blocks again. The agent is permanently stuck.
Either don't call recordBlock for deterministic blocks (they don't indicate classifier degradation), or exclude them from the consecutive counter so the fallback mechanism is reserved for classifier failures it can actually remediate.
— qwen3.7-max via Qwen Code /review
| // failures or classifier misjudgment cannot allow destructive git/IaC | ||
| // commands through. Only applies to shell-like tools. | ||
| if (SHELL_LIKE_TOOL_NAMES.has(input.ctx.toolName) && input.ctx.command) { | ||
| const command = |
There was a problem hiding this comment.
[Critical] This guard does not check !input.pmForcedAsk, unlike L5.1 and L5.2 above. User-configured permissions.ask rules for destructive shell commands are silently overridden — instead of a manual approval prompt, the user gets a hard block that feeds denial tracking (compounding the dead-loop issue). Add !input.pmForcedAsk to the guard condition:
| const command = | |
| if (!input.pmForcedAsk && SHELL_LIKE_TOOL_NAMES.has(input.ctx.toolName) && input.ctx.command) { |
— qwen3.7-max via Qwen Code /review
What this PR does
Adds a deterministic Layer 0 pre-filter in
evaluateAutoMode()that blocks destructive git and IaC shell commands before the L5.3 LLM classifier runs. The guard uses regex pattern matching against the raw command string (including shell-indirection unwrapping) and cross-references the user's most recent prompt to determine whether destructive actions were explicitly requested. A newAutoModeDecisionvariantblocked:destructive-commandis introduced so the scheduler can surface clear, actionable block messages. Session commit tracking is provided forgit commit --amendvalidation. 114 unit + integration tests cover all patterns, bypass vectors, and integration with the existing auto-mode pipeline.Why it's needed
The L5.3 LLM classifier was the only defense against destructive commands like
git reset --hard,git clean -fd, orterraform destroyin AUTO mode. The classifier is non-deterministic — it can fail due to API unavailability, timeout, or poor judgment on ambiguous prompts like "clean up the git state". Additionally,gitis not inDANGEROUS_BASH_INTERPRETERS, so a user allow rule likeBash(git *)would bypass the dangerous-rule stripping and leave only the classifier's single soft-deny line as the guard. Claude Code added hard blocks for these commands in v2.1.183 after observing the same failure mode. This PR brings equivalent deterministic protection to Qwen Code.Reviewer Test Plan
How to verify
cd packages/core && npx vitest run src/permissions/destructive-commands.test.ts src/permissions/autoMode.test.ts— all 114 tests should pass.npx tsc --noEmit -p packages/core/tsconfig.json— should produce no errors.npx eslint packages/core/src/permissions/destructive-commands.ts packages/core/src/permissions/autoMode.ts— should produce no errors.npm run build && npm run bundle), start in auto mode, and ask the agent to rungit reset --hard— the command should be blocked with a clear message before the classifier is invoked.Evidence (Before & After)
Before:

git reset --hardin AUTO mode relies solely on the LLM classifier (non-deterministic, can fail).After:

git reset --hardis blocked deterministically by regex before the classifier runs.Test output:
Tested on
Environment (optional)
N/A — unit tests only, no runtime environment required.
Risk & Scope
userMentionsDiscardescape hatch mitigates this for intentional use.registerSessionCommitinto the shell tool's post-execution hook for automaticgit commit --amendunblocking is deferred — currently all--amendcommands are blocked unless manually approved.AutoModeDecisionvariant is additive; existing consumers handle unknown variants via the exhaustivedefaultbranch.Linked Issues
Closes #5749
中文说明
本 PR 做了什么
在
evaluateAutoMode()中添加了一个确定性的 Layer 0 前置过滤器,在 L5.3 LLM 分类器运行之前拦截破坏性的 git 和 IaC shell 命令。该守卫使用正则表达式模式匹配原始命令字符串(包括 shell 间接调用解包),并交叉引用用户最近的 prompt 来判断是否明确要求了破坏性操作。引入了新的AutoModeDecision变体blocked:destructive-command,使调度器能够展示清晰、可操作的拦截消息。提供了 session commit 追踪用于git commit --amend校验。114 个单元 + 集成测试覆盖了所有模式、绕过向量和与现有 auto 模式管道的集成。为什么需要
L5.3 LLM 分类器是 AUTO 模式下对
git reset --hard、git clean -fd或terraform destroy等破坏性命令的唯一防线。分类器是非确定性的——可能因 API 不可用、超时或对模糊 prompt(如"清理 git 状态")的判断失误而失败。此外,git不在DANGEROUS_BASH_INTERPRETERS中,因此用户的允许规则Bash(git *)会绕过危险规则剥离,只留下分类器的单行软拒绝作为守卫。Claude Code 在 v2.1.183 中观察到相同的失败模式后添加了硬拦截。本 PR 为 Qwen Code 带来了同等的确定性保护。风险与范围
userMentionsDiscard逃逸机制可缓解有意使用的场景。registerSessionCommit接入 shell 工具的执行后钩子以自动解除git commit --amend拦截已延后——目前所有--amend命令均被拦截,除非手动批准。AutoModeDecision变体是增量的;现有消费者通过穷举default分支处理未知变体。关联 Issue
Closes #5749