Repository navigation
Interrupt autoboot by being early, not by being clever - #18
Merged
Merged
Conversation
`analyze --interrupt-autoboot` takes the U-Boot prompt, pulls the environment,
prints the boot-chain verdict, and hands the terminal back. That completes the
loop `bootintel verdict` opened: capture to verdict without the operator typing
anything, and without a byte leaving the machine.
The obvious implementation does not work, so this does not do it. U-Boot's
autoboot delay is a loop around `tstc()`, and `bootdelay=0` means the check
happens ONCE. Boards ship that way. By the time "Hit any key to stop autoboot"
has crossed the wire, been read by this process and been recognised, the window
is gone: at 115200 a character is already ~87us of wire time, and the read is
scheduled by the OS, not by us. What works is that the byte is already in the
UART's receive register when the board looks, so this hammers from the instant
the port opens and asks the operator to power-cycle after that starts. There is
nothing to detect, which is the point: detection is the part that is too slow.
`--reset-line dtr|rts` removes human timing entirely where the adapter is wired
for it.
Split by requirement rather than by layer:
* the hammer is a thread that writes one byte string on a timer and holds no
logic at all, because it is the only part that has to be fast;
* every decision is a pure state machine over the received bytes with no
clock and no I/O, because that is the part that has to be right, and it can
then be tested against a simulated board instead of a bench.
`timing_tests` runs that simulation: a board whose check fires once, at seven
different instants from 1ms to 900ms, caught every time. The contrast test arms
the hammer only after the countdown is visible, the design this replaces, and
misses.
Safety, because this runs against a client's only sample of a device. The
hammer never sends CR or LF (refused when the key is parsed, and asserted):
hammered bytes accumulate in U-Boot's line buffer and a newline would execute
whatever they spell. The default key is a space, the default commands are
read-only, a bare CR flushes the accumulated bytes before anything is typed,
and every byte the tool sends is announced so a client transcript shows which
were the tool's.
Two false-positive guards, both from thinking about what actually appears on a
UART. A `#` prompt after the kernel handoff is a Linux shell, so it is ignored
and hammering continues until a fresh bootloader banner clears the latch;
typing `printenv` into a root shell would have produced a confident, wrong
verdict. And a prompt that does not answer re-arms rather than abandoning the
attempt, since `Loading: #` mid-transfer and a board that resets under us are
both cheaper to retry than to give up on.
Verified end-to-end against a socat PTY pair and a simulated bootdelay=0 board:
caught the single check, ran all three commands, printed the verdict, and the
board saw only those three commands despite 594 buffered spaces and a stray
0x04 from the harness. The missed-window path was verified too, by getting the
harness ordering wrong first: it reported the miss with the four things worth
checking and never implied success.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zenofex
added a commit
that referenced
this pull request
Sep 28, 2026
Version bump, lockfile, changelog, and one docs fix. No code changes: everything in this release is already on `main` and was reviewed in #17 and #18. ## What ships | | | | --- | --- | | #17 | `bootintel verdict <capture>` — assess a U-Boot session offline | | #18 | `analyze --interrupt-autoboot` — take the prompt and pull the environment | Together: bench to verdict with no upload and no typing. ## Why MINOR Two new subcommands' worth of surface and no behaviour change for anyone who does not use them. The policy at the top of `CHANGELOG.md` reserves PATCH for changes with no user-visible behaviour change, and MAJOR for renamed flags, schema changes, or exit-code policy changes. ## The one non-mechanical change `crates/cli/Cargo.toml` pins `bootintel-detectors` by exact version, because the published binary crate has to name a version that exists on the index. That bump is part of cutting a release rather than something cargo derives, and forgetting it fails `cargo update -w` immediately, so it cannot reach a release quietly. It is still the step that gets missed, so `docs/releasing.md` step 1 now names it. ## Verification - `cargo test --workspace`: 349 passed, 0 failed - `cargo clippy --workspace --all-targets -- -D warnings`: clean - `cargo fmt --all --check`: clean - `cargo build --release` then `bootintel --version` reports `bootintel 0.8.0` After merge: dispatch `cli-release` for `0.8.0` with `publish_crates`, verify the draft against `SHA256SUMS`, publish and mark latest, then move the Homebrew formula. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zenofex
added a commit
that referenced
this pull request
Sep 28, 2026
Follows #17/#18. The engine learned to read what the bootloader actually verified ([BootIntel.com@fd96903](BootIntel/BootIntel.com@fd969038)); until the CLI does too, the offline verdict says "I cannot tell whether images are checked" about captures that answer the question themselves, and the two implementations disagree about the same device. ## What the CLI now reports ``` confirmed Image verification The bootloader checked the image before booting it, using a legacy uImage CRC, and the check passed. That proves the image was not corrupt. It is not a signature: anyone who can write the image can recompute the checksum, so this stops bit-rot rather than an attacker. read from Verifying Checksum ... OK exposed Secure boot anchor The SoC reports the HAB fuse is not enabled, so the boot ROM will run an unsigned image. Whatever the bootloader does about checksums afterwards is advisory: the chain has no anchor. read from hab fuse not enabled ``` A passing checksum is `confirmed`, never `hardened`. Only a verified signature (`sha256,rsa2048:dev+ OK`) is `hardened`. The anchor entry is emitted without needing a `printenv`, since the fuse fact does not depend on one. ## The fixtures are the point Neither is synthetic: | Fixture | Source | What it pins | | --- | --- | --- | | `hab-unblown.log` | bootintel-7 lines 1-45 | prompt reached with **no** environment, unblown HAB fuse, and a harness line quoting `'Bad Linux ARM64 Image magic!'` that must not trip the anchored failure pattern | | `verified-image.log` | bootintel-20 lines 55-160 | environment provable only retroactively, plus a **failed** check (`Bad Magic Number`, from an operator's `iminfo`) and a **passed** one in that order | Both implementations reproduce `expect.txt` byte for byte across five fixtures. That is the first time the guarantee has covered anything but a synthetic capture and two environments. ## A bug found while porting The engine's signature pattern included `## Checking (hash|sign)`, and U-Boot's ordinary FIT output is `## Checking hash(es) for FIT Image at ...`. Every device using **unsigned** FIT hashes would have been reported as having had a signature verified, and the verdict would have told a client authenticity was established. No corpus log prints that line, which is exactly why it survived review: absence of evidence read as correctness. The real tell is the algorithm list, so that is what both sides key on now, with a test for each shape. ## Breaking, library only `boot_chain::assess` returns an `Assessment { session, integrity, verdicts }` instead of a tuple, and `verdict` takes the integrity alongside the session. The alternative was a second entry point for the same operation; two functions differing only in how much they tell you is worse for whoever reads it next. **No CLI flag, output or exit code changed.** `verdict --json` gains a `boot_integrity` object mirroring the engine's key names, omitting fields the capture said nothing about rather than emitting nulls. Under the policy at the top of `CHANGELOG.md` that puts the next release at 0.9.0. ## Verification - `cargo test --workspace`: 349 passed, 0 failed - `clippy --workspace --all-targets -- -D warnings` and `fmt --check`: clean - Engine side green at 418 passed in `ci-api-regression`, pushed and deployed - Ran the built binary against both new fixtures 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
bootintel analyze <port> --interrupt-autoboottakes the U-Boot prompt, pulls the environment, prints the boot-chain verdict, and hands the terminal back. This closes the loop #17 opened: capture to verdict with no typing and no byte leaving the machine.Why it is not "watch for the countdown, then send a key"
That is the obvious design and it does not work. The autoboot delay is a loop around
tstc(), andbootdelay=0means the check happens once. Boards ship that way. By the time the countdown has crossed the wire, been read by this process and been recognised, the window is gone: at 115200 a character is already ~87us of wire time, and the read is scheduled by the OS, not by us.What works is that the byte is already in the UART's receive register when the board looks. So this hammers from the instant the port opens and asks the operator to power-cycle after that starts. There is nothing to detect, which is the point.
--reset-line dtr|rtsremoves human timing entirely, where the adapter is wired to the board's reset.Split by requirement, not by layer
src/term/autoboot.rs: a pure state machine over received bytes, no clock, no I/OWhich is what makes the timing claim testable.
timing_testssimulates a board whose check fires once, at seven instants from 1ms to 900ms: caught every time. The contrast test arms the hammer only after the countdown is visible (the design this replaces) and misses.Safety, on a client's only sample of a device
printenv,bdinfo,mtdparts).--at-promptreplaces that set entirely, so the operator decides exactly what is typed.--interrupt-autobootare refused rather than ignored: someone who passed--interrupt-key stopand got a plain terminal would reasonably think the tool tried.Two false-positive guards
#prompt after the kernel handoff is a Linux shell. It is ignored and hammering continues until a fresh bootloader banner clears the latch. Typingprintenvinto a root shell would have produced a confident, wrong verdict.Loading: #mid-transfer and a board that resets under us are both cheaper to retry than to give up on.Verification
cargo test --workspace: 349 passed, 0 failed (was 324).clippy -D warningsandfmt --checkclean.bootdelay=0board: caught the single check, ran all three commands, printed the verdict. The board saw only those three commands despite 594 buffered spaces and a stray0x04from the harness, which is the CR flush doing its job.Not wired into
--tuiyet; that combination is refused rather than silently ignored. No version bump.🤖 Generated with Claude Code