Executes a shell command via /bin/sh -c with a configurable timeout.
Arguments:
command(required) — the shell command.timeout_ms(optional, default 120_000, max 600_000).
Returns captured stdout+stderr plus the exit code. Timeouts kill the spawned
process and surface {:error, :timeout} to the loop.
Runs with cd: ctx.cwd, stderr_to_stdout: true. No input redirection.
When the run is confined (ctx.allowed_roots is set), the command is wrapped
in an OS sandbox (ExAthena.Sandbox) that blocks writes outside the roots.
If no sandbox helper (sandbox-exec/bwrap) is available the tool fails
closed: it refuses to run and returns
{:error, {:sandbox_unavailable, helper}} naming the missing helper —
running unconfined would silently break the confinement contract, and a
command-string scan is never a substitute (trivially bypassed). Hosts that
accept degradation opt in with confine: :best_effort on the run
(ctx.confine_mode == :best_effort), which runs the command unconfined with
a logged warning. Either way an [:ex_athena, :sandbox, :unavailable]
telemetry event is emitted.
Summary
Functions
Classify a bash invocation's args as read-only or not. Used by the
permissions layer to allow read-only bash calls (cat, ls, grep, git log,
gh issue view, …) during :plan phase while still denying mutations
(rm, mkdir, redirects, git add/commit, mix ecto.migrate, …).
Why args failed to classify read-only, or nil when it is read-only.
Functions
Classify a bash invocation's args as read-only or not. Used by the
permissions layer to allow read-only bash calls (cat, ls, grep, git log,
gh issue view, …) during :plan phase while still denying mutations
(rm, mkdir, redirects, git add/commit, mix ecto.migrate, …).
Allowlist-based: a command is read-only only when every shell segment
(split on ;, &&, ||, |, &, newline) starts with a command known
to be read-only. Unknown commands — including interpreter one-liners like
python -c, perl -e, ruby -e, node -e, and editors like ex/ed —
are treated as MUTATING, because arbitrary code execution can never be
proven read-only by a string scan. Command substitution ($(…), backticks)
and file redirects (except 2>&1-style fd duplication and /dev/null
discards) also disqualify a command.
Missing/empty command → false (treat as not read-only so the model gets
a clear phase-gated denial rather than a silent allow).
This classifier gates the :plan phase only; it is advisory next to the
OS sandbox (ExAthena.Sandbox) which enforces write confinement for
confined runs regardless of phase.
Why args failed to classify read-only, or nil when it is read-only.
Returns %{reason: String.t(), segment: String.t() | nil}. segment names
the offending command ("cd", "git commit") when a single shell segment
is at fault, and is nil when the whole invocation is disqualified by a
construct — command substitution, a write pattern, a file redirect.
read_only_command?/1 answers whether; a model that chained four
segments also needs which. Denying with only "not recognized as
read-only" sent a live subagent into a retry loop, re-sending the same
shape three times because nothing told it which part lost.