fix: exec/2 returns a shell result instead of raising, and always terminates - #73
Conversation
davydog187
left a comment
There was a problem hiding this comment.
Verdict: request changes. GitHub refuses a formal
--request-changesreview on one's own PR, so this is posted as a comment; finding 1 is a blocker and should gate the merge.
Two lenses were applied — a contract lens (does exec/2 actually keep the two guarantees the title claims, on every public entry point and every composition form?) and a test-integrity lens (would the new tests fail if the fix were removed?). 11 candidates went in; 8 survived verification on the PR branch (d0efbf2, checked out in a private worktree, mix run probes). The rest were merged as duplicates — see the closing block.
The core of the PR is right, and the containment for registry builtins is a real fix. The problems below are all about the edges of the two guarantees: which entry points get them, which loops the deadline can actually see, and what the new error path throws away on the way out.
1. "always terminates" is false: any loop inside a single command still hangs exec/2 forever
Severity: blocker — lib/just_bash/interpreter/executor.ex:84
Limit.check_deadline!/1 is consulted in exactly three places: the statement loop (executor.ex:84), Find.find_recursive/5 (find.ex:142), and FS.walk/3 (which has no production caller — finding 3). Nothing checks it inside a command's own loop, so max_wall_ms bounds the gap between statements, not execution.
awk is the live instance: awk/evaluator.ex:590/613/619 implement for, while and do-while with no iteration cap and no deadline check.
Measured on this branch with limits: :strict (max_wall_ms: 1_000), each killed at a 6 s external timeout:
awk 'BEGIN{while(1){x=x+1}}' -> HUNG (killed at 6s)
awk 'BEGIN{for(i=0;i>=0;i++){}}' -> HUNG (killed at 6s)
while true; do :; done -> 12 ms exit 1 "bash: execution step limit exceeded (10000)"
Note the third line: the shell-level infinite loop was already bounded, by max_steps and by Loop's @default_max_iterations 10_000 (executor/loop.ex:16). So the statement-loop check adds little that did not already exist, and the case it does not cover — "one command, many iterations" — is precisely the shape of the printf '%b' hang (56c74b8) that issue #69 cites as motivation. The category is not retired.
Suggested fix: thread the deadline into awk's evaluator loop constructs (and any other command with an unbounded internal loop) the way find.ex:142 does, or give them an iteration cap like Loop's. Failing that, narrow the claim in the PR title and in Limit's moduledoc to what is actually enforced — and either way add a test asserting that a single long-running command is bounded, since there is currently no such test.
2. JustBash.exec!/2 gets neither new guarantee
Severity: major — lib/just_bash.ex:528
exec/2 was hardened; exec!/2, the other documented public execution entry point, was left untouched. It calls Executor.execute_script/2 directly with no State.arm_deadline/2 and no rescue, so bash.interpreter.deadline stays nil and Limit.check_deadline!/1 short-circuits to :ok at all three checkpoints. Measured, limits: [max_wall_ms: 30, max_steps: 10_000_000]:
exec! "for i in $(seq 1 100); do spin; done" -> 1105 ms exit 0 stderr "" deadline=nil
exec "for i in $(seq 1 100); do spin; done" -> 33 ms exit 1 "bash: execution wall clock limit exceeded (30 ms)"
exec! "wreck-env; echo $HOME" -> ** (Protocol.UndefinedError) escapes to the host
exec "wreck-env; echo $HOME" -> exit 1, "bash: internal error (...)"
The raise escaping is arguably a bang function's contract, though the docstring promises only "raising on parse errors" and a Protocol.UndefinedError naming a JustBash internal is not that. The wall clock is not defensible either way: unlike max_steps, max_wall_ms requires arming, so this PR introduces a bound that silently does not exist on one of the two public entry points. (exec_file/2 is fine — it delegates to exec/2.)
Suggested fix: arm the deadline in exec!/2 too — it is the same three lines — and state in its @doc which exceptions it is allowed to propagate.
3. Only find was bounded; FS.walk/3's new :deadline has no caller in lib/
Severity: major — lib/just_bash/fs/fs.ex:167
The PR summary names FS.walk/3 as one of the enforcement points, but grep -rn 'FS\.walk\|VFS\.walk' lib/ on this branch returns only the definition (fs.ex:167,171) and a doc mention in limit.ex:256 — zero production callers, let alone ones passing :deadline. The wrapper and Limit.enforce_deadline/2 are reachable only from test/sandbox_contract_test.exs:259-287.
Meanwhile every recursive command except find hand-rolls its traversal, and a whole traversal is charged as one step, so nothing else bounds them either. Measured, 8000-file tree under /tree, limits: [max_wall_ms: 1, max_steps: 10_000_000], each redirected to /dev/null:
find /tree -> 16 ms exit 1 "execution wall clock limit exceeded (1 ms)"
grep -r hello /tree -> 4895 ms exit 0 stderr ""
du /tree -> 5011 ms exit 0 stderr ""
tree /tree -> 5425 ms exit 0 stderr ""
cp -r /tree /copy -> 8136 ms exit 0 stderr ""
These terminate eventually, so this is not a hang — but they overrun the declared bound by three to four orders of magnitude with no diagnostic, which is what max_wall_ms exists to prevent. Only the one command the issue happened to name is covered.
Suggested fix: either route the recursive commands through FS.walk(fs, root, deadline: bash.interpreter.deadline) — which would also give the new wrapper a reason to exist — or add Limit.check_deadline!/1 to each *_recursive helper the way find.ex:142 does. If neither is in scope, drop the unused :deadline branch and say plainly that find is the only bounded traversal.
4. x=$(failing_cmd) is a silent success, and command substitution swallows stderr
Severity: major — lib/just_bash/interpreter/expansion.ex:302
The PR checked composition against the oracle for cmd | cat, echo $(cmd) and if cmd, concluded the exit codes were already right, and fixed only the pipeline's lost stderr. Two things were missed in the sibling path. execute_command_substitution/2 is three lines and discards everything but stdout:
defp execute_command_substitution(bash, %AST.Script{} = script) do
{result, _bash} = Executor.execute_script(bash, script)
String.trim_trailing(result.stdout, "\n")
endOracle (GNU bash 3.2.57, this machine) vs this branch:
bash: x=$(cat /nope); echo "code=$?" -> stderr "cat: /nope: No such file or directory", code=1
JustBash: x=$(cat /nope); echo "code=$?" -> exit 0, stdout "code=0 x=[]", stderr ""
bash: echo $(cat /nope) -> stderr "cat: /nope: No such file or directory"
JustBash: echo $(cat /nope) -> stderr ""
The second line is the same defect this PR just fixed for pipelines, in the substitution path: a diagnostic produced inside the substitution never reaches the caller. The first is the one composition form where bash does propagate the substitution's status — a plain assignment (export x=$(...) and local x=$(...) correctly stay 0, since those return the builtin's own status). Combined, x=$(boom) — the issue's own crash probe — returns exit 0, stdout "", stderr "": a total silent success from a crashed command, reachable in one statement.
Suggested fix: return {stdout, stderr, exit_code} from execute_command_substitution/2; accumulate the stderr onto the enclosing statement the way execute_pipeline/2 now does, and set $? from the substitution's exit code on the bare-assignment path. Add oracle-backed tests for x=$(cat /nope) and x=$(boom).
5. The exec/2 internal-error net discards all stdout and rolls back all session state
Severity: major — lib/just_bash.ex:420
The rescue is a clause of exec/2's implicit try, so the only bash in scope is the function parameter — the rebinding in the body (bash = if ... arm_deadline ...) is not visible there, and neither is anything the script did. internal_error/2 therefore returns stdout: "", env: bash.env and the caller's original struct.
{_, b2} = JustBash.exec(b, "echo hi > /f.txt; export FOO=bar; wreck-env; echo $HOME")
-> exit 1, stdout "", "bash: internal error (Protocol.UndefinedError: ...)"
JustBash.exec(b2, "cat /f.txt; echo FOO=$FOO")
-> "cat: /f.txt: No such file or directory", "FOO="
Both the redirect and the export completed before the crash, and both vanished. This is inconsistent with the codebase's other error paths — including the one directly above it. The Limit.ExceededError handler at executor.ex:97 keeps prior work:
limits: [max_steps: 4], "echo a > /f.txt; echo one; ... echo six"
-> exit 1, stdout "one\ntwo\nthree\n", "bash: execution step limit exceeded (4)"
-> next exec: cat /f.txt -> "a"
command_crashed/3 is likewise correctly scoped to one command. Neither new internal-error test asserts anything about state or prior output (they check exit_code == 1 and a stderr prefix), so the rollback is uncovered. A long-lived host that reuses the struct silently loses a session.
Suggested fix: contain the internal error at execute_statement/2, alongside the Limit.ExceededError clause — that already produces the "halt, but keep everything earlier statements did" shape. If it must stay at the exec/2 level, wrap do_exec/2 in an explicit try with the armed bash bound outside it and thread the last-known-good struct out. Either way, add a test asserting a pre-crash > and export survive.
6. No test fails if the deadline is never re-armed
Severity: major — test/sandbox_contract_test.exs:203
test "the deadline is rearmed for each top-level exec" uses max_wall_ms: 200 and runs two execs of ~20 ms each. A deadline armed once and never refreshed still has ~175 ms left when the second exec starts, so the test passes either way. Verified by patching lib/just_bash.ex:398 to arm only when deadline == nil — the exact regression the test is named after:
mix test test/sandbox_contract_test.exs -> 25 tests, 0 failures
mix test -> 4777 tests, 0 failures
Two neighbours have the same shape. test "limits: false disables the wall clock too" (:213) runs 10 ms of work, so it passes under any non-degenerate budget and cannot catch Limit.deadline(nil) accidentally returning a default-budget deadline. And assert elapsed_us < 2_000_000 (:192) is 66x the 30 ms bound it guards, so a deadline that fired sixty times late still passes.
This is the #69/#70 lesson repeating: the boundary inputs are chosen so all the interesting variants render the same result.
Suggested fix: make the budget smaller than the cumulative work across execs — e.g. max_wall_ms: 30 with two spins per exec (40 ms total), so a stale deadline trips on the second call. Tighten elapsed_us to the order of the bound (e.g. < 300_000 for 30 ms). For limits: false, assert the absence structurally: bash.interpreter.deadline == nil after the exec.
7. Containing a registry crash silently retires the documented [:just_bash, :command, :exception] event
Severity: major — lib/just_bash/interpreter/executor.ex:861
invoke_builtin/5's rescue sits inside Telemetry.command_span/3 (executor.ex:417), so :telemetry.span/3 never sees the exception and never emits its :exception event. telemetry.ex:46 documents [:just_bash, :command, :exception] as "Emitted when a command raises", and telemetry.ex:96 lists it in the copy-paste attach_many example. For all ~90 registry commands that is now false. Verified both directions on this branch with a handler attached to the four :exception events, running wreck; cat /etc/hosts:
with invoke_builtin/5 (as merged): exception events emitted: []
with the try removed (pre-PR shape): [{[:just_bash, :command, :exception], "cat"},
{[:just_bash, :session, :run, :exception], nil}]
So the containment is not merely "no new telemetry" — it removes signal a host could already subscribe to. The failure class this PR contains (#52's cp MatchError, #67's date FunctionClauseError) used to be loud; it is now an ordinary exit 1, indistinguishable in metrics from a script legitimately failing.
Suggested fix: emit from command_crashed/3 — either :telemetry.execute([:just_bash, :command, :crash], ...) with the exception and stacktrace, or re-raise into the span and catch outside it so the existing documented event keeps firing. [:just_bash, :session, :run, :exception] does still fire at exec/2's net today (the rescue is outside session_span) — worth a test so it stays that way. If the command event is genuinely retired, update telemetry.ex:46.
8. internal_error/2's stderr bypasses max_output_bytes
Severity: minor — lib/just_bash.ex:420
internal_error/2 builds stderr from Exception.message(error) and returns straight out of the rescue, outside execute_statement/2's Limit.track_output! accounting. The per-command path does not have this problem — command_crashed/3 flows through the normal result and is correctly refused. Measured with limits: [max_output_bytes: 100]:
"wreck-env; echo $HOME" -> 1107 bytes of stderr returned
"bigraise" (140 KB message) -> 45 bytes: "bash: output size limit exceeded (100 bytes)"
Exception.message/1 is unbounded from the sandbox's point of view (inspect/1 allows 4096 bytes per binary and 50 collection elements), so a MatchError or KeyError carrying interpreter or FS state can inline sandbox file contents into a stderr the host asked to be capped.
Suggested fix: truncate the detail in internal_error/2 to max_output_bytes (or a small fixed cap) before building the message.
Considered and merged/dismissed (3 of 11 candidates)
- "The wall clock is only checked between statements" — duplicate of finding 1. Same defect, found independently by both lenses. Merged; kept the better-argued framing plus the observation that
while true; do :; donewas already bounded bymax_steps, which is what makes the remaining gap the interesting one. - "
FS.walk/3's:deadlineandLimit.enforce_deadline/2have no caller outside the new tests" — duplicate of finding 3. The dead-option observation and the unbounded-grep -r/cp -rmeasurement are one finding seen from two sides; merged, since giving the option a caller resolves both. - "The outer rescue rolls back the whole script's filesystem and env side effects" — duplicate of finding 5. Merged. The surviving version explains why — the implicit
trycannot see the body's rebinding — and that explanation drives the suggested fix.
Nothing was outright refuted: every candidate reproduced on d0efbf2. Two severities were adjusted. The exec!/2 finding dropped from blocker to major (a bang function propagating a raise is partly defensible; the missing wall clock is not). The telemetry finding rose from minor to major once it turned out a documented event stops firing, rather than merely a new one not being added.
…traversal, and exec!/2 Review findings 1, 2 and 3 on #73. The deadline was only consulted between statements, so "always terminates" was false for the one shape that matters: one command, many iterations. That is exactly the `printf '%b'` hang (56c74b8) issue #69 cites as motivation. Finding 1 — a loop inside a single command. `awk 'BEGIN{while(1){x=x+1}}'` and `awk 'BEGIN{for(i=0;i>=0;i++){}}'` hung past a 6s external kill under `limits: :strict`. The interpreter's statement loop is never re-entered while one command runs and the step counter charges the whole command as one step, so awk's `for`, `while` and `do-while` now check the wall clock per iteration. Audited the other candidates named in the review: sed has no label/branch support so `:a;ba` cannot loop, printf's format recycling terminates, and the expansion paths are bounded by the statement loop. `seq 1 100000000` is a real third instance — one command, 100M iterations — so its range walk goes through `Limit.enforce_deadline/2`. Finding 2 — `exec!/2` armed no deadline, so `interpreter.deadline` stayed nil and all three checkpoints short-circuited: a 30 ms budget ran for 1105 ms at exit 0. Arming is now shared with `exec/2` via `arm_top_level/1`. `exec!/2` still propagates raises — that is a bang function's contract — but the docstring now says so instead of promising only "raising on parse errors". Finding 3 — only `find` was bounded. On an 8000-file tree with a 1 ms budget, `grep -r` ran 4895 ms, `du` 5011 ms, `tree` 5425 ms and `cp -r` 8136 ms, all at exit 0 with empty stderr. Each hand-rolls its own descent, so each now checks the deadline in its recursive helper; `cp -r` recurses inside `FS.cp/4`, which gains a `:deadline` option for the purpose. `FS.walk/3`'s `:deadline` option is gone — it never had a caller in `lib/`, and `Limit.enforce_deadline/2` is the general mechanism, now reached from `seq`. Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
…ller Review finding 4 on #73. `execute_command_substitution/2` kept only the trimmed stdout, so a failure inside `$( )` was invisible in both directions: x=$(cat /nope); echo code=$? -> exit 0, stdout "code=0", stderr "" echo $(cat /nope) -> stderr "" x=$(boom) -> exit 0, stdout "", stderr "" GNU bash 3.2.57 prints the diagnostic in all three and reports code=1 for the first. The lost stderr is the same defect this PR already fixed for pipelines, still live in the sibling path. A substitution now hands back a `{:substitution, stderr, exit_code}` trace alongside the assignments an expansion already returns, so it travels out of arbitrarily nested expansions — `"$(...)"`, `${x:-$(...)}`, `$(( $(...) + 1 ))` — without a second channel. `Expansion.take_substitutions/1` splits the traces back out at the two places that consume them: - a simple command prepends the accumulated stderr to its result, *outside* `with_redirections/3`, because bash performs redirections after expansion and `echo $(cat /nope) 2>/dev/null` still prints the diagnostic; and - a bare assignment reports the last substitution's exit status as `$?`, which is what bash does when no command claims it. `export`/`local`/`declare` keep their own status, and a command keeps its own. Fifteen tests, each checked against the oracle on this machine. Two sibling paths still discard expansion side effects and so stay silent: array literals (`arr=($(cat /nope))`) and for-loop word lists (`for i in $(cat /nope)`). Both drop `${VAR:=default}` assignments too, which predates this PR, so they are left for their own change. Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
…telemetry Review findings 5, 7 and 8 on #73. Finding 5 — the `exec/2` rescue is a clause of the function's implicit `try`, so the only `bash` it can see is the parameter. Everything the script did was invisible to it: exec(b, "echo hi > /f.txt; export FOO=bar; wreck-env; echo $HOME") -> exit 1, stdout "", and the returned struct has neither /f.txt nor FOO The neighbouring `Limit.ExceededError` handler keeps prior work; this one silently retired a session. Containment moves down to the statement loop, into `Executor.run_statement/2`, which is where the last known-good shell actually lives — the accumulated output and the struct the previous statements left behind. It halts the script and preserves both, exactly like the limit handler. `exec/2`'s net stays as a last resort for raises outside the statement loop (parsing, the EXIT trap), where there is no session state to preserve. Because containment now lives in the interpreter, `exec!/2` gets it too, so its docstring no longer claims it propagates every interpreter exception. Finding 7 — `invoke_builtin/5`'s rescue sat *inside* `Telemetry.command_span/3`, so `:telemetry.span/3` never saw the exception and the documented `[:just_bash, :command, :exception]` event stopped firing for all ~90 registry commands. Verified: as merged, `wreck; cat /etc/hosts` emitted no exception events at all. The rescue moves outside the span as `contain_command_crash/3`, so the span emits the documented event and the crash is still contained. `execute_custom_command/5`'s own rescue folds into the same place, which means a host-supplied command that crashes is now visible in telemetry too — it never was. Both wordings are preserved ("command crashed" / "custom command crashed"). Finding 8 — `internal_error/2` built stderr straight from `Exception.message/1` and returned outside `Limit.track_output!`, so with `max_output_bytes: 100` it returned 1107 bytes. `inspect/1` allows 4096 bytes per binary, so a MatchError or KeyError carrying interpreter or FS state can inline sandbox file contents into a stderr the host asked to be capped — an information-disclosure edge on the sandbox boundary. The detail is now truncated to `min(512, max_output_bytes)`, on a UTF-8 boundary. Measured after: 100 bytes and 512. Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
Review finding 6 on #73. Three boundary inputs were chosen so all the interesting variants rendered the same result — the #69/#70 lesson repeating. `test "the deadline is rearmed for each top-level exec"` used `max_wall_ms: 200` with two execs of ~20 ms, so a deadline armed once still had ~175 ms left on the second call. Verified by patching `arm_top_level/1` to arm only when `deadline == nil`: `mix test test/sandbox_contract_test.exs` stayed at 61 tests, 0 failures. It now runs six execs of ~55 ms against the same 200 ms budget — each comfortably inside it on its own, so only rearming keeps them all green — plus a structural sibling asserting that each exec's `at_ms` is strictly later than the previous one's, which does not depend on how fast the machine is. Both go red against the arm-once patch; the structural one fails on exact equality. `test "limits: false disables the wall clock too"` ran 10 ms of work, so it could not catch `Limit.deadline(nil)` handing back a default-budget deadline. It now asserts the absence structurally. Verified by patching `deadline(nil)` to `deadline(defaults())`: the test goes red on `bash.interpreter.deadline == nil`. `assert elapsed_us < 2_000_000` guarded a 30 ms bound at 66x. Tightened to 300_000. Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
|
All 8 confirmed findings are addressed. Every one was reproduced on
1 — a loop inside a single commandReproduced: Audited the other candidates the review named. sed has no label/branch support ( Five new tests, each run under a 2 —
|
Verification of review fixesIndependently re-ran every repro at
Gates (re-run here, Elixir 1.19.5 / Erlang 28.3)New problems introduced by the fix commitsNone found. Three things that look wrong were checked at
Bottom line7 of 8 fixed and verified. Finding 1 is the one that should not close: the three awk loops and |
The last open item: brace expansion was unbounded
Pre-existing rather than a regression — Why the statement loop misses itThe same reason it missed awk's Two instruments, because a word list has two ways to be too expensiveCardinality, per the review's own suggestion. A million words is counted
The wall clock, checked per word produced, so a Making the bounds reachable meant fixing the walk. Each level was concatenating Also on the audit: glob expansionGlob descends the filesystem itself, one directory read per wildcard segment, The rest of the audit — measured this time, not asserted
AfterSame harness, same Nothing legitimate moved. TestsTen new cases in
Gates |
Final verificationIndependent re-verification of Verdict: all four claims hold. One minor inaccuracy in the claims themselves, reported below. Per-item
1. Brace expansion —
|
| Mutation | Test that went red | Failure |
|---|---|---|
Drop Limit.check_expansion_words!(bash, count) from expand_into/3 |
a product of ranges each inside the bound is still counted | exit_code left 0, right 1 |
Replace the range pre-measure with _ = range_size(...) |
a range is refused before it is built, not after | elapsed_us — 916543 < 200000 failed |
Drop Limit.check_deadline!(bash) from expand_into/3 |
the wall clock still bounds a word list the cardinality cap allows | stderr was output size limit exceeded (1048576 bytes), not the wall clock |
Pass nil instead of deadline in expand_wildcard_segment/6 |
glob expansion is bounded by the wall clock | exit_code left 0, right 1 |
And the whole-fix revert (git checkout 16c85b3 -- lib/…/brace.ex) produces 8 failures, matching
the fixer's "6 of 8 failed, the two untouched cases passed as intended" — the five table probes plus
the three targeted ones. Baseline unmutated is 73 tests, 0 failures in 1.6 s.
Minor finding — the one thing that is not as claimed. The fix note says "All new probes use the
existing bounded_exec/2 shape", and the describe-block comment says "Each probe runs under a task
so a regression fails the test instead of wedging the suite." Both are inaccurate: four of the new
tests call JustBash.exec/2 directly, including one of the four mutation-critical ones —
test "a range is refused before it is built, not after" (test/sandbox_contract_test.exs:599) and
test "a product of ranges each inside the bound is still counted" (:614).
This is observable, not theoretical. Under the whole-fix revert, the "refused before it is built" test
does not flunk at 5 s — it runs until ExUnit's 60 s per-test timeout, and the file's run time goes
from 1.6 s to 91.6 s:
8) test … a range is refused before it is built, not after
** (ExUnit.TimeoutError) test timed out after 60000ms
stacktrace:
lib/just_bash/interpreter/expansion/brace.ex:46: … Brace.expand_with_brace/2
…
Finished in 91.6 seconds
73 tests, 8 failures
The suite still goes red, so CI is not wedged, and that test's elapsed_us assertion does need a real
measurement — but wrapping the :timer.tc call inside a Task keeps both properties. Not a blocker;
the check itself is genuine and mutation-sensitive.
Regression sweep
Old vs. new, mechanically diffed. 56 scripts run at 16c85b3 and at afe0aa8 under
limits: :strict, with exit code + stdout + stderr serialized and compared term-by-term:
NO BEHAVIOURAL DIFFERENCES across 56 scripts
What was swept, so the absence of findings is legible rather than ambiguous:
- Brace, ordinary shapes —
{a,b,c}with prefix / suffix / both,{1..5},{5..1},{1..10..2},
{10..1..-3},{a..e},{e..a},{a..e..2},{01..05},{-3..3},{3..-3} - Brace, degenerate shapes —
{},{a},{,},{,a},{a,}, zero step ({1..10..0}, the one
input where the newrange_size/3mirror could have divided by zero — it returns 0, matching
expand_range/3), backwards step ({1..10..-2}), quoted ('{a,b}',"{a,b}"), escaped (\{a,b\}) - Brace, cartesian and nested —
{1..3}{a..c},{a,b}{c,d}{e,f},{a,{b,c},d},{{1..3},{a..c}},
{a..c}{1..2}{x,y},a{b,c}d{e,f}g,file{1..3}.{txt,log} - Brace crossed with other expansions —
{$v,there},{a,b}$v,{a,b}$(echo z),$((1+1)){a,b}.
This is the path where the accumulator rewrite could have reordered pending assignments; output and
ordering are identical before and after. - Brace in other syntactic positions —
for i in {1..4},a=({1..4}),a=({a,b}{1,2}), pipelines,
casepatterns - Glob — single and multi-segment (
/t/*,/t/*/*,/t/*/*/*), suffix (*.txt), no-match fallback
to the literal pattern,?,[12], dotfile exclusion, trailing-slash preservation (/t/a/*/),
relative globs aftercd, glob as an operand tolsandcat, glob + brace together
(/t/{a,b}.txt,/t/{a,b}*) - Limit presets —
false,:strict,:default,:relaxedall exercised on legitimate expansions;
all correct and fast (echo {1..50000} | wc -w→50000in 43–46 ms) - Neighbouring unbounded-work paths — arithmetic overflow,
${var//pat/rep}, indirect expansion,
function recursion depth, nestedeval(table in §3)
Three unclaimed improvements fell out of the sweep:
v=abc; for i in $(seq 1 30); do v=$v$v; done; echo ${#v}— at16c85b3this ran 4069 ms at
exit 0 and allocated a 3.2 GB string, straight through the 1000 ms:strictbudget. Atafe0aa8
it is 1203 ms, exit 1, wall clock limit exceeded. The per-word deadline check now sits in the
path every word takes, so it catches this too.printf 'a%.0s' {1..100000}—DID NOT TERMINATE within 15 sbefore, 0 ms, exit 1 now.- Side effect of the accumulator rewrite: with
limits: false,echo {1..100000} | wc -wwent from
~18 s to 101 ms.
Two pre-existing issues found and confirmed not caused by this PR — identical at 16c85b3, and
neither in the surface this commit touches. Recording them so they are not rediscovered as regressions
in a later round:
awk 'BEGIN{for(;;){…}}'— an emptyforcondition evaluates as false, so the body never runs.
awk 'BEGIN{n=0; for(;;){n++; if(n>3) break}; print n}'prints0; GNU awk prints4. It
terminates, so this is a correctness bug, not a sandbox hole, and it is out of scope for The sandbox contract permits a host crash and an unbounded hang: builtin raises escape exec/2, and Limit has no wall-clock bound #69. The
forvariant the suite covers,for(i=0;i>=0;i++), is bounded correctly at 1002 ms.v=abc; while true; do v=${v}${v}; doneoverruns the 1000 ms budget by ~2.4–2.9× (2422 ms at
16c85b3, 2946 ms atafe0aa8) because a single string-doubling step allocates gigabytes between
deadline checks. It returns at exit 1 in both, so theexec/2-returns contract holds; the overrun
factor is unchanged by this commit.
Gates — all five re-run on this head (afe0aa8)
$ mix format --check-formatted
(clean, exit 0)
$ MIX_ENV=test mix compile --force --warnings-as-errors
Compiling 176 files (.ex)
Generated just_bash app (exit 0)
$ mix credo --strict
5158 mods/funs, found 1 refactoring opportunity.
[F] test/support/banned_fixture_apply.ex:4:16 — Avoid `apply/2` and `apply/3`
(the intentional test fixture; exit 0)
$ mix test
Finished in 23.8 seconds (23.6s async, 0.2s sync)
2 doctests, 62 properties, 4825 tests, 0 failures (5 excluded)
$ mix dialyzer
Total errors: 13, Skipped: 13, Unnecessary Skips: 0
done (passed successfully)
Round 4 — the last open item: four probes were not actually task-wrapped
The round-3 note said "All new probes use the existing
CI still went red without the wrapper, so this was never a correctness hole. It was the wrong Before — measured with the brace fix reverted$ git checkout 16c85b3 -- lib/just_bash/interpreter/expansion/brace.ex
$ mix test test/sandbox_contract_test.exs:599 # "a range is refused before it is built"
1) test ... a range is refused before it is built, not after
** (ExUnit.TimeoutError) test timed out after 60000ms
stacktrace:
(just_bash 0.3.0) lib/just_bash/interpreter/expansion/brace.ex:46:
anonymous fn/5 in JustBash.Interpreter.Expansion.Brace.expand_with_brace/2
(elixir 1.19.5) lib/enum.ex:2520: Enum."-reduce/3-lists^foldl/2-0-"/3
(just_bash 0.3.0) lib/just_bash/interpreter/expansion/brace.ex:43: …expand_with_brace/2
(just_bash 0.3.0) lib/just_bash/interpreter/expansion.ex:123: …expand_word_with_glob/2
…
Finished in 60.1 seconds
1 test, 1 failure (72 excluded)
$ mix test test/sandbox_contract_test.exs # whole file, same mutation
Finished in 91.6 seconds
73 tests, 8 failuresThe change
defp bounded_exec(bash, script) do
{_elapsed_us, result} = bounded_exec_timed(bash, script)
result
end
# The clock is read *inside* the task, so a probe that asserts on elapsed
# time measures the run itself rather than the yield, and still fails at 5s
# instead of wedging the suite.
defp bounded_exec_timed(bash, script) do
task = Task.async(fn -> :timer.tc(fn -> JustBash.exec(bash, script) end) end)
case Task.yield(task, 5_000) || Task.shutdown(task, :brutal_kill) do
{:ok, {elapsed_us, {result, _bash}}} -> {elapsed_us, result}
nil -> flunk("`#{script}` did not terminate within 5s")
end
endThe other three swap After — same mutation, same command$ git checkout 16c85b3 -- lib/just_bash/interpreter/expansion/brace.ex
$ mix test test/sandbox_contract_test.exs:607 # the test moved 8 lines
1) test ... a range is refused before it is built, not after
`echo {1..20000000}` did not terminate within 5s
code: {elapsed_us, result} = bounded_exec_timed(bash, "echo {1..20000000}")
stacktrace:
test/sandbox_contract_test.exs:614: (test)
Finished in 5.1 seconds
1 test, 1 failure (72 excluded)
$ mix test test/sandbox_contract_test.exs # whole file, same mutation
Finished in 36.6 seconds
73 tests, 8 failures60.1 s → 5.1 s for the probe, and a named diagnostic instead of a stacktrace into a hung reducer. Unmutated, the file is unchanged: $ mix test test/sandbox_contract_test.exs
Finished in 1.6 seconds (1.6s async, 0.00s sync)
73 tests, 0 failuresScope note
Gates — all five re-run on
|
Final verificationRound 4, independent re-run at Item
Structural check. Reproduced the original defect first. With the brace fix reverted and the pre-fix test file: After the fix, same mutation: 60.1s TimeoutError to a 5.2s flunk; 91.7s to 36.7s; the same 8 failures on both sides. The fix note's 91.6 / 36.6 / 5.1 numbers reproduce within noise. Unmutated, at HEAD: 1.6s at seed 38116, 1.6s at seed 1000, 1.6s at seed 2000 — unchanged from the pre-fix baseline. Proving the new coverage can failThe brace mutation exercises Reverted afterwards. A truly hanging script inside Regression sweepRefute-by-default. What I checked, including what came back clean:
GatesAll five run here at Credo's single finding is the intentional test fixture that CLAUDE.md calls out; it is not introduced by this branch. Remaining nit (non-blocking, prose only)The round-4 fix note says "its four pre-existing callers are untouched". Verdict: all clear. The item is genuinely fixed, the coverage is proven able to fail, and the sweep turned up nothing. |
…minates Closes #69. Two holes in the sandbox contract, both reachable from a two-line script. **A raise from a registry builtin escaped `exec/2`.** Only custom commands had a catch-all (`executor.ex:865`); the ~90 registry commands had none, and `exec/2` had no `rescue` at all. Live example on `main`: {r, _} = JustBash.exec(JustBash.new(), "head -n abc") ** (FunctionClauseError) no function clause matching in Enum.take/2 The host gets an exception naming an Elixir internal, which it cannot show to whatever drove the command. `invoke_builtin/5` now gives registry commands the same containment custom commands already had — `bash: head: command crashed (...)`, exit 1 — and `exec/2` has an outer net for raises from expansion, redirection and control flow. `Limit.ExceededError`, `Expansion.UnsetVariableError` and `ArithmeticError` are re-raised so their existing handlers keep producing the better diagnostic. **A non-final pipeline stage's stderr was dropped.** `cat /nope | cat` returned empty stderr; bash prints the diagnostic and exits 0. A crash inside a pipeline was therefore contained *and silent*. Every stage's stderr is now accumulated as iodata; only stdout is piped onward. The exit codes were already right — bash reports 0 for `cmd | cat`, `echo $(cmd)` and `if cmd; then fi`, so the tests assert that against real bash rather than "fixing" it. **`Limit` had no wall-clock bound.** Every bound counted work, so both historical hangs — `printf '%b'` recycling its format, `find` looping through a symlink cycle — consumed no steps and tripped nothing. `max_wall_ms` (default 5_000; 1_000 strict, 30_000 relaxed) arms a monotonic `Limit.Deadline` once per top-level `exec/2`, checked in the interpreter statement loop, in `FS.walk/3`, and in `find`'s recursion — the places where a script can burn time without doing countable work. Nested `eval`/`source` run inside the caller's budget. The check is one clock read and a comparison, no per-step timestamp.
…traversal, and exec!/2 Review findings 1, 2 and 3 on #73. The deadline was only consulted between statements, so "always terminates" was false for the one shape that matters: one command, many iterations. That is exactly the `printf '%b'` hang (56c74b8) issue #69 cites as motivation. Finding 1 — a loop inside a single command. `awk 'BEGIN{while(1){x=x+1}}'` and `awk 'BEGIN{for(i=0;i>=0;i++){}}'` hung past a 6s external kill under `limits: :strict`. The interpreter's statement loop is never re-entered while one command runs and the step counter charges the whole command as one step, so awk's `for`, `while` and `do-while` now check the wall clock per iteration. Audited the other candidates named in the review: sed has no label/branch support so `:a;ba` cannot loop, printf's format recycling terminates, and the expansion paths are bounded by the statement loop. `seq 1 100000000` is a real third instance — one command, 100M iterations — so its range walk goes through `Limit.enforce_deadline/2`. Finding 2 — `exec!/2` armed no deadline, so `interpreter.deadline` stayed nil and all three checkpoints short-circuited: a 30 ms budget ran for 1105 ms at exit 0. Arming is now shared with `exec/2` via `arm_top_level/1`. `exec!/2` still propagates raises — that is a bang function's contract — but the docstring now says so instead of promising only "raising on parse errors". Finding 3 — only `find` was bounded. On an 8000-file tree with a 1 ms budget, `grep -r` ran 4895 ms, `du` 5011 ms, `tree` 5425 ms and `cp -r` 8136 ms, all at exit 0 with empty stderr. Each hand-rolls its own descent, so each now checks the deadline in its recursive helper; `cp -r` recurses inside `FS.cp/4`, which gains a `:deadline` option for the purpose. `FS.walk/3`'s `:deadline` option is gone — it never had a caller in `lib/`, and `Limit.enforce_deadline/2` is the general mechanism, now reached from `seq`. Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
…ller Review finding 4 on #73. `execute_command_substitution/2` kept only the trimmed stdout, so a failure inside `$( )` was invisible in both directions: x=$(cat /nope); echo code=$? -> exit 0, stdout "code=0", stderr "" echo $(cat /nope) -> stderr "" x=$(boom) -> exit 0, stdout "", stderr "" GNU bash 3.2.57 prints the diagnostic in all three and reports code=1 for the first. The lost stderr is the same defect this PR already fixed for pipelines, still live in the sibling path. A substitution now hands back a `{:substitution, stderr, exit_code}` trace alongside the assignments an expansion already returns, so it travels out of arbitrarily nested expansions — `"$(...)"`, `${x:-$(...)}`, `$(( $(...) + 1 ))` — without a second channel. `Expansion.take_substitutions/1` splits the traces back out at the two places that consume them: - a simple command prepends the accumulated stderr to its result, *outside* `with_redirections/3`, because bash performs redirections after expansion and `echo $(cat /nope) 2>/dev/null` still prints the diagnostic; and - a bare assignment reports the last substitution's exit status as `$?`, which is what bash does when no command claims it. `export`/`local`/`declare` keep their own status, and a command keeps its own. Fifteen tests, each checked against the oracle on this machine. Two sibling paths still discard expansion side effects and so stay silent: array literals (`arr=($(cat /nope))`) and for-loop word lists (`for i in $(cat /nope)`). Both drop `${VAR:=default}` assignments too, which predates this PR, so they are left for their own change. Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
…telemetry Review findings 5, 7 and 8 on #73. Finding 5 — the `exec/2` rescue is a clause of the function's implicit `try`, so the only `bash` it can see is the parameter. Everything the script did was invisible to it: exec(b, "echo hi > /f.txt; export FOO=bar; wreck-env; echo $HOME") -> exit 1, stdout "", and the returned struct has neither /f.txt nor FOO The neighbouring `Limit.ExceededError` handler keeps prior work; this one silently retired a session. Containment moves down to the statement loop, into `Executor.run_statement/2`, which is where the last known-good shell actually lives — the accumulated output and the struct the previous statements left behind. It halts the script and preserves both, exactly like the limit handler. `exec/2`'s net stays as a last resort for raises outside the statement loop (parsing, the EXIT trap), where there is no session state to preserve. Because containment now lives in the interpreter, `exec!/2` gets it too, so its docstring no longer claims it propagates every interpreter exception. Finding 7 — `invoke_builtin/5`'s rescue sat *inside* `Telemetry.command_span/3`, so `:telemetry.span/3` never saw the exception and the documented `[:just_bash, :command, :exception]` event stopped firing for all ~90 registry commands. Verified: as merged, `wreck; cat /etc/hosts` emitted no exception events at all. The rescue moves outside the span as `contain_command_crash/3`, so the span emits the documented event and the crash is still contained. `execute_custom_command/5`'s own rescue folds into the same place, which means a host-supplied command that crashes is now visible in telemetry too — it never was. Both wordings are preserved ("command crashed" / "custom command crashed"). Finding 8 — `internal_error/2` built stderr straight from `Exception.message/1` and returned outside `Limit.track_output!`, so with `max_output_bytes: 100` it returned 1107 bytes. `inspect/1` allows 4096 bytes per binary, so a MatchError or KeyError carrying interpreter or FS state can inline sandbox file contents into a stderr the host asked to be capped — an information-disclosure edge on the sandbox boundary. The detail is now truncated to `min(512, max_output_bytes)`, on a UTF-8 boundary. Measured after: 100 bytes and 512. Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
Review finding 6 on #73. Three boundary inputs were chosen so all the interesting variants rendered the same result — the #69/#70 lesson repeating. `test "the deadline is rearmed for each top-level exec"` used `max_wall_ms: 200` with two execs of ~20 ms, so a deadline armed once still had ~175 ms left on the second call. Verified by patching `arm_top_level/1` to arm only when `deadline == nil`: `mix test test/sandbox_contract_test.exs` stayed at 61 tests, 0 failures. It now runs six execs of ~55 ms against the same 200 ms budget — each comfortably inside it on its own, so only rearming keeps them all green — plus a structural sibling asserting that each exec's `at_ms` is strictly later than the previous one's, which does not depend on how fast the machine is. Both go red against the arm-once patch; the structural one fails on exact equality. `test "limits: false disables the wall clock too"` ran 10 ms of work, so it could not catch `Limit.deadline(nil)` handing back a default-budget deadline. It now asserts the absence structurally. Verified by patching `deadline(nil)` to `deadline(defaults())`: the test goes red on `bash.interpreter.deadline == nil`. `assert elapsed_us < 2_000_000` guarded a 30 ms bound at 66x. Tightened to 300_000. Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
… function Keeps :telemetry's local-function warning out of the suite output, matching test/telemetry_test.exs. Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
Retracts an audit claim in 554d8d9. That commit said "the expansion paths are bounded by the statement loop". They are not, and thirty seconds of measurement refutes it. Under `limits: :strict`, on that commit's own tree: echo {1..100000} -> 18341 ms, exit 0, stderr "" echo {1..1000000} -> did not terminate within 30s echo {1..300}{1..300}{1..300} -> did not terminate within 30s Pre-existing, not a regression — `d0efbf2` measures the same — but it is an unfixed instance of exactly the class the finding asked to close, in the path the finding named, on a PR whose subject claims the class is retired. A two-command untrusted script that hangs the host is what #69 exists to retire. The statement loop cannot see this, for the same reason it could not see awk's `while`: a whole word is one step no matter what it names, and the expansion finishes before the loop is re-entered. `{1..1000000}` is twelve characters and a million words. Two instruments, because one word list has two ways to be too expensive. - Cardinality. A million words is counted work, not merely slow work, so `Limit.check_expansion_words!/2` holds one word's expansion to `:max_steps` — the bound that already counts work, rather than a sixth key. A range is measured before it is built (`range_size/3` computes what `expand_range/3` would allocate), because counting words as they arrive is too late when they arrive all at once: `echo {1..20000000}` costs 880 ms and ~1 GB to build and 0 ms to measure. A cartesian product is counted as the walk produces it, since `{1..50}{1..50}` has no range worth refusing. - The clock, checked per word produced, so a `max_steps` generous enough to permit the list still cannot be spent entirely on building it. Making the bounds reachable meant fixing the walk itself. Each level was concatenating its children's lists (`words_acc ++ new_words`), which is why 100k words took 18 s. One accumulator now threads through the whole cartesian walk, which makes it linear and — the actual point — gives both bounds a single place that sees every word at the moment it is produced. Also on the audit: glob expansion descends the filesystem itself, one directory read per wildcard segment, and had no deadline. It is the only traversal that runs before a command is chosen, so `find`, `grep -r`, `du`, `tree` and `cp -r` were all bounded in 554d8d9 while `echo /tree/*/*/*` on the same 2000-file tree ran 295 ms unbounded at exit 0. It now carries the deadline through the descent and is checked per matched entry, like its neighbours. The rest of the audit, measured rather than asserted this time. `$((10 ** 1000000))` is rejected by the arithmetic parser; `$((1 << 100000000))` raises SystemLimitError, which the internal-error handler contains at exit 1 in 2 ms; `${var//pat/rep}` and friends are bounded by the length of the value they operate on, which is bounded by `max_output_bytes` and `max_file_bytes`; brace expansion reached through `for i in {...}` and `a=({...})` is the same code path and is covered. After, same harness, same `:strict`: echo {1..100000} -> 7 ms, exit 1, word expansion limit exceeded (10000 words) echo {1..1000000} -> 0 ms, exit 1, word expansion limit exceeded (10000 words) echo {a,b}{1..50000} -> 0 ms, exit 1, word expansion limit exceeded (10000 words) echo {1..300}{1..300}{1..300} -> 3 ms, exit 1, word expansion limit exceeded (10000 words) for i in {1..200000}; do :; done -> 2 ms, exit 1, word expansion limit exceeded (10000 words) a=({1..200000}); echo done -> 0 ms, exit 1, word expansion limit exceeded (10000 words) `echo {1..5} {a..e} {x,y}{1..3}` and `echo {1..10}{1..10}` still match GNU bash 5.x exactly. Every probe runs under a `Task` with a timeout, so a regression fails the suite instead of wedging CI. Each of the four new checks was verified against its own deletion: dropping the per-word count fails "a product of ranges each inside the bound is still counted", dropping the range pre-measure fails "a range is refused before it is built, not after" on elapsed time, dropping the brace deadline fails "the wall clock still bounds a word list the cardinality cap allows", and dropping the glob deadline fails "glob expansion is bounded by the wall clock". Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
…pped The fix note said "All new probes use the existing bounded_exec/2 shape" and the describe comment said "Each probe runs under a task so a regression fails the test instead of wedging the suite." Four tests called JustBash.exec/2 directly, so neither was true for them. Measured, with the brace fix reverted (git checkout 16c85b3 -- lib/just_bash/interpreter/expansion/brace.ex), "a range is refused before it is built, not after" died with ** (ExUnit.TimeoutError) test timed out after 60000ms and a stacktrace through Brace.expand_with_brace/2, taking the file from 1.6s to 91.6s. A termination test should not take 60s to fail on the exact regression it guards. All four now use the bounded_exec/2 shape. The elapsed_us assertion needs a real measurement, so :timer.tc moved *inside* the Task: bounded_exec_timed/2 owns the Task.async + Task.yield 5s + brutal_kill + flunk, and bounded_exec/2 is that with the timing dropped. Under the same mutation the probe now flunks in 5.1s with "`echo {1..20000000}` did not terminate within 5s", and the file goes 91.6s -> 36.6s with the same 8 failures. Unmutated the file is unchanged at 1.6s, 73 tests, 0 failures. Claude-Session: https://claude.ai/code/session_013UQWCDMJdSCZhJVkeFpN8z
ddc4ee3 to
0f1e4c5
Compare
Closes #69
JustBash.exec/2owes a host two things when it runs untrusted script text: itreturns, and it returns a shell result rather than an Elixir exception. Neither
was contractual. Both are now, with tests as the net.
1. A raise from a builtin escaped
exec/2Only custom commands had a catch-all (
executor.ex:865). The ~90 registrycommands had none, and
exec/2had norescueat all — onlyParser.parseerrors were handled. The issue cites the
cpMatchErrorfrom #52 and adateFunctionClauseErrorfound during #67; both are fixed, but the hole they camethrough was not.
A live one still on
main:And, from the issue's own probe shape — a command that hands back a wrecked
JustBashstruct, so the next registry command is the one that raises:Fix. Containment happens at two levels, both chosen so that nothing already
done is lost:
Executor.contain_command_crash/3wraps command dispatch, giving registrycommands the treatment host-supplied ones already had:
bash: cat: command crashed (FunctionClauseError: ...), exit 1, scriptcontinues. It sits outside
Telemetry.command_span/3deliberately, so:telemetry.span/3still sees the exception and the documented[:just_bash, :command, :exception]event keeps firing — containing it insidethe span silently retired that event for every command in the registry.
execute_custom_command/5's own rescue folded into the same place, so ahost-supplied command that crashes is now visible in telemetry too; it never
was.
Executor.run_statement/2wraps everything else the statement loop runs —expansion, redirection, control flow — reporting
bash: internal error (...), halting the script, and keeping the output andthe shell state of the statements that already ran. That is why it lives in
the statement loop rather than in
exec/2: a rescue onexec/2is a clause ofits implicit
tryand can only see the parameter, so it silently rolls thewhole session back. This matches the neighbouring
Limit.ExceededErrorhandler, which already behaved that way.
exec/2keeps an outer net as a last resort, for raises outside the statementloop (parsing, the EXIT trap). Its stderr is truncated to
min(512, max_output_bytes)on a UTF-8 boundary:Exception.message/1isunbounded from the sandbox's point of view —
inspect/1alone allows 4096 bytesper binary — so a
MatchErrorcarrying interpreter or filesystem state couldotherwise inline sandbox file contents into a stderr the host asked to be capped.
Every rescue carries a comment saying this is deliberate containment at a trust
boundary, not defensive coding: a raise reaching them is a bug in JustBash, but a
host driving the sandbox has no way to act on an exception naming a JustBash
internal, so it must still get a shell-shaped answer. The type-specific rescues
stay —
Limit.ExceededError,Expansion.UnsetVariableErrorandArithmeticErrorare re-raised so their existing handlers keep producing the better message (and
so a limit breach keeps unwinding to halt the script).
Because containment now lives in the interpreter rather than in
exec/2,exec!/2inherits it. Whatexec!/2still propagates — aRuntimeErroron aparse error, and anything raised outside the statement loop — is now stated in
its
@docinstead of the old "raising on parse errors".Related: what composition actually loses
The issue reports the exit code being dropped through pipelines and command
substitution. Checked against the oracle first (GNU bash 3.2.57):
A pipeline reports its last stage, and a command reports its own status — so
those exit codes were already right, and the tests assert them against bash
rather than "fixing" them. A bare assignment is the one form where bash does
propagate the substitution's status, and it is the last substitution that wins
(
y=$(cat /nope) x=$(cat /nada)is code=1;x=$(cat /nope) y=$(echo ok)iscode=0).
export,localanddeclarekeep their own status.What was lost, in both paths, is the diagnostic:
execute_pipeline/2now accumulates every stage's stderr as iodata (onlystdout is piped onward).
2>/dev/nullon a stage still silences that stage,and
PIPESTATUSandpipefailare unchanged.execute_command_substitution/2now returns a{:substitution, stderr, exit_code}trace alongside the assignments an expansion already hands back,so it travels out of arbitrarily nested expansions —
"$(...)",${x:-$(...)},$(( $(...) + 1 )), backticks — without a second channel.Expansion.take_substitutions/1splits the traces out at the two consumers: asimple command prepends the accumulated stderr to its result outside
with_redirections/3(bash performs redirections after expansion, soecho $(cat /nope) 2>/dev/nullstill prints the diagnostic), and a bareassignment reports the last substitution's status as
$?.Still out of scope: array literals (
arr=($(cat /nope))) and for-loop wordlists (
for i in $(cat /nope)) discard expansion side effects entirely and staysilent. Both also drop
${VAR:=default}assignments, which predates this PR, sothat path wants its own change.
2.
Limithad no wall-clock boundEvery bound counted work. Both historical hangs burned wall clock without
consuming a step —
printf '%b'recycling its format (56c74b8),findlooping a symlink cycle (#53) — so nothing fired.
Fix.
max_wall_ms, defaulting to 5_000 ms (1_000 strict, 30_000relaxed). Documented in a bounds table in
JustBash.Limit's moduledoc. 5s isthree orders of magnitude above what any script in this repo's corpus takes:
generous enough that a legitimate script never trips it, small enough that an
agent waiting on the sandbox is never blocked for long.
A
Limit.Deadlinestruct (at_msfromSystem.monotonic_time/1, plusmax_wall_msso the diagnostic can name the bound) is armed once pertop-level execution — by
exec/2andexec!/2, which sharearm_top_level/1— and carried in
Interpreter.State.Limit.check_deadline!/1is then one clockread and an integer comparison: no timestamp per step, no allocation.
It is checked everywhere a script can burn time without doing countable work.
The statement loop is not enough on its own — a shell-level
while true; do :; donewas already bounded bymax_stepsandLoop's iteration cap, and theshape that actually escaped every bound is "one command, many iterations":
Executor.execute_statement/2);for,whileanddo-while(
awk 'BEGIN{while(1){x=x+1}}'hung forever), andseq's range walk(
seq 1 100000000), viaLimit.enforce_deadline/2. sed has no label/branchsupport, printf's format recycling terminates, and the expansion paths are
bounded by the statement loop;
find,grep -r,du,tree, andcp -r(whose recursion lives in
FS.cp/4, which gained a:deadlineoption). Awhole traversal is charged as a single step, so on an 8000-file tree with a
1 ms budget the unbounded ones ran 4.5-8.1 seconds at exit 0 with empty
stderr.
rm -rfis a bulk prune rather than a hand-rolled descent and isunaffected (3 ms on the same tree).
It raises
Limit.ExceededErrorwithkind: :wall_clock_limit, so the existingrescue in
execute_statement/2reports it and halts, exactly like the otherbounds:
Nested
eval/sourcerun inside the top-level call's budget rather thanstarting a fresh one.
Tests
test/sandbox_contract_test.exs, 62 tests. Every one was watched failing beforeits fix. Probes are synthetic and stable rather than leaning on any one command's
arg parsing, so a sibling PR fixing
headcannot silently retire them:Boom— the issue's custom-behaviour module, raises onexecute/3.Wreck/WreckEnv— return a valid result plus a corruptedJustBashstruct, so the raise happens inside a registry builtin, and inside
expansion, respectively.
Spin—Process.sleep(10)per call, for a deterministic wall-clock probe.Covering: containment for custom, registry and non-dispatch raises, and that an
internal error keeps the prior statements' output and session state (with the
Limit.ExceededErrorhandler pinned alongside as the reference behaviour);that a contained crash does not halt the rest of the script; that a limit breach
keeps its own diagnostic; that
head -n abcreturns a shell result; pipelinestderr for one, several and redirected stages, with
PIPESTATUSandpipefailunchanged; fifteen command-substitution forms checked against the oracle;
exec!/2's wall clock, containment and parse-error contract;max_wall_msdefaults and presets; awk's three loop forms and
seqbounded under aTasksoa regression fails rather than hangs; five recursive commands bounded on a
2000-file tree; that the deadline is rearmed per execution, asserted both
behaviourally and structurally; that
limits: falseleavesdeadline == nil;and that a contained crash still emits
[:just_bash, :command, :exception].The three wall-clock tests were each verified red against the mutation they are
named for — arming only when
deadline == nil, anddeadline(nil)returning adefault-budget deadline.
Gates
mix compile --warnings-as-errors --forcemix format --check-formattedmix credo --strictThe single finding is the intentional test fixture; identical on
main.mix testmix dialyzerNote for reviewers
JustBash.BannedCallTracer's grep heuristic flags= Systemon any sourceline, so
check_deadline!/1reads the clock in expression position rather thanbinding it. That is why the raise path calls
System.monotonic_time/1a secondtime via
elapsed_ms/2instead of reusing a bound value; the hot path stilldoes exactly one read.
FS.walk/3's:deadlineoption from the first round is gone: it never had acaller in
lib/, andLimit.enforce_deadline/2is the general mechanism, nowreached from
seq. Commands whose loop is not already an enumerable callcheck_deadline!/1directly.