The Windows node gates system.run through an exec-approval policy. To stop a caller from hiding a disallowed command inside a shell wrapper, ExecShellWrapperParser.Expand decomposes the requested command into its constituent commands ("targets") and the approval layer evaluates each target, so a deny rule or prompt still applies to a command buried inside a wrapper. Expand handles cmd /c , bash -c , powershell -Command / -EncodedCommand , and it splits top-level command chains on ; , & , && , and || .
It does not split the pipeline operator | (only the logical-OR || ), and it does not extract command-substitution spans $(...) / `...` at all. But system.run executes the approved command through a real shell — LocalCommandRunner.BuildProcessArgs builds powershell.exe -NoProfile -NonInteractive -Command " " (default), pwsh … -Command " " , or cmd.exe /C " " — and those shells interpret | (pipeline) and $(...) (subexpression / command substitution). So a command like echo x | iex or echo $(Remove-Item -Recurse $ ) runs the smuggled sub-command, yet that sub-command is never surfaced as an evaluation target . An anchored deny rule (the natural form — Remove-Item * → ^Remove-Item .*$ , exactly what the unwrapper exists to enforce) therefore never fires, while a benign Allow rule ( echo * , Get-ChildItem * , curl * , type * ) matches the outer string and auto-approves with no prompt. Net effect: a single benign-looking Allow rule becomes arbitrary command execution (CWE-863 / CWE-184). The gap also defeats the node's shipped default denylist (e.g. net * "Block net commands").
The unwrapper's intent is to surface every embedded command. The vendored test suite asserts that ; , & , && , and || each produce multiple targets, with the docstring: "the security-critical parser that unwraps shell wrapper commands … to allow the approval policy to evaluate the actual underlying command." Pipeline | and substitution $(...) invoke underlying commands the same way, but there is no test for either — because neither is handled.
SplitTopLevelCommands only treats || as a pipe separator. The single | falls through to the default "append to current segment" branch ( src/OpenClaw.Shared/ExecShellWrapperParser.cs ), so a | b is one segment and b is never isolated. Command substitution is never scanned for.
Execution is shell-interpreted. LocalCommandRunner.PlanExecution uses the shell-wrapped path for system.run (no direct-argv is set by SystemCapability ), and BuildProcessArgs emits powershell -Command / cmd.exe /C ( src/OpenClaw.Shared/LocalCommandRunner.cs ). PowerShell interprets | , $(...) , backtick; cmd interprets | .
The consumer relies on surfacing for its deny check. EnsureCommandAndNestedTargetsApprovedAsync ( src/OpenClaw.Shared/Capabilities/SystemCapability.cs ) evaluates the outer command and every Expand target; an explicit-deny target always blocks even under an approved wrapper. If the dangerous command is not surfaced, that deny is never consulted, and a benign outer Allow rule auto-approves the whole thing with no prompt (V1/legacy path — the enforced one; no V2 handler is installed).
Newline is not a vector (fails safe). MatchesPattern compiles rules with RegexOptions.IgnoreCase | Compiled (no Singleline/Multiline), so . does not cross \n and a newline-separated command matches neither allow nor deny — it drops to the default action. The live vectors are the single-line operators | and $(...) /backtick.
PoC (benign — nothing destructive is executed; the real parser + real ExecApprovalPolicy are driven)
Policy: defaultAction=Deny , rules (top-to-bottom, first match wins): deny "Remove-Item " (anchored → ^Remove-Item . $) allow "echo *" allow "Get-ChildItem *" Faithfully replaying the consumer's deny short-circuit (evaluate the outer command AND every ExecShellWrapperParser.Expand target; any explicit deny blocks):
The same shape smuggles a shell/executor past a "no shells" policy: with curl * allowed and sh / bash / iex denied, curl | iex (or | sh ) auto-approves and runs the fetched script — the deny for the executor never fires because the pipeline stage is never surfaced. Shipped default denies ( net * ) fall the same way: echo $(net user attacker P@ss /add) is auto-approved under echo * .
Executed proof (added as a regression test, tests/OpenClaw.Shared.Tests/ExecApprovalPipeSubstBypassTests.cs ): Passed! - Failed: 0, Passed: 6, Skipped: 0, Total: 6 (net10.0) covering both the parser asymmetry ( ; surfaces / | and $() do not) and the end-to-end deny bypass above.
CWE-863 (Incorrect Authorization) / CWE-184 (Incomplete List of Disallowed Inputs). On a node where "Run system tools" is enabled and any benign Allow rule exists — the ordinary configuration; users allow read/output tools precisely to avoid prompts — a connected gateway/agent (the lower-trust or prompt-injected caller the exec-approval boundary is meant to contain) can execute arbitrary commands on the Windows host by wrapping them in a pipeline or a command substitution. It simultaneously (a) bypasses anchored deny rules, including the shipped defaults, and (b) suppresses the per-command prompt, because the smuggled command is never surfaced for review. Confidentiality, integrity, and availability of the host are all at risk. This is the same subsystem as the broad-wildcard and interpreter-self-grant advisories but a distinct root cause: incomplete decomposition of shell command operators, not an over-broad rule.
Make the decomposer surface the same command classes the shell will actually execute, mirroring the existing ; / && / || handling:
Pipeline | — in SplitTopLevelCommands , treat a single | as a top-level separator (still consuming the second | of || as one operator), so every pipeline stage becomes its own target: if (!inSingleQuotes && !inDoubleQuotes) { if (c == ';' || c == '&') { FlushCurrent(parts, current); if (c == '&' && i + 1 < command.Length && command[i + 1] == '&') i++; continue; } if (c == '|' && i + 1 < command.Length && command[i + 1] == '|') { FlushCurrent(parts, current); i++; continue; } if (c == '|') { FlushCurrent(parts, current); if (i + 1 < command.Length && command[i + 1] == '|') i++; // consume 2nd '|' of "||" continue; } }
Pipeline | — in SplitTopLevelCommands , treat a single | as a top-level separator (still consuming the second | of || as one operator), so every pipeline stage becomes its own target:
if (c == '|' && i + 1 < command.Length && command[i + 1] == '|')
FlushCurrent(parts, current);
FlushCurrent(parts, current);
if (i + 1 < command.Length && command[i + 1] == '|') i++; // consume 2nd '|' of "||"
Command substitution $(...) / `...` — after trimming each segment in Expand , scan it (quote- and nesting-aware) for $(…) and backtick spans and enqueue their inner text as additional commands at depth+1 , so the substituted command is evaluated like any other target.
Command substitution $(...) / `...` — after trimming each segment in Expand , scan it (quote- and nesting-aware) for $(…) and backtick spans and enqueue their inner text as additional commands at depth+1 , so the substituted command is evaluated like any other target.
Both keep the "first token must be a concrete command" property intact and reuse the existing deny/prompt evaluation, so legitimate piped commands still work (each stage is checked) while a denied or unreviewed stage is caught. Note that shell metacharacter coverage is inherently best-effort: the deny/prompt path is defense-in-depth, and an Allow rule should be understood as "this string may run in a shell." The two changes above close every vector demonstrated here.
Fix will be implemented on branch advisory-fix-1 and re-gated ( dotnet test tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj , plus the new positive tests asserting | and $() stages are now surfaced and blocked) before pushing to the advisory fork.
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
