DEV Community

Avery Lin
Avery Lin

Posted on

Dry-Run AI-Generated Shell Commands in a No-Network Sandbox Before They Touch a Real Host

Why this is worth reading: a generated curl | bash style command that passes a quick syntax check can still remove the wrong directory, read a secret, or rely on /var being writable. You will learn a three-stage dry-run pipeline that turns those implicit assumptions into visible failures, using static checks and a disposable no-network container.

When you use MonkeyCode's free model access and free server option to draft maintenance runbooks, it is tempting to copy the result straight into a terminal. Free generation makes it easy to produce several candidate commands, but it does not make the commands safer; it just gives you more of them. The safer loop is to make every candidate justify the environment it needs.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The real bug is the environment the script assumed, not the syntax

Most generated shell is syntactically valid. That is the trap. A script can be valid and still assume one of these things:

  • /tmp is writable and large enough.
  • The working directory is on a writable filesystem.
  • Network access to a package registry or an internal API is available.
  • Tools such as curl, jq, or sudo exist at the expected path.
  • The script can create files anywhere under / because it is running as root.

When you paste that command into a normal terminal or a CI job, these assumptions are silently satisfied until the day they are not. A dry-run jail makes each assumption visible by denying it and seeing which part of the script fails.

Stage 1: static checks before execution

Before running anything, treat the generated text as data. Save it to candidate.sh and inspect it with cheap, deterministic checks.

# Reject syntax errors first.
bash -n candidate.sh

# If shellcheck is available, treat warnings as review notes.
command -v shellcheck >/dev/null 2>&1 && \
  shellcheck --severity=warning candidate.sh

# Show every command the script would run, without executing it.
bash -x candidate.sh 2>&1 | head -n 200
Enter fullscreen mode Exit fullscreen mode

bash -x on a non-executed file is not a security boundary. It just shows the expansion path and catches obvious mistakes like rm -rf "$DIR/" where DIR is empty because an earlier variable assignment was missing. Use it as a reading aid, not as an approval.

Stage 2: run the candidate in a no-network jail

The container should deliberately lack the things a production host might or might not have. If a script needs network, a writable root filesystem, or root privileges, it should fail here instead of surprising you later.

Save the wrapper as dry_run.sh:

#!/usr/bin/env bash
set -euo pipefail

# Usage: ./dry_run.sh path/to/candidate.sh [args...]
CANDIDATE="$(realpath "${1:?usage: dry_run.sh <script> [args...]}")"
shift

ENGINE="${ENGINE:-podman}"
IMAGE="${IMAGE:-debian:bookworm-slim}"

# Bring the candidate into a dedicated read/write workspace so the
# container does not mutate the repository it came from.
WORKSPACE="$(mktemp -d)"
trap 'rm -rf "$WORKSPACE"' EXIT
cp "$CANDIDATE" "$WORKSPACE/candidate.sh"

# The script runs as a non-root user, with no network, no new privileges,
# a read-only root filesystem, and only a small temporary workspace.
"$ENGINE" run --rm \
  --network none \
  --read-only \
  --tmpfs /tmp:rw,size=64m \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --user 65534:65534 \
  --pids-limit 64 \
  --memory 256m \
  -v "$WORKSPACE:/work:rw" \
  -w /work \
  "$IMAGE" \
  bash ./candidate.sh "$@"
Enter fullscreen mode Exit fullscreen mode

Run it as:

chmod +x dry_run.sh
./dry_run.sh candidate.sh
Enter fullscreen mode Exit fullscreen mode

If you use Docker instead of Podman, set ENGINE=docker. The image name is just the minimal runtime for the script; replace debian:bookworm-slim with the image your runbooks actually target. Keep the image pinned to a tag so the dry-run environment is reproducible, not latest.

The exact flags matter more than the container tool:

  • --network none rejects any implicit API or package-registry dependency.
  • --read-only plus a small --tmpfs /tmp rejects scripts that expect to write outside their workspace.
  • --cap-drop ALL and --security-opt no-new-privileges reject privilege escalation.
  • --user 65534:65534 rejects root-presuming commands.
  • --memory 256m and --pids-limit 64 catch runaway loops before they damage a real host.

This is not a security sandbox in the formal sense. It is a signal generator: each denied capability produces a specific failure that tells you what the script assumed.

Stage 3: read the failure as an environment requirement

When the candidate fails, do not immediately conclude the model is wrong. Classify the failure first.

Failure inside the jail Likely assumption Next action
curl: not found or DNS failure Network access to an API or registry Decide whether the runbook is supposed to have that network path; if so, add an allowlisted egress step.
Permission denied writing under / Root or a writable root filesystem Rewrite the command to write under a declared workspace or $TMPDIR.
No space left on device in /tmp Large temporary storage Add an explicit check for available space before downloading or building.
cannot create /var/... A persistent writable path Move runtime state out of the script or make the path a declared volume.
Process limit or memory limit hit Unbounded loop or large in-memory processing Add guards, timeouts, or streaming.
Clean exit with no output Success under the constrained environment Schedule human review, not automatic trust.

The last row is important. A clean exit in the jail is not proof of correctness. It is evidence that the command works without hidden root, network, or writable-root assumptions. You still need a human to decide whether the command does the right thing.

Where free model access helps

With free model access and a free server option, you can generate three or four competing commands for the same task and run each through the same dry-run pipeline without paying per retry with your primary infrastructure. Use the free generators for volume, and use the jail as a cheap filter that eliminates candidates needing dangerous implicit capabilities. The result is that the only candidate you review is one that survived a reproducible environment check.

That is the right division of labor: the model proposes, the jail constrains, and the human approves. None of the three should do the others' jobs.

Limitations to record

The wrapper is not a replacement for policy review. It does not inspect the script for secrets exfiltration, malicious commands, sudo attempts outside the contained process, or supply-chain risk in downloaded files. It can also produce false confidence if the sandbox is more constrained than production; a script may pass in the jail and fail later because production has different init behavior, user accounts, or mounted files.

The dry run also cannot emulate every target platform. A Debian container will not catch macOS- or Alpine-specific assumptions. Use a base image that matches the runbook's real host whenever possible, and keep an image-pinning policy so results are comparable over time.

Who should skip this

Skip it if your runbooks are already reviewed by a human and executed in a fully managed, audited environment that enforces capability boundaries. Skip it if the cost of a container pull and a few extra seconds per candidate outweighs the risk you are trying to reduce. Skip it if your organization's policy already prohibits running generated commands locally at all; in that case, the dry-run step belongs in a separate isolated runner, not on a developer laptop.

For on-call runbooks, maintenance scripts, and one-off database or filesystem fixes, this is a small, legible gate that fits between a model's suggestion and a real terminal. Place the script next to the runbook in the repository and run each candidate separately; keep the failures in the commit message so the team can see what the command was not allowed to do.

Top comments (0)