Skip to content

v2.4.8

Choose a tag to compare

@randomittin randomittin released this 19 Oct 20:54
· 183 commits to main since this release

The resume release. A stopped or crashed laptop client no longer unpairs the phone, and neither does Claude Code ending, restarting, resuming or sitting idle: each phone's relay login is stored outside the repo and resumed, by default with no age limit, and only an explicit disconnect, a revoke from the phone or a login the relay refuses unpairs. The push gate now grades each push against the repo being pushed, with that repo's own checks, and arms for more ways of typing a push. The SessionStart pair window sends your gh token only for a repo you ran hmd app connect in, and only when pairing needs it. A disk-space floor (10% free by default) stops agent spawns and test runs on a full disk. Users must re-pair phones once after upgrading.

Added

  • A repo can declare its own checks, and the push gate passes it on a record of them — .heimdall/repo-checks.json ({"schema":1,"checks":[{"name","cmd"}]}, committed) plus bin/heimdall-repo-checks run|status [--repo PATH]: run executes each check from the repo root in order and writes .heimdall/receipts/repo-checks.json atomically (head_sha, tree_sha, decl_sha256, dirty, exit_code, finished_at, per-check exit_code/secs); status says whether it is green and current. The gate recomputes the commit, its tree and the committed declaration's hash from git objects and also requires a clean tree at run time, a green exit and exactly the declared checks; a stale, red, dirty, missing, mismatched, malformed or invalid record blocks and names the runner. A receipt attests ONE commit: the hook hands the commits the push sends as sources to status --pushes (each refspec's source resolved by git, HEAD for a bare push) and every one must be the receipt's head_sha; --all, --mirror, --tags, a wildcard refspec, an option it cannot read and push configuration that picks the refs (push.default=matching, any remote.<name>.push) cannot be tied to one receipt and block. The receipt's fields, types and formats are defined ONCE, in bin/lib/hmd_repo_receipt.py, which run builds the receipt with and status verifies it against; a test walks that definition (a receipt run writes passes, every field mutated alone fails). Like last-sweep.json it is a cooperating-agent control, not a security boundary against someone who can write the file. Test: test/heimdall-push-gate-per-repo.test.sh.
  • A disk-space floor: agent spawns and test runs stop below 10% free — HMD_MIN_FREE_PCT (a whole percent, default 10, 0 turns it off; a value that is not a whole number from 0 to 100, such as 7%, is the default and says so on stderr, never a silent off) is checked against the free percent (df -Pk, Available ÷ size) of the volume holding TMPDIR and of the one holding the repo; bin/lib/hmd_disk_floor.sh is the one definition. It is enforced in three places: the Agent hook (bin/heimdall-precheck-agent) denies a spawn; the Bash hook (bin/heimdall-precheck-bash) denies a command that RUNS a *.test.sh or test/run-all.sh, while text that only mentions one is not a run; and test/run-all.sh refuses to start (exit 5) beside its absolute HMD_TEST_MIN_FREE_GB floor (default 5; whichever is stricter refuses), then asks again before every suite and every solo re-run. Once a floor is crossed mid-sweep nothing more is launched, the suites not yet started are REFUSED (a table row, suites_refused and disk_floor in the receipt, and in non_green_suites) and the run is red (exit 1), so a sweep cut short never reads as one that passed. A hook reads its own environment, so set the knob where Claude Code runs: an inline HMD_MIN_FREE_PCT=5 bash test/x.test.sh reaches the test, not the hook that has already said no. A df that cannot answer warns and allows (a resource guard, not a security gate). bin/heimdall-disk-floor check shows where you stand. Test: test/disk-floor.test.sh.

Changed

  • hmd app: a stopped laptop relay client no longer unpairs the phone. Once a phone is bound, the client's relay session login (bearer
    token, hmd key, seq marks) is stored outside the repo -- the macOS Keychain (written over security -i stdin, never argv), else a 0600
    file under $HEIMDALL_HOME (bin/lib/hmd_session_login.py; HMD_SESSION_LOGIN_STORE=file|keychain|auto picks which, default auto: the Keychain on macOS when security is on PATH,
    else the file; test/run-all.sh defaults it to file, so a sweep never touches the operator's Keychain). A TERM, Ctrl-C, KILL or crash revokes nothing; hmd app connect and its
    respawns start the client with --resume, which reattaches to the same relay session without pairing again, however long ago the old
    client was last alive: there is no age limit unless the operator sets HMD_SESSION_RESUME_H (hours, per phone; 0 = never resume a stopped client's
    login), and the relay's own 30-day device-token lifetime is the one clock left. Only hmd app disconnect, hmd app disconnect --device, a revoke from the phone or a refused login unpair:
    disconnect revokes at the relay through the stored login, also for a crashed client (it used to say "could NOT be revoked"). SIGUSR1 is
    no longer a revoke trigger. No relay change, no relay deploy needed.
  • hmd app disconnect --forget-local: the explicit way out of a stored login whose relay revoke can never be confirmed (stuck revoking, a relay that stays
    down or refuses it). Without the flag nothing changes -- an unconfirmed revoke still fails loudly and keeps the login -- and no other flag implies it. With it
    (all phones, or one with --device) the revoke is tried first; a login still unconfirmed is dropped here: the relay session id, a warning that the phone can
    keep connecting until that session expires and when (absolute UTC, the stored session expiry: the bind plus the relay's 30-day device-token lifetime) are
    printed, the stored login (Keychain item and file) is deleted under the store lock, and a session_login_forgotten event (relay-events.jsonl) and a line in
    $HEIMDALL_HOME/logs/app.log record session, device, expiry and that the relay was NOT told. Exit 0 only once the delete and its log succeeded. Test:
    test/app-session-forget-local.test.sh.
  • hmd app: every paired phone comes back by itself after a restart, not only the first. A relay session binds one phone, so each phone has a client of its own and the
    login of its session stored; hmd app connect (also its supervisor's respawn, and its look every HMD_RESUME_RECONCILE_S seconds, default 15) and the SessionStart pair
    window now start one heimdall-relay-client --resume-only for every stored login that no live client serves, each resuming its own phone's session (its own seq blocks and
    store lock, no re-pair, no nonce reuse) however long ago that phone's client was last alive (an operator-set HMD_SESSION_RESUME_H bounds it, per phone). Phones added with
    hmd app add-device are included. --resume-only never pairs: with no login left to claim it says resume_none and exits 0. Test: test/app-session-multi-resume.test.sh.
  • hmd app disconnect and --forget-local can no longer race a resume (security review of the multi-phone resume): a disconnect MARKS FIRST -- every stored login
    goes revoking in one hold of the store lock, before a window, the supervisor, a client or the relay is touched -- so no resume claims it afterwards (a periodic look,
    a respawn, the pair window and a --resume-only client all claim under that same lock and skip what is marked or gone); then the connect supervisor (its pid is
    pid_watcher in connect.json) is stopped and waited for, then the clients, and only then is the relay asked to revoke (or the login dropped), so nothing is
    left running that could reattach, respawn or look again; a connect's periodic look also stops once connect.json is gone. A client that got through anyway ends
    itself, revoking its session, within HMD_SESSION_LOGIN_CHECK_S (2 s) of its login being marked or gone; a store it merely cannot read ends nothing. Tests:
    test/app-session-revoke-race.test.sh.
  • A paired phone survives Claude Code ending, restarting, resuming or sitting idle — the repo's pair window belongs to the repo, not to a session, and a paired phone is never let go of by a session ending, a restart, a resume or idle time: the SessionEnd hook leaves the window, its supervisor and the phone's client running, the supervisor brings the client back if it dies, and the next session finds the same code and the same relay session. Each repo keeps a pairing record, $HEIMDALL_HOME/app/repos/<sha256 of the repo's real path, first 16 hex>.json (0600: the repo's session code and the relay session and device ids of its phones; written when a phone binds, removed with the last stored login); while it exists every surface shows ITS code, so a regenerated machine seed cannot change the code of a repo with a paired phone. Only hmd app disconnect, a revoke from the phone or a login the relay refuses ends a pairing, and each stop of a window or a client leaves a line in $HEIMDALL_HOME/logs/app.log saying what ended it and whether its session was kept or revoked. HMD_PAIR_WINDOW_IDLE_H now defaults to 0, no idle shutdown (the window used to stop after 4 hours); set above 0 it is an operator's bound that applies only while no phone is paired. Users must re-pair phones once after upgrading: a phone paired by an earlier release has no stored login to resume (its login lived only in the running client's memory) and its repo has no opt-in record, so run hmd app connect in the repo; from then on a restart resumes it. Tests: test/app-session-survives-cc-restart.test.sh, test/app-session-resume.test.sh.
  • The SessionStart pair window no longer sends your gh token on its own — it used to read gh auth token and register the repo's code in every attended session of every repo while gh was signed in, and again about every 5 minutes while no phone was paired. The token now goes out only for a repo hmd app connect was run for (the opt-in is a 0600 record under $HEIMDALL_HOME/app/pair-optin/, never inside the repo, so a checkout cannot opt itself in; hmd app disconnect and hmd app connect --no-code remove it, a session ending never does) and only when pairing needs it: the window registers the code ONCE, with the token, while no phone is paired. The relay keeps a registration 360 s and nothing renews it (no timer, no restarted client, no later session start, which finds the window open and sends nothing); a lapsed code is offered again by hmd app connect or hmd app add-device, under the same code. A repo with neither the opt-in nor a paired phone starts nothing: no gh call, no token, no relay request. Resuming a paired phone needs no opt-in, no gh and no token. HMD_PAIR_WINDOW_RENEW=1 (exactly 1) restores the old schedule: the code registered again about every 5 minutes, the token sent each time, until a phone binds or HMD_PAIR_WINDOW_IDLE_H hours pass. Tests: test/pair-window-token-optin.test.sh, test/app-session-survives-cc-restart.test.sh.
  • The push gate runs each repo's own checks, not heimdall's — the quality gate used to run in the session's cwd for any push, so a push of the site (six gates of its own) was blocked for lacking heimdall's sweep receipt. bin/lib/hmd_push_target.py now names the repo each push goes to by asking git itself (-C chains, --git-dir, GIT_DIR, subdirectories, linked worktrees; --work-tree cannot act as a decoy) and its class: sweep (test/run-all.sh names last-sweep.json: heimdall's own regime, the old gate unchanged and fail closed, now run inside that repo), repo-checks (a green, current receipt of its own checks) or unmanaged (one warning, the push goes on). The class is read from the COMMITS a push can carry (HEAD, its upstream, origin/HEAD, origin/<branch>, the refspecs named), never the work tree, and the strictest wins: an uncommitted marker edit changes nothing, and a pushed commit that drops a marker cannot downgrade a repo whose upstream still has it. The shell is read by a conservative model ($( ), backticks, eval, sh -c, here-strings and heredocs fed to shells, xargs, pushd/popd, chains of cd as sets of possible states); any construct it does not model exactly (a dynamic or conditional cd, cd -, a function, source, alias, a script piped to a shell, find -exec, an unknown git option or subcommand, an unmodelled GIT_* variable, a marker that cannot be inspected) makes the command uncertain, and then EVERY repo involved gets the strict sweep gate, never a warning; a repo that declares its own checks keeps owing the receipt under uncertainty (for commits nobody can list, so it is refused until the command is restated), and a strict gate that was never evaluated (heimdall-state has no state there) blocks instead of allowing. Every error path is strict too: git exiting non-zero, timing out or printing something it does not print, a ref that cannot be placed, a marker that is a link, a submodule or a directory, a resolver that crashes, a corrupt or oddly typed receipt or declaration; the tests force each one (a git on PATH that fails, prints nonsense or hangs at each call the resolver makes). A real shell is the judge of the model: every command of a corpus is RUN by bash and zsh with a git that records where it was started, and the repositories the pushes really went to are compared with the resolver's answer. It found a cd behind env/nice/xargs (a process runs it, not this shell), the last element of a pipeline (zsh runs it in the current shell), CDPATH= and command cd (bash runs the builtin, zsh does not); the same review made env with any option and assignments to HOME, PWD, OLDPWD, BASH_ENV and ENV uncertain. The secret, self-scan, oracle and corpus stages are not relaxed. Tests: test/heimdall-push-gate-per-repo.test.sh, and test/heimdall-push-gate-target-repo.test.sh for the case that was reported (the session in heimdall, the push to another repo by cd, by -C or from it, each graded on that repo's own record).

Fixed

  • Tests no longer fill the disk with leftover temp folders -- on 2026-10-13 the disk reached 0 bytes free mid-session. bin/heimdall-agents made a mktemp -d work dir per call and removed it only on normal return, so the statusline's timeout kill stranded one each time (3,731 hmd-agents.* dirs); it now needs no on-disk work dir. test/lib/dashboard_alerts_battery.py never removed its dir (782 hmd-alerts-*). On this macOS a template-less mktemp ignores TMPDIR, so tests that set a throwaway HOME and ran uv leaked tmp.* dirs; the new test/lib/private-tmp.sh keeps a test's temp files, mktemp calls and pip/uv caches in one private root removed on EXIT, INT, TERM and HUP, adopted by install-stranger, update-signed-apply and heimdall-deploy-maintainer. The sweep gives each suite its own TMPDIR and removes it, records tmp_leaks and tmp_outside in the receipt, fails a suite that leaves over 50 MB inside or outside its TMPDIR, refuses to start below 5 GB free (exit 5) and refuses socket paths without headroom (exit 6). Not heimdall's and not fixed here: the pip-unpack-* dirs (2.2 GB), which come from Claude Code's own ~/.claude/security/agent-sdk-venv pip bootstrap. 499 of the 534 suites (the test/*.test.sh files that contain mktemp and not private-tmp, counted 2026-10-15) still use bare mktemp and can leak if killed.

  • release/ship.sh --npm-only and --release-only no longer refuse the sweep receipt a full ship has just used, and they now ship the tagged commit's own files. The ship grades the
    receipt BEFORE its version bump, so its own chore(release): vX.Y.Z commit (one no sweep ever saw) made that receipt look stale, and the npm-drift recovery died with "graded , HEAD is " although nothing had landed since. These two post-tag modes now grade the TAGGED commit, not HEAD: the tag v<version> names the release commit T,
    and the receipt covers it when it graded T itself, or the commit T was bumped from (journal and ledger commits, the hook-owned paths, aside: the rule the ship applied to that commit)
    while T does nothing but re-pin version files. That means every changed path is one the bump writes (RELEASE_BUMP_PATHS) or CHANGELOG.md, the edits are in place with file modes
    untouched, each file differs from its parent only in version numbers and sha256 digests (CHANGELOG.md only in the release heading), the commit has exactly one parent, its subject is
    exactly chore(release): v<version> and the manifest version really moved; T's own manifest must also say the version the tag is named for. Commits after the tag (journal or code) and
    uncommitted edits are irrelevant, because nothing is read from them: --npm-only exports packages/ of T with git archive into a temporary directory (removed on exit) and publishes
    from there, so an uncommitted edit under packages/ can no longer ship without proof (the export is checked against T's blobs, and an export-ignore, export-subst or eol rule that
    would change a file refuses instead); --release-only signs T's install.sh (the bytes the tag serves, which bin/heimdall-autoupdate verifies) instead of the working tree's, and
    refuses a release-manifest.json whose install_sha256 is not T's. A --release-only run that must create the tag has no tagged commit to grade and still needs the receipt to cover
    HEAD, as a full ship does. A sweep recorded at a HEAD past the tag proves HEAD, not the tag, so it no longer covers the tag: the refusal names the tag's commit, the reason and the commit
    to re-sweep. The release notes are not read from T (Release prose, editable afterwards). A new full ship (it grades the commit it is about to bump), the pre-push gate and
    bin/heimdall-state are unchanged. Test: test/ship-npm-only-after-bump.test.sh (the real full ship in a throwaway repo, then the real --npm-only and --release-only with nothing
    landed in between, with a journal commit, a code commit and an uncommitted edit after the tag -- what is published and signed is compared with the tag's files -- and the shapes that must
    still be refused).

  • The per-repo push gate, last security review (2026-10-14) -- three findings against the gate described under Changed, each with its tests in test/heimdall-push-gate-per-repo.test.sh
    (test/heimdall-precheck-bash.test.sh filters two more trace lines of the old gate's call). (1) A resolver that cannot answer fell back to the WRONG repository. A timeout (HMD_PUSH_TARGET_TIMEOUT_S),
    a crash or a missing resolver ran the strict gate on ONE directory (the hook's own cwd), so a push to another repository by -C / cd, or from the payload's cwd, went ungraded. Now the strict
    gate runs against EVERY candidate -- the session's cwd, the payload's cwd and each -C / cd / --git-dir / --work-tree directory the parser had reached (the resolver prints them itself when it times
    out or fails, without asking git; the hook adds the two cwds when it gets no answer at all) -- and the push is blocked unless all of them pass. A push whose repository git names but whose work tree cannot
    be confirmed from the other end (a dotfiles layout) is graded on those directories as well, not only on the cwd's; a gate directory the hook cannot enter blocks (it used to run the gate wherever the hook
    was). Tests C50-C53. (2) Gate scope mismatch. One resolved (git directory, work tree) pair now feeds classification, refspec resolution, the receipt lookup and the heimdall-state check: each resolver
    row carries it (class, work tree, extra, git directory), every git call about the pushed repository runs with GIT_DIR and GIT_WORK_TREE pinned to it, heimdall-repo-checks status --git-dir verifies the
    pair from both ends and reads the receipt inside that work tree, and the hook runs heimdall-state in that work tree with the same pair exported. A GIT_* variable the resolver does not reproduce
    (GIT_COMMON_DIR, GIT_OBJECT_DIRECTORY, GIT_NAMESPACE, GIT_INDEX_FILE, ...) gave the push another view of the refs than the one the gate classified; it now makes the command uncertain (transport and
    identity variables such as GIT_SSH_COMMAND keep the class). Tests B "pair" and B "GIT_*", C54-C59. (3) Self-audit. The receipt is read as ONE regular file inside the repository: a symlinked .heimdall,
    .heimdall/receipts or receipt is never followed (and run will not write through one), the file is opened once with O_NOFOLLOW and checked on that descriptor, at most 1 MiB is read (a committed
    declaration over 1 MiB is refused too) and nesting deep enough to exhaust the parser is "not valid JSON", not a crash; status names HEAD once and reads the tree and the declaration of that commit, so a
    HEAD that moves between its reads cannot mix two commits; one regex that backtracked quadratically on a long command (a signal cannot interrupt a regex, so the resolver's own timeout never fired) is two linear
    searches. Tests A10, B "linear time", C60-C61. The unmanaged-repo warning, the secret and self-scan gates and every earlier class rule are unchanged.

  • The push gate arms for more ways of typing a push — the Bash hook decides whether a command is a push with one matcher (GP_RE in bin/heimdall-precheck-bash), and a push it did not read as one walked past the quality, secret, self-scan, oracle and corpus stages. It now also reads as a push: a push in backticks; behind a wrapper with its options and their values (nice -n 5 git push, sudo -u user git push, timeout -k 5 30 git push, env -u NAME git push, ionice -c 2, stdbuf -oL, caffeinate, xargs -n 1; the wrappers are sudo, doas, command, env, exec, time, timeout, nohup, nice, ionice, setsid, stdbuf, caffeinate, builtin and xargs); with a quoted or backslash-escaped command word ("git" push, \git push, "/usr/bin/git" push); and after an assignment or option value that holds a space, an escape or a substitution (FOO=$(echo a b) git push, FOO=a\ b git push, git -c user.name="a b" push, git -C My\ Dir push). Chains (&&, ;, ||, |, &, a newline), subshells, substitutions, cd x &&, sh -c and eval "..." strings, git -C and a full-path git were already read. Text that only mentions a push (echo "git push", git stash push) still does not arm the gate. Run against the previous matcher, 17 of the 47 one-line forms in the main list of section E of test/heimdall-push-gate-per-repo.test.sh were not read as a push; all 47 are now. Still not armed: a push no pattern can see ($GIT push, unquoted eval git push, a here-string, a script piped into a shell, process substitution, bash --norc -c '...'); the native pre-push hook, where it is wired, is the only gate for those.

  • Runtime state is no longer committed — .gitignore ignored .heimdall/ only at the repo root, so the runtime caches under skills/designmatch/.heimdall/ (7 files) and a stray packages/runheimdall/heimdall-state.json were tracked. The pattern is now **/.heimdall/* (the root exceptions are unchanged), those 8 files are untracked, and test/heimdall-nested-state-gitignore.test.sh fails if runtime state is tracked again. Two example files stay tracked on purpose at the root (.heimdall/cp-endpoint.json.example, .heimdall/issue-loop.config.json.example); the test allowlists exactly those.

  • Plugin auto-installs are receipted and loud — every plugin, marketplace and skill install attempt is logged (name, source marketplace, version or pin, result) to $HEIMDALL_HOME/receipts/installs-<run id>.ndjson, and summary-card --receipt lists that run's lines. A failed install prints install failed, continuing without <name> on stderr and records the same line, instead of vanishing behind || true; install.sh no longer reports the plugin step as done regardless. The fallback to the unlisted wshobson/agents marketplace is removed: only claude-plugins-official and heimdall-marketplace can be registered. The set of plugins attempted is unchanged. The Claude Code CLI has no plugin version flag, so only hmd@heimdall is pinned (by the installer's tag); other installs record the version they resolved. Five plugins the prompt can trigger (devops-toolkit, database-toolkit, seo, security-scanner, api-design) are not in the official catalog and now fail loudly when triggered. A consent model is planned for 3.1. Test: test/plugin-install-receipt.test.sh.

  • The plugin and marketplace listings carry the proof positioning — .claude-plugin/plugin.json and marketplace.json no longer call hmd an "autonomous multi-agent orchestrator" that turns "one prompt into a finished project"; they lead with the proof line (work passes a test the agent didn't write, shown able to fail, before the push lands), the pre-push-hook gate and the phone headline, with keywords to match.

  • The CI waste guard: agents can no longer pay CI minutes to re-roll a flaky result — the Bash hook denies git commit --allow-empty, gh run rerun (any flags), gh workflow run and a gh api POST to a re-run, re-request or dispatch endpoint (an hmd agent in a client repo had pushed an empty commit and re-run a workflow twice to get past a flaky test). It reads the command like a shell, so text that only mentions a form (echo, grep, a commit message) passes. A flaky test gets fixed or reported; the agent definitions and the definition of done say so. Operator override HMD_CI_RERUN_ALLOW=1 in the environment Claude Code runs in; a cost guard, so it fails open. Proof: test/ci-waste-guard.test.sh.

  • The relay session login no longer asks macOS for a keychain that is not there — under a throwaway HOME (env -i HOME=..., as many test harnesses run) storing a login opened a "Keychain Not Found … Reset To Defaults" dialog while security -i waited for a human. The store now asks security default-keychain (read-only) first and uses the Keychain only when it names a file under $HOME/Library/Keychains; otherwise it keeps the login in the 0600 file with one stderr notice, and an explicit HMD_SESSION_LOGIN_STORE=keychain is an error rather than a silent fallback. Every security call is killed after HMD_SECURITY_TIMEOUT_S seconds (default 20). Test: test/session-login-no-keychain.test.sh.

  • heimdall-landmine-lint no longer flags a source from a tree that was deleted and then recreated (git clone, mkdir, mv into the same path) before the source, the shape of install.sh's clone fallback; a delete followed by a source with no recreate is still flagged.

  • test/install-stranger.test.sh no longer leaves a ~1 GB (growing to 4.5 GB) Headroom install behind: the installer's validation boots Claude Code, whose SessionStart hook started a detached heimdall-modules add headroom in the throwaway HOME. The suite now runs with HEIMDALL_NO_AUTOUPDATE=1 and HEIMDALL_NO_MODULES=1, asserts no module install starts, and stops anything still running in its root before removing it.

Not in this release

  • The kill guard is not narrowed yet (the 2.4.7 notes said 2.4.8); it still denies kills by name or computed pid. HMD_KILL_GUARD=off still turns it off.
  • release/ship.sh does not yet commit, check and push the site's version pins, and a full ship still publishes npm from the working tree rather than the tag; both are planned for 2.4.9.
  • The push gate still allows a push, with a warning, when heimdall-state cannot give a verdict in a heimdall checkout that was never initialised.

Full changelog: v2.4.7...v2.4.8

Install/upgrade:

curl -fsSL https://raw.githubusercontent.com/randomittin/heimdall/v2.4.8/install.sh | bash

install.sh.minisig is the detached minisign signature of this release's install.sh; bin/heimdall-autoupdate verifies it against release/heimdall-signing.pub before applying.