Skip to content

security: static loader environment assignments can bypass concrete Bash allow rules #10197

Description

@SLP-DEV1

Summary

A saved concrete Bash(...) allow rule can be matched after Qwen strips leading environment assignments even when those assignments change the runtime semantics of the allowed program and cause additional code execution.

This does not require command substitution, backticks, $(), or another shell-side execution primitive.

For example, with the concrete allow rule:

Bash(npm --version)

this command can be normalized for permission matching to npm --version:

NODE_OPTIONS=--require=/tmp/qwen-preload.cjs npm --version

while Node/npm still interprets NODE_OPTIONS and loads /tmp/qwen-preload.cjs before the trusted main command runs.

The same underlying issue applies to other environment variables whose values alter executable startup behavior, such as Git's GIT_CONFIG_* environment configuration.

Harmless reproduction: NODE_OPTIONS

Assume the user has explicitly allowed only:

Bash(npm --version)

Create a harmless preload file:

// /tmp/qwen-preload.cjs
require('fs').writeFileSync('/tmp/qwen-node-options-poc', 'executed');

Then run:

NODE_OPTIONS=--require=/tmp/qwen-preload.cjs npm --version

Runtime verification outside Qwen shows that npm still succeeds while the preload runs first:

npm stdout: 10.9.2
returncode: 0
NODE_OPTIONS side effect: true
marker: executed

Expected behavior

Because the leading environment assignment changes the startup behavior of the allowed executable and introduces additional code execution, Qwen should require confirmation rather than treating it as equivalent to plain:

npm --version

Actual behavior

matchesCommandPattern() normalizes the command through stripLeadingVariableAssignments() before comparing it with the saved Bash rule.

The helper removes every leading token matching:

^[A-Za-z_][A-Za-z0-9_]*=

so the permission matcher can reduce:

NODE_OPTIONS=--require=/tmp/qwen-preload.cjs npm --version

to:

npm --version

and match the saved concrete rule.

Second reproduction: Git environment configuration

This is not Node-specific. Git accepts runtime configuration through environment variables.

A harmless executable fsmonitor hook can be prepared, for example:

#!/bin/sh
printf 'executed' > /tmp/qwen-git-env-poc
printf '2\n'

Then:

GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0=core.fsmonitor \
GIT_CONFIG_VALUE_0=/tmp/fsmonitor.sh \
git status --short

was verified to execute the configured program while git status itself still returns successfully:

git returncode: 0
git stdout:
git stderr:
core.fsmonitor side effect: true
marker: executed

A saved concrete rule such as:

Bash(git status --short)

therefore does not fully describe the execution Qwen is authorizing if arbitrary leading environment assignments are discarded before matching.

Root cause

matchesCommandPattern() calls stripLeadingVariableAssignments() before applying the saved command rule.

That normalization was introduced so benign prefixes such as:

PYTHONPATH=/tmp/lib python3 script.py

can still match a rule for the underlying command.

However, the normalization assumes that a syntactically static NAME=value assignment is semantically inert. That is not generally true.

Environment variables can be interpreted by the launched executable, runtime, loader, or shell startup machinery and can cause code execution before or during the trusted command.

This is especially important because the shell AST safety layer already treats commands with environment assignments conservatively (unknown / confirmation path), but an explicit Bash allow rule can bypass that default analysis once the normalized command matches.

The virtual shell-operation analysis also strips leading environment-assignment tokens before analyzing the main command, so it does not add a separate safety floor for these startup semantics.

Why this matters

Concrete allow rules are intended to represent an exact trust decision by the user. In AUTO mode, broad interpreter/package-manager allow rules may be removed as dangerous, but concrete rules such as Bash(npm --version) or Bash(npm test) are intentionally preserved because the user explicitly trusted that command.

Stripping arbitrary environment assignments widens that trust boundary from:

run this exact command

to effectively:

run this command under arbitrary process/runtime environment overrides

which can include additional executable code.

Relation to #10192

This is related to, but distinct from, #10192.

#10192 requires shell execution semantics inside the assignment itself, for example:

X=`command` npm --version

The obvious fix there is to preserve matching for simple static NAME=value prefixes while rejecting assignments containing command/process substitution.

That is insufficient here: the assignments in these reproductions are completely static strings. The extra execution happens because the target runtime/application interprets their values.

For example:

NODE_OPTIONS=--require=/tmp/qwen-preload.cjs npm --version

contains no shell command substitution at all.

Suggested fix

Do not treat arbitrary environment assignments as permission-neutral when matching concrete Bash allow rules.

Possible approaches:

  1. Keep env-prefixed commands at ask unless the assignment names are explicitly known to be safe for the target command.
  2. Maintain a denylist of execution-sensitive environment variables as a defense-in-depth measure, including runtime/loader/startup variables, but avoid relying on a denylist alone because executable-specific environment semantics are open-ended.
  3. More conservatively, require the saved rule to include the environment prefix when environment assignments are present instead of stripping them from the trust decision.
  4. Preserve the current normalization only for rules or modes where widening the command identity is explicitly intended, not for concrete auto-approval grants.

Regression tests should cover at least:

Bash(npm --version)

against:

npm --version                                      # allow
FOO=bar npm --version                              # policy-dependent
NODE_OPTIONS=--require=/tmp/preload.cjs npm --version  # must not silently auto-allow

and an equivalent GIT_CONFIG_* case.

Severity

I believe this is High / P1 because it crosses the confirmation boundary of a saved concrete Bash allow rule and permits additional code execution without changing the trusted main command.

It is not zero-precondition: a matching saved Bash allow rule must already exist.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    category/securitySecurity and privacypriority/P1High priority issuescope/shellShell command executionscope/vulnerabilitySecurity vulnerabilitiesstatus/ready-for-humanSpecified but requires human judgment to implement; not suitable for an autonomous agenttype/bugSomething isn't working as expected

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions