Skip to content

Bug: Command substitution denial is inconsistently applied and opaque #4093

@huww98

Description

@huww98

Bug: Command substitution denial is inconsistently applied and opaque

What happened?

When the agent runs a shell command containing command substitution ($(), backticks, <(), >()), the security check that should deny such commands is inconsistently applied:

  • In some cases (compound commands where one sub-command matches an existing allow rule), the command is hard-denied with an opaque error message that does not mention command substitution. The user cannot override this even in YOLO mode, and the agent cannot understand why the command was rejected.
  • In other cases (simple commands with no relevant permission rules), the command substitution check is entirely bypassed — the command is treated like any other non-read-only command and the user can approve it without any security warning.

What did you expect to happen?

The command substitution security check should be applied consistently — either always deny (with a clear reason), or always ask the user for confirmation (with a clear warning about command substitution). The current behavior where the check is sometimes applied and sometimes bypassed based on whether the user has configured permission rules is clearly unintended.

Additionally, when a command is denied due to command substitution, the error message should clearly state the reason, so both the user and the agent can understand and adapt.

Reproduction scenario

The 'deny' behavior is triggered when the PermissionManager has "relevant" rules for the command (i.e., some rules could potentially apply to the command context), but no rule actually matches the specific command containing command substitution. This most commonly happens with compound commands where one sub-command matches an existing allow rule (making the rules "relevant"), while another sub-command contains command substitution but doesn't match any rule.

Test command (in YOLO mode):

echo hello && python3 -c "print($(echo hello))"

Why this triggers the deny:

  • The compound command is split into sub-commands: echo hello and python3 -c "print($(echo hello))"
  • echo hello matches an existing Bash(echo *) allow rule → the PermissionManager considers its rules "relevant" to this command context
  • python3 -c "print($(echo hello))" doesn't match any rule → evaluateSingle() returns 'default'
  • For 'default' results on shell commands, resolveDefaultPermission() is called, which detects command substitution and returns 'deny'
  • The most restrictive result ('deny') wins over 'allow' → the entire compound command is denied

What the agent sees (returned as tool error):

Tool "run_shell_command" is denied by permission rules.

What the user sees (rendered in the TUI):

  ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
  │ x  Shell {"command":"echo hello && python3 -c \"print($(echo hello))\"","description":"Test compound command with substitution in YOLO mode"}  │
  │                                                                                                                                                │
  │    Tool "run_shell_command" is denied by permission rules.                                                                                     │
  ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Note: a simple command like python3 -c "print($(echo hello))" alone would NOT trigger the deny — it would get 'ask' (because the PermissionManager has no relevant rules for python3 and skips evaluation entirely). This inconsistency makes the behavior especially confusing.

Anything else we need to know?

Root cause analysis:

The inconsistency comes from the L3→L4 permission evaluation flow:

  • L3 (ShellToolInvocation.getDefaultPermission()): Does NOT check for command substitution. AST analysis marks commands with substitution as non-read-only → returns 'ask'.
  • L4 (PermissionManager.evaluate()): When hasRelevantRules() returns true (some rule could apply), evaluate() is called. For sub-commands where no rule matches ('default'), resolveDefaultPermission() detects command substitution → 'deny'. But when hasRelevantRules() returns false (no rules could apply at all), PM evaluation is skipped entirely and the command keeps its L3 'ask' — the command substitution check is never reached.
  • L5: YOLO mode auto-approves 'ask' but does NOT override 'deny'.

This means the command substitution security check only fires when the PermissionManager is invoked (hasRelevantRules=true), but is completely bypassed when no rules are relevant. The hasRelevantRules() gate was likely intended as a performance optimization, but it inadvertently creates a security inconsistency.

Suggested fix:

  • Change resolveDefaultPermission() to return 'ask' instead of 'deny' for command substitution, ensuring consistent behavior regardless of the user's rule configuration.
  • Add a specific warning about command substitution in the confirmation prompt, so users can make informed decisions.
  • Include the denial reason in the error message when a command substitution command is denied, so the agent can understand and adapt.

中文

发生了什么?

当 Agent 执行包含命令替换($()、反引号、<()>())的 shell 命令时,应该拒绝此类命令的安全检查不一致地执行:

  • 某些情况下(复合命令中一个子命令匹配已有的 allow 规则),命令被硬拒绝,错误消息不透明且不提及命令替换。用户即使在 YOLO 模式下也无法覆盖,Agent 也无法理解为何被拒绝。
  • 其他情况下(没有相关权限规则的简单命令),命令替换检查被完全绕过——命令被当作普通的非只读命令处理,用户可以在无安全警告的情况下批准。

你期望发生什么?

命令替换安全检查应该一致地执行——要么始终拒绝(附带明确原因),要么始终要求用户确认(附带命令替换安全警告)。当前行为中,检查有时执行有时绕过,取决于用户是否配置了权限规则,这显然不是设计意图。

此外,当命令因命令替换被拒绝时,错误消息应明确说明原因,让用户和 Agent 都能理解并调整。

复现场景

'deny' 行为在 PermissionManager 有"相关"规则(即某些规则可能适用于该命令上下文),但没有规则实际匹配包含命令替换的特定命令时触发。这最常见于复合命令——其中一个子命令匹配已有的 allow 规则(使规则变得"相关"),另一个子命令包含命令替换但不匹配任何规则。

测试命令(YOLO 模式下):

echo hello && python3 -c "print($(echo hello))"

触发原因:

  • 复合命令被拆分为子命令:echo hellopython3 -c "print($(echo hello))"
  • echo hello 匹配已有的 Bash(echo *) allow 规则 → PermissionManager 认为它的规则与此命令上下文"相关"
  • python3 -c "print($(echo hello))" 不匹配任何规则 → evaluateSingle() 返回 'default'
  • 对于 shell 命令的 'default' 结果,调用 resolveDefaultPermission(),检测到命令替换并返回 'deny'
  • 最严格的结果('deny')胜过 'allow' → 整个复合命令被拒绝

Agent 看到的内容(作为工具错误返回):

Tool "run_shell_command" is denied by permission rules.

用户看到的内容(TUI 渲染):

  ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
  │ x  Shell {"command":"echo hello && python3 -c \"print($(echo hello))\"","description":"Test compound command with substitution in YOLO mode"}  │
  │                                                                                                                                                │
  │    Tool "run_shell_command" is denied by permission rules.                                                                                     │
  ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

注意:单独运行 python3 -c "print($(echo hello))" 不会触发拒绝——它会得到 'ask'(因为 PermissionManager 对 python3 没有相关规则,会跳过评估)。这种不一致性使行为特别令人困惑。

补充信息

根本原因分析:

不一致性来自 L3→L4 权限评估流程:

  • L3ShellToolInvocation.getDefaultPermission()):不检查命令替换。AST 分析将包含替换的命令标记为非只读 → 返回 'ask'
  • L4PermissionManager.evaluate()):当 hasRelevantRules() 返回 true(某些规则可能适用)时,调用 evaluate()。对于没有规则匹配的子命令('default'),resolveDefaultPermission() 检测命令替换 → 'deny'。但当 hasRelevantRules() 返回 false(没有任何规则可能适用)时,PM 评估被完全跳过,命令保持 L3 的 'ask'——命令替换检查永远不会被执行。
  • L5:YOLO 模式自动批准 'ask',但不覆盖 'deny'

这意味着命令替换安全检查只在 PermissionManager 被调用(hasRelevantRules=true)时触发,而在没有相关规则时被完全绕过。hasRelevantRules() 门控可能原本是性能优化,但它无意中造成了安全不一致。

建议修复:

  • resolveDefaultPermission() 对命令替换的返回值从 'deny' 改为 'ask',确保无论用户的规则配置如何,行为都一致。
  • 在确认提示中添加关于命令替换的特定警告,让用户做出知情决策。
  • 在命令替换命令被拒绝时,在错误消息中包含拒绝原因,让 Agent 能理解并调整。

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions