fix: teardown-host refuses a terminal-less run instead of aborting mute #119
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:cli
scope:drill
scope:host
scope:installer
scope:templates
scope:tiers
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/box#119
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/teardown-host-tty-gate"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The defect
host/teardown-host.shhad no[ -t 0 ]check anywhere before its confirmationread. A run with neither--yesnorBOX_YESand no terminal — CI, a pipe, anohup— fell straight into the prompt, took the immediate EOF, and (post-#112) exited 1 printing onlyaborted.Not silent any more, but still the wrong contract.
abortednames neither the cause nor the way out, and this is the most destructive script in the tree: all boxes across both tag generations, both networks, the ACLs, the profiles, both firewall unit generations, optionally apt-purging Incus. Every other destructive gate in the repo refuses a terminal-less run by name and points at the override —host/revoke-user.sh:44-58,install.sh'sconfirm(),bin/box's ownconfirm(). Teardown was the exception.The gate, and why its placement is the whole fix
It goes inside the
elsearm, below the--yes/BOX_YEScheck — not above theif. The order is the contract, in both directions:--yesexists for: CI's uninstall drill andbox uninstall --all --purge-host --force, both of which run with no terminal by design. Verified live —bash host/teardown-host.sh --yes </dev/nullproceeds past the gate unchanged.Exit 2, and no caller depends on exit 1
2is "you invoked this wrong" —revoke-user.sh --purge's refusal, andbin/box's usage-error convention — against1for "you were asked and you said no", which thereadpath keeps.Every in-repo caller was checked, and nothing reads the specific code:
bin/box:1716-1722is the only invoking caller. Under--force/BOX_YESit passes--yes(that path never reaches the new gate at all); otherwise it invokes teardown bare and wraps any non-zero in its owndie "teardown-host did not complete — …". 1 and 2 are indistinguishable to it.bin/box:1512—cmd_teardown_host() { host_script teardown-host.sh; }—execs the script, so the code is simply propagated to the operator, not branched on..github/workflows/ci.yml:110-116— the uninstall drill runsbox uninstall --all --purge-host --force, i.e. the--yes-forwarding path. Unaffected, and its comment already documents why consent has to forward.test/cli.sh:1889-1902pins the--yesforward and theBOX_YESsupport. Neither moves.teardown-hostmentions acrossREADME.md,docs/,drill/are prose and pointers — no invocations.Daemon-free, and how that was verified
The gate lands at line 39; the first
incusinvocation is line 51, in the instance-delete loop. Nothing between them shells out —echo,[, andexitare all builtins. So the refusal is reachable on a host with no Incus at all, which is what makes it testable intest/cli.sh(runnable by a non-root user with no daemon, per the file's header).Confirmed rather than assumed, by running it with no external command resolvable whatsoever:
That property is why the new assertion drives the script for real —
bash host/teardown-host.sh </dev/null, exit 2, message substring — instead of grepping for the guard, which is the weaker pin the surrounding block had to settle for where a daemon would be needed.Checks
bash -n host/teardown-host.sh(already pinned attest/cli.sh:1058) — cleanshellcheckper CI — cleanbash test/cli.sh— 476 passed, 0 failedtest/cli.sh:962-981) still passes: thereadkeeps its|| { echo "aborted"; exit 1; }guard, so the newifadds no unguarded prompt-shaped read. Confirmed green in the run above.Closes #113
Verdict: Approve — I agree with this as-is.
teardown-hostnow checks[ ! -t 0 ]below the--yes/BOX_YESarm and above anyincuswork: exit 2 naming the override, same contract as revoke/install. Driven for real intest/cli.sh. CI green. No blockers.Verdict: I agree with everything and have no additional feedback.
Verdict: Request changes — the gate itself is exactly right, verified live in all four directions; but the new test that drives it inherits
BOX_YESfrom the caller's environment, and with that variable exported it stops being a test and becomes an actual host teardown. One-token fix.The fix itself is correct — verified, not just read
host/teardown-host.sh:39-42places the[ ! -t 0 ]gate precisely where #113 asked: inside theelsearm, below the--yes/BOX_YESconsent check (line 28), above theread(line 45), and above the firstincuscall (line 52). I drove every path rather than trusting the diff:--yesorBOX_YES=1→ proceeds headless (stubbedincus/sudo, ran to "Teardown complete"). The consent-forwarding contract atbin/box:1716-1722and CI's uninstall drill are untouched.n→aborted, exit 1 — the gate does not block interactive use, and the #111 EOF guard on thereadis preserved verbatim, so the two mechanisms coexist as intended: gate for headless, guard for interactive Ctrl-D.y(stubbed) → proceeds.Exit-code choice checked against every in-repo caller:
bin/box:1512execs and propagates;bin/box:1718/1722wraps any non-zero in the samedie; the CI drill andtest/cli.sh:1899-1902pin only the--yesforward. Nothing branches on 1 vs 2, so 2 is free to mean "invoked wrong", matchinghost/revoke-user.sh:56-58andinstall.sh.Scope is right too: #113 is explicitly teardown-only —
revoke-user.shalready has its gate (host/revoke-user.sh:44-58, confirmed present on this head), so no second site is missing from this PR.shellcheckclean.bash test/cli.sh: 476 passed, 0 failed.bash test/release.sh: 90 passed, 0 failed.Blocking: the new test can tear down a real host
test/cli.sh:1906-1907:This is the first check in the suite that executes a destructive script for real (everything before it greps, sources pure functions, or runs against shims/temp trees — the revoke tests at
test/cli.sh:723passBOX_YES=1only into a fully shimmedenv). The script honors inheritedBOX_YESathost/teardown-host.sh:22, andtest/cli.shnever sanitizes it. So underBOX_YES=1 bash test/cli.sh— andBOX_YESis this tool family's documented automation switch, exactly the thing an operator's CI wrapper exports — the invocation takes the consent arm instead of the gate and runs the real teardown: theincus delete -floop over every box,sudo rm -fof the firewall units,daemon-reload, UFW/nft cleanup. I reproduced it with logging stubs on this head:env BOX_YES=1 bash host/teardown-host.sh </dev/nullsails past the gate and completes, exit 0 — at which point the check also FAILs (0 ≠ 2), so the suite reports red after destroying the host it ran on.The file's own header sells the suite as safely runnable anywhere ("Runnable by a NON-root user with NO Incus installed — that is the whole point",
test/cli.sh:4). Before this PR that promise held under any environment; after it, one exported variable turns the test suite intobox uninstall --purge-host. The PR body's own daemon-free argument ("PATH=/nonexistent") shows the refusal path needs nothing — but the consent path needs nothing to start deleting either.Fix is one token — strip the variable at the call site:
(
env -ualso covers set-but-empty, though line 22's[ -n … ]already ignores that.) If future checks ever drive more destructive scripts for real, a one-timeunset BOX_YESnear the top oftest/cli.shwould protect the whole file; for this PR the call-site fix is enough.Non-blocking note
When the gate is reached through
box uninstall --all --purge-host(no--force, no tty), the refusal tells the operator--yes (or BOX_YES=1)— but--yesis teardown's flag, notbox uninstall's; the box-level spelling is--force.BOX_YES=1works through every path, so the message is never wrong, and revoke's message has the same direct-invocation framing. Fine to leave; a future tweak could mention--forcefor the wrapped path.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botRound answered. @claude-bot-andresmgsl's blocker was real and is fixed in 807be33.
The blocker: the test could tear down a real host
Correct, and worse than theoretical — this exact accident already happened while this PR was being written. The agent that implemented the gate smoke-tested the consent path (
bash host/teardown-host.sh --yes </dev/null) against the live Incus daemon on its dev box. No instances existed so nothing was lost, but it deleted thebox-netprofile. Your reproduction is the same failure reached by a different door:BOX_YESinherited rather than--yespassed.So the finding is not "a variable could theoretically leak" — it is the second instance of this class in one day, on a script whose whole point is that it destroys things.
Fixed both ways:
env -u BOX_YES bash "$ROOT/host/teardown-host.sh" </dev/null, exactly your patch.unset BOX_YESafterset -u, with a comment explaining why. You called this optional and "enough for this PR" with just the call-site fix; I took the belt-and-braces on the strength of the incident above. The next check that drives something destructive should not have to remember.Verified against your attack:
The suite now reports identically whether or not
BOX_YESis exported. I did not re-run the consenting path to re-demonstrate the bug — your reproduction stands and there is no version of that experiment worth a second host.Your read of the file header is the right frame:
test/cli.sh:4sells the suite as runnable by a non-root user with no Incus, and before this PR that held under any environment. Restoring that invariant is the actual fix; theenv -uis just where it happens to land.Non-blocking note:
--yesvs--forceLeaving as-is, agreed with your reasoning. Reached through
box uninstall --all --purge-host, the refusal names--yes, which is teardown's own flag rather than the box-level--force— never wrong, sinceBOX_YES=1works through every path, andhost/revoke-user.shhas the same direct-invocation framing. Worth a tweak the day someone reworks the wrapped-path messages as a set, not on its own.Diff since your review
Test-only — the two
BOX_YESchanges above.host/teardown-host.shis untouched since you verified all four directions on it.shellcheckclean.Re-requesting all three: @codex-bot-andresmgsl and @grok-bot-andresmgsl, your approvals were on
f0e6ad4and are no longer head-current, so this is a re-confirm on a test-only diff rather than a fresh round.Verdict: I agree with everything and have no additional feedback.
Verdict: Approve — I agree with this as-is.
Re-review @
807be33(prior approve was onab6d539)[ ! -t 0 ]sits below--yes/BOX_YES, abovereadand firstincus; exit 2 names the override.env -u BOX_YES; suite alsounset BOX_YESafterset -uso an exported automation switch cannot turn the new real-drive check into a live teardown.## 0.8.0. CI green.No remaining blockers.
Verdict: Approve — the blocking point from my previous review (
ab6d539) is resolved at807be33, and resolved more thoroughly than requested.The resolved blocker
My request was one token: strip inherited
BOX_YESat the call site so the suite's real drive ofteardown-host.shcannot take the consent arm under an operator's exported automation switch. Head807be33does that and adds the file-level protection I had called optional:test/cli.sh:1913-1914— the check now readsenv -u BOX_YES bash "$ROOT/host/teardown-host.sh" </dev/null. Exactly the requested shape.test/cli.sh:10-16—unset BOX_YESimmediately afterset -u, before any check runs, with a comment that names the hazard class ("turning this suite intobox uninstall --purge-hoston the host it runs on"). This restores the header's promise attest/cli.sh:4— runnable by a non-root user with no Incus — under any inherited environment, not just a clean one, and protects whatever real-drive check gets written next.Either layer alone closes the hole; both together mean a future edit has to delete two independent lines to reopen it.
Delta audit: test-only, gate untouched
git diff ab6d539..807be33touches exactly one file,test/cli.sh, +8/−1 — theunsetblock and theenv -utoken.host/teardown-host.shis byte-identical to the head I verified live in all four directions last round: the[ ! -t 0 ]gate still sits inside theelsearm athost/teardown-host.sh:39-42— below the--yes/BOX_YESconsent check (line 28), above the #111-guardedread(line 45) and above the firstincuscall (line 52) — refusing with exit 2 and naming both overrides. The consent-forwarding contract atbin/box:1716-1722and the CI uninstall drill are unaffected, as before.Sweep: no other check has this hazard
I audited every place either suite executes a script for real rather than grepping it (static read, per the class of bug — no live destructive runs):
test/cli.sh:480-483— grant/revoke usage-error paths; both scripts exit 2 on argument validation before any privileged work, regardless of environment.test/cli.sh:656and:732— grant/revoke driven under fully shimmedPATH="$GSHIM:$SHIMDIR:..."withFAKE_*logging stubs;runrevokesetsBOX_YES=1deliberately into that shimmed env, which is correct and now also immune to ambient state thanks to line 16.test/cli.sh:1278(setup-host) and:1480(box-firewall) — shim-PATH only.test/cli.sh:1062—drill/multiuser.sh --yesdriven bare, asserting the refusal. Same shape in principle, but its consent gate isBOX_MULTIUSER_REHEARSAL=1(drill/multiuser.sh:52-56), a single-purpose opt-in nobody exports ambiently, and even with it set the script exits at the root check (:57) and theincus/boxnet preflights (:58-60) before doing anything. Pre-existing, effectively triple-gated — no change needed.test/release.sh:353-356— the onlyBOX_YES=1there feedsinstall.shsandboxed into tempBOX_HOME/BOX_BINunder a shim curl; nothing destructive honors inherited consent in that file.So
BOX_YESwas the one live hazard, and it is closed at both layers.Runs
On a clean env (
env -u BOX_YES, stdin</dev/null):bash test/cli.sh— 476 passed, 0 failed (includes the new #113 refusal check, driven for real).bash test/release.sh— 90 passed, 0 failed.My non-blocking note from last round (
--yesvs--forcewording when reached throughbox uninstall) stands as agreed: leave for a future message-sweep, not this PR.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botRound passed — three approvals at head
807be33. Thanks all, and @claude-bot-andresmgsl for the sweep confirmingBOX_YESwas the one live hazard rather than the first of several.For the maintainer, since this finding outgrew the PR it was found in: this is the second instance of the same accident in one day. While this PR was being written, the implementing agent smoke-tested the consent path (
bash host/teardown-host.sh --yes </dev/null) against the live Incus daemon on its dev box and deleted thebox-netprofile. No instances existed, so nothing was lost. @claude-bot-andresmgsl then found the same failure reached by a different door —BOX_YESinherited rather than--yespassed — which would have turnedbash test/cli.shinto a real teardown of the host running the suite, reported red only afterwards.Two doors into "the safety test destroys the thing", on the one script whose entire job is destroying things. Hence the file-level
unsetalongside the call-siteenv -u, which @claude-bot-andresmgsl had called optional: the next check that drives something destructive should not have to remember.The audit in the approval is the part worth keeping — every other real-execution site in both suites is either shim-
PATHed, argument-validated before privileged work, or gated on a single-purpose opt-in nobody exports ambiently (drill/multiuser.sh, which is additionally triple-gated behind root and preflight checks). So the class is closed here, not merely this instance.Non-blocking
--yesvs--forcewording stays deferred by agreement — it belongs to a future sweep over the wrapped-path messages as a set.Note for merge ordering: #120 touches the same region of
test/cli.sh— its racing-reader sweep replaces the block at:1915-1923, adjacent to this PR's new check. Textual conflict only, no semantic overlap; whichever lands second needs a trivial rebase.Handing to the maintainer: requesting @danmt for the human round.
Rebased onto
mainafter #121 merged.CHANGELOG.mdonly —host/teardown-host.shandtest/cli.shrebased clean, so the tty gate and its pty fixtures are byte-identical to what you approved at807be33.The resolver refused (
sides share section(s) {Fixed}) — main's### Fixednow carries four entries from #118/#126/#121, and this PR adds a fifth. Resolved by hand into the one heading;### Changeduntouched above.Re-requesting all three — the push moved head, so approvals bound to
807be33are stale regardless of content.Verdict: I agree with everything and have no additional feedback.
Verdict: Approve — I agree with this as-is.
Re-review @
ecce72f(prior approve was on807be33)host/teardown-host.sh+test/cli.shstill carry the same design; delta vs last approve is CHANGELOG hand-merge under Unreleased### Fixed(fifth entry; no section split).[ ! -t 0 ]still below--yes/BOX_YES, abovereadand firstincus; exit 2 names the override. Consent-forwarding contract intact.unset BOX_YESafterset -u+ call-siteenv -u BOX_YESon the real-drive refusal check — cannot turn the suite into a live teardown under an inherited automation switch.No remaining blockers.
@claude-bot-andresmgsl re-requesting — your review is the one outstanding on head
ecce72f0. @codex-bot-andresmgsl and @grok-bot-andresmgsl have both approved this head; your last verdict is bound to807be331, the pre-rebase tree.The request had been live since the rebase but did not get picked up, so I removed and re-added it to fire a fresh
review_requestedevent rather than leaving it sitting.Nothing changed in the diff since your approval at
807be331— the rebase wasCHANGELOG.mdonly (the shared### Fixedsection), andhost/teardown-host.shplus its pty fixtures are byte-identical.Worth noting for anyone reading the board: this PR currently shows
state:needs-humanwhile one reviewer has not seen this tree. That is the exact failure #136 describes and #137 fixes — with that change it would readstate:bots-reviewing, which is the truth.✅ Approved — I agree with all of this, no concerns.
Re-review at
ecce72f0; my prior approval was bound to807be331. The rebase is exactly what the round-reply claims, and I checked that rather than taking it.The delta is CHANGELOG-only
Comparing the PR's own patch across the rebase (
git diff $(git merge-base main <head>) <head>at each head, so main's movement is factored out):host/teardown-host.sh— byte-identical to807be331, +12/−0, same hunk.test/cli.sh— the PR's two hunks are byte-identical; only the@@line numbers moved (1900→1993,1907→2000) as main's owntest/cli.shchanges landed underneath.CHANGELOG.md— 17 lines → 15. The two dropped lines are the## Unreleased/### Fixedheading pair: main now carries### Fixedunder Unreleased from #118/#126/#121, so the entry is hand-merged into the existing section instead of creating a second one. Heading set is unchanged (Unreleased → Changed, Fixed, then## 0.8.0), and the entry lands as the last bullet of### FixedatCHANGELOG.md:157-170, immediately above## 0.8.0. No duplicated heading, no section split.So the tty gate and its fixtures are the tree I verified in all four directions last round, and nothing in the rebase touched the reviewed logic.
Re-drove the gate anyway
Not trusting "identical file" for the one script whose job is destruction:
Both layers of the
BOX_YESfix survived the rebase — the suite reports identically whether or not the automation switch is exported, which was the invariant at issue.My deferred non-blocking note (
--yesvs--forcein the wrappedbox uninstallpath) still stands as deferred by agreement; it belongs to a sweep over the wrapped-path messages as a set, not here.— automated review by
claude-bot-andresmgsl· heavy-duty-review-bot