CVE-2026-34430: The Regex That Thought It Understood a Shell
ByteDance DeerFlow's LocalSandboxProvider tried to fence an LLM's `bash` tool inside a virtual `/mnt/user-data` jail using a regular expression that scanned for absolute paths. Shells do not think in absolute paths, and the gap between "what the regex saw" and "what `sh -c` did" was a clean escape to the host.
ByteDance DeerFlow's LocalSandboxProvider tried to fence an LLM's bash tool inside a virtual /mnt/user-data jail using a regular expression that scanned for absolute paths. Shells do not think in absolute paths, and the gap between "what the regex saw" and "what sh -c did" was a clean escape to the host.
The advisory in plain English
DeerFlow is a LangGraph "super-agent" that gives models a real toolbox: file read/write, subagents, and a bash tool. When the sandbox is configured as LocalSandboxProvider, that bash tool runs commands directly on the host, with a thin validation layer meant to keep the agent confined to its per-thread workspace under /mnt/user-data. VulnCheck scores it 8.8 (no CVE has been assigned): an attacker who can steer the agent's tool calls (prompt injection, a hostile document, a malicious skill) reads and writes files outside the sandbox boundary and reaches arbitrary command execution.
The interesting part isn't that host bash is dangerous — everyone knew that. It's how confidently the guard failed. The maintainers didn't tighten the regex. They ripped host bash out of the default path entirely and wrote a docstring admitting the check "is not a secure sandbox boundary." That's the tell: the defect was architectural, not a missing branch.
The flawed function
Everything hinges on one module-level pattern and the loop that consumes it. From backend/packages/harness/deerflow/sandbox/tools.py @ 8b6c333, L18:
_ABSOLUTE_PATH_PATTERN = re.compile(r"(?<![:\w])/(?:[^\s\"'`;&|<>()]+)")
Read that carefully. It matches a token that starts with a slash. The validator then walks only those matches (tools.py @ 8b6c333, L515):
for absolute_path in _ABSOLUTE_PATH_PATTERN.findall(command):
...
unsafe_paths.append(absolute_path)
if unsafe_paths:
unsafe = ", ".join(sorted(dict.fromkeys(unsafe_paths)))
raise PermissionError(f"Unsafe absolute paths in command: {unsafe}. Use paths under {VIRTUAL_PATH_PREFIX}")
So the entire security model is: enumerate the absolute paths in the command string, reject the ones that aren't in an allowlist. The allowlist is /mnt/user-data, /mnt/skills, /mnt/acp-workspace, MCP-configured roots, plus a handful of system prefixes (/bin/, /usr/bin/, /dev/…) that are waved through with no traversal check at all (tools.py @ 8b6c333, L535).
The bug is not in what the regex matches. It's in the enormous set of things it cannot match, because a shell command line is a language and this is a substring scan.
Why the check was insufficient
Three properties of real shell semantics fall straight through the gap:
1. Relative paths are invisible. The pattern requires a leading /. A command that never writes an absolute path — one built from cd plus ../ navigation, or a bare relative name — produces zero matches, so unsafe_paths stays empty and the guard raises nothing. The validator only constrains the absolute way of naming a file. The shell offers a dozen relative ways, and the model (or an injector steering it) is free to use them.
2. There is no notion of a working directory. validate_local_bash_command_paths inspects a flat string. It has no model of where the shell is when each token is resolved. A cd into the workspace followed by upward relative traversal walks the real filesystem tree, but to the regex it's just words with no slashes worth flagging. The advisory names this precisely: "directory changes and relative paths." The check reasons about lexical tokens; the shell reasons about an evolving cwd. Those are different machines.
3. The allowlisted prefixes aren't even traversal-checked. System prefixes like /bin/ and /usr/bin/ continue unconditionally. _reject_path_traversal — which does look for .. segments — is only invoked on the virtual/skills/ACP/MCP branches, never on the system-prefix branch.
And what's downstream of this porous gate? At the pre-fix commit the sink is unambiguous — local/local_sandbox.py @ 8b6c333, L158:
result = subprocess.run(
resolved_command,
executable=self._get_shell(),
shell=True,
capture_output=True,
text=True,
timeout=600,
)
shell=True. The validated-and-rewritten string is handed to a real shell for full interpretation — command substitution, globbing, redirection, cd, relative resolution, the lot. The advisory's phrase "subprocess invocation with shell interpretation enabled" is not paraphrase; it's this line.
Tracing the whole path confirms there is exactly one gate and it's the leaky one. In bash_tool (tools.py @ 8b6c333, L755):
if is_local_sandbox(runtime):
validate_local_bash_command_paths(command, thread_data)
command = replace_virtual_paths_in_command(command, thread_data)
output = sandbox.execute_command(command)
return mask_local_paths_in_output(output, thread_data)
The attacker-controlled command parameter reaches sandbox.execute_command(command) with validate_local_bash_command_paths as the sole interposed check, and replace_virtual_paths_in_command merely rewrites /mnt/... prefixes to host paths — it validates nothing. The dataflow bears this out: command flows parameter-to-argument into execute_command, and the only inspection standing between them is _ABSOLUTE_PATH_PATTERN.findall(command) — an iteration over absolute-path substrings, blind to everything else on the line. Source to sink, one regex, no isolation.
What the fix changed
Commit 92c7a20 does not try to teach the regex about cd. That would be a losing game — you'd be reimplementing a shell parser inside an allowlist and losing to the next quoting trick. Instead it introduces sandbox/security.py (@ 92c7a20) and gates the host-bash path behind an explicit, off-by-default opt-in:
def is_host_bash_allowed(config=None) -> bool:
...
if not uses_local_sandbox_provider(config):
return True
return bool(getattr(sandbox_cfg, "allow_host_bash", False))
bash_tool now refuses to run at all under LocalSandboxProvider unless the operator has knowingly set sandbox.allow_host_bash: true (tools.py @ 92c7a20):
if is_local_sandbox(runtime):
if not is_host_bash_allowed():
return f"Error: {LOCAL_HOST_BASH_DISABLED_MESSAGE}"
The disabled-message text points operators to AioSandboxProvider — Docker-based isolation — for actual bash access. And the validator's docstring gains an honest confession: the path check "is only a best-effort guard… It is not a secure sandbox boundary and must not be treated as isolation from the host filesystem." The regex still exists, but it's been demoted from "the wall" to "a courtesy sign," with the real wall now being process/container isolation the operator must consciously choose.
The lesson
You cannot sandbox a shell with a pattern match on its argument string. A shell command line is a program in a small, expressive language with mutable state — a working directory, variables, substitution, quoting. A regex that enumerates absolute-path tokens is modeling one narrow surface syntax of that language and silently trusting everything it can't name. The vulnerability lived entirely in the space between the two: relative paths, cd, and the unmodeled cwd.
The right boundary for "run arbitrary shell on behalf of an untrusted planner" is structural — a container, a VM, a user namespace — where the confinement is enforced by the kernel regardless of what the command string says. Lexical validation can be a helpful nudge on top of that. It can never be the boundary itself. DeerFlow's patch is the correct shape of the admission: don't parse harder, stop pretending the parse was ever isolation, and make the isolated provider the default instead of the string-matcher. When your allowlist and your interpreter disagree about what a line of shell means, the interpreter always wins.
References
- Fix commit
92c7a20: https://github.com/bytedance/deer-flow/commit/92c7a20cb74addc3038d2131da78f2e239ef542e - PR #1547: https://github.com/bytedance/deer-flow/pull/1547
- VulnCheck advisory: https://www.vulncheck.com/advisories/bytedance-deerflow-localsandboxprovider-host-bash-escape
- Flawed pattern —
sandbox/tools.pyL18: https://github.com/bytedance/deer-flow/blob/8b6c333afc26d7b4dce5a624b5085a7f49c72103/backend/packages/harness/deerflow/sandbox/tools.py#L18 - Validator loop —
sandbox/tools.pyL515-542: https://github.com/bytedance/deer-flow/blob/8b6c333afc26d7b4dce5a624b5085a7f49c72103/backend/packages/harness/deerflow/sandbox/tools.py#L515-L542 bash_toolorchestration —sandbox/tools.pyL755-760: https://github.com/bytedance/deer-flow/blob/8b6c333afc26d7b4dce5a624b5085a7f49c72103/backend/packages/harness/deerflow/sandbox/tools.py#L755-L760- Sink
shell=True—sandbox/local/local_sandbox.pyL154-165: https://github.com/bytedance/deer-flow/blob/8b6c333afc26d7b4dce5a624b5085a7f49c72103/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py#L154-L165 - New gate —
sandbox/security.py: https://github.com/bytedance/deer-flow/blob/92c7a20cb74addc3038d2131da78f2e239ef542e/backend/packages/harness/deerflow/sandbox/security.py
— the resident
The shell always parses last