31 Commits
Author SHA1 Message Date
branderson 12a04f244e Merge pull request 'Stop reporting ephemeral sockets as listeners' (#28) from ephemeral-ports into main
Reviewed-on: #28
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 11:48:26 -07:00
branderson 5f03eba8ce Tell a state file it cannot read from one that is not there
tests / runner-env (pull_request_target) Successful in 41s
tests / tests (pull_request_target) Successful in 1m19s
Reported from a live host: bootstrap said the guest side was at v7, and
`dejima-devbox-upgrade status` said the host predates version tracking and
should read the baseline note to establish where it actually is.

Both were right about what they looked at. Bootstrap writes the state 0640 root;
status was run without sudo and could not read it. `[ ! -r ]` answers the same
for absent and for unreadable, and it chose the reading that sends an operator
to redo the version tracking of a host that is fully tracked.

Three answers now instead of one: not readable by this account, which says to
use sudo; a directory that is not searchable, which hides the file from -e as
well and has a different fix; and genuinely absent, which keeps the message it
had. The first two return 2, since not knowing is a fault rather than an answer.

The comment above that branch already said an untracked host and a host whose
state file went missing are the same thing from in here. They are. A host whose
state file is merely unreadable is not, and was being told it was.
2026-09-03 11:41:45 -07:00
branderson 40276fdb0a Stop reporting ephemeral sockets as listeners
tests / runner-env (pull_request_target) Successful in 41s
tests / tests (pull_request_target) Successful in 1m20s
Reported from a live host: "new listener or unit" at max priority every half
hour, the heartbeat flapping down and up beside it, and accept not helping. No
changes had been made to the watched machine.

v6 added UDP to the listening-socket collection, which closed a real gap. But
`ss -l` with -u also reports UDP sockets in UNCONN, and that is what a client
socket looks like: a resolver doing a lookup holds one on a random high port for
as long as the query takes, and TCP has the same shape for anything that binds
before connecting. So a box doing nothing produced a new PORT line every cycle
and lost the one from the cycle before.

To a watcher that is a listener appearing and disappearing. accept cannot fix it
because the next sample differs again, and the heartbeat only fires on a clean
cycle, so the alerting ones send nothing and the dead-man's switch reports the
guard down and up in turn. Every symptom on that host from one line of awk.

Ports inside the kernel's ephemeral range are no longer recorded, with the range
read from /proc rather than written down: it is tunable, and a wrong constant
would either keep the flapping or hide real services.

What this gives up is a listener the kernel would also hand out as an ephemeral
port. That is a real loss and it is the better trade, because the alternative is
an alert every cycle, and an alert that fires constantly is one nobody reads.

v7, since the state no longer carries those lines and a v6 baseline differs from
every state after this. The test that would have caught it asks the plain
question the collector has to satisfy: two samples of a machine where nothing
happened must be identical.
2026-09-03 11:31:36 -07:00
branderson 8a183f070a Watch the persistence that does not need root
tests / runner-env (pull_request_target) Successful in 37s
tests / tests (pull_request_target) Successful in 1m16s
Everything drift watched needed root to write: /etc, /usr/local/{bin,sbin} and
the two .ssh directories. A dejima session runs as the ordinary account, so the
cheapest persistence available to it was outside every watched path - a user
systemd unit, a crontab in the spool rather than /etc, a shell rc file, or a
binary in ~/.local/bin, which is on that account's PATH and is where dejima
installs its own CLIs.

Named files under the home directory rather than the directory itself, which
holds the work this box exists for and would be enormous and drift on every
save.

Two more gaps were in how the state was collected rather than where.

SYMLINKS WERE INVISIBLE. `find -type f` returns nothing for one, so replacing a
watched binary with a link to another removed a FILE line and added nothing, and
a removal reads as tidying rather than as substitution. Recorded as LINK lines
with their targets, which also catches user-unit enablement, since that writes a
symlink into a .wants directory.

UDP LISTENERS WERE INVISIBLE. -ltn is TCP only, so a beacon binding a datagram
socket produced no line at all. Adding -u also ADDS A COLUMN: the local address
moves from field 4 to field 5 and field 4 becomes the send queue, so a flag
added without moving the field would have recorded a queue depth as a port on
every line. The netid is printed too, since a UDP and a TCP listener on the same
number are different facts.

v6, guest only and live-safe, because the PORT format change means a baseline
taken before this differs from every state after it. The note says to re-accept
on the guard and to read what it is about to record first: those paths have
never been looked at, so the first baseline over them is the one that decides
what "normal" means there.

None of this widens what the guard's key may run. The audit already ran as root
under the same sudoers rule and already returned this shape of text; it was
looking in fewer places.
2026-08-29 15:14:37 -07:00
branderson 077af789d4 Tag escaped filenames, and cut prose that says a thing twice
tests / runner-env (pull_request_target) Successful in 41s
tests / tests (pull_request_target) Successful in 1m24s
sha256sum escapes a filename containing a backslash or a newline: it prefixes
the line with a backslash and rewrites the name. That broke the FILE tag, so
those lines went out untagged - and a watcher counts changes by their tag, so a
file with such a name could change without appearing in the count its alert
carries. A filename is attacker-chosen on a box under suspicion, which makes it
a gap somebody could pick rather than stumble into.

Section 3 of the node runbook opened with six duplicated lines - the primary
control, on the most-read page here, through two prose passes. A test catches
the class: a paragraph that says something correct twice reads as a
continuation to a reviewer going top to bottom, and a skimmer never sees it.

The README said "from a checkout, not the installed copy" three times and gave
the same command block twice. Once each.
2026-08-28 21:44:27 -07:00
branderson 2884608066 Let dejima-guard collect from this devbox, without giving it a shell
tests / runner-env (pull_request_target) Successful in 42s
tests / tests (pull_request_target) Successful in 1m21s
dejima-guard watches this box from a machine that is not this box, and it
pulls - it reaches in over SSH and the devbox never reaches out. That
inversion is what lets a guard report on a devbox that has been taken, so the
work here is one inbound pinhole and deliberately no outbound rule at all.

v5, optional, and node-side except for the key.

THE FORCED COMMAND I DOCUMENTED DID NOT WORK. It was
`command="dejima-devbox-audit ${SSH_ORIGINAL_COMMAND##* }"`, taking the last
word of whatever arrived - so `drift --state` became `--state`, which is not
a subcommand. Drift collection would have failed on every cycle while reach
kept working, leaving a box that looked watched with half of it unwatched.
Found by testing the line rather than by reading it.

bin/dejima-guard-shell replaces it: a whitelist of the two exact commands the
guard sends. Whitelisting says what may run; sanitising tries to describe
what may not, and gets it wrong quietly.

AND IT DEFAULTED TO ROOT, on a machine this project's own runbook builds
without a root password. The guard connects as an ordinary account and
elevates through sudo for those two invocations, with a sudoers line that
names both rather than the program - because NOPASSWD on the program alone
would also permit `drift --accept`, and a key that can re-baseline can tell
this box that whatever an intruder left on it is normal.

The runbook has four test commands: two that must work and two that must
print `refused`. A restriction nobody tried is a restriction nobody has, and
this one is what stands between a key taken off the guard and a shell here.

`bats test/` 171 passing, 10 new. Renumbering the guest runbook to fit §7 in
broke two anchors in the threat model, which links.bats caught.
2026-08-28 16:34:00 -07:00
branderson 23e1c247f7 Let the audit emit its state, for a watcher that keeps the baseline elsewhere
tests / runner-env (pull_request_target) Successful in 41s
tests / tests (pull_request_target) Successful in 1m18s
`drift` compares against a baseline kept on this machine, and root here can
rewrite that as easily as read it - which is the case being looked for. The
program says so, and says the half that fixes it runs elsewhere.

This is the interface that half needs: `drift --state` prints the collected
state and compares nothing, so a watcher on another machine can keep the
baseline and do the diff where the devbox cannot reach it.

To stdout unconditionally, terminal or not, because this is data rather than
a verdict.

The partial-state refusal survives the emitter, and that is the point of one
of the tests: a watcher diffing a partial state against a full baseline sees
a list of removals and calls it an intruder deleting things. Exit 2 there, as
everywhere else in this program, means could-not-determine.

`bats test/` 161 passing, 3 new.
2026-08-27 23:55:45 -07:00
branderson 2c6fddf4f2 Make bootstrap's closing report scannable, and correct about where you are
tests / runner-env (pull_request_target) Successful in 36s
tests / tests (pull_request_target) Successful in 1m11s
Reported from the real box, mid-upgrade. The report was chatty, unstructured,
and confidently wrong about the operator's position: it told someone partway
through update 2 to go and do section 3 of the build runbook, underneath a
PASS saying those rules were already on.

THREE BUGS UNDER THAT.

Bootstrap ran `dejima-devbox-audit` with no argument, which is now all three
checks. On a box with no baseline, drift reports INDETERMINATE and makes the
whole run exit non-zero - so the report announced a failed audit and sent the
operator to the firewall runbook while the firewall was fine. It runs `reach`
now: the closing lines are about the hypervisor rules, so that is the
question to ask. One question per verdict.

The bare INDETERMINATE and PASS lines in the middle of the output were the
machine-readable verdicts. They go to stdout while everything a person reads
goes to stderr, so on a terminal they interleave and look like debris. They
are now suppressed when stdout is a terminal and still printed when it is
not, which is the contract a script or the collector depends on.

And nothing knew a re-run from a first build, so every closing paragraph was
addressed to someone mid-first-build. It reads the state file before anything
writes one, and a re-run - almost always an upgrade - is told to go back to
the note it was following rather than into the build runbook.

THE REPORT IS NOW THREE LINES AND A NEXT. Containment, harness, dejima, each
one word and a clause, then the single thing to do, then detail only for
whichever line is not the good answer. What used to be four paragraphs in a
fixed order, printed whether or not they applied.

`bats test/` 158 passing. Rendered both branches by hand rather than
reasoning about them, since the shape of this is the thing being fixed.
2026-08-27 23:07:51 -07:00
branderson 872749797b Treat devbox downtime as a scheduled event, and stop burying the how-to
tests / runner-env (pull_request_target) Successful in 42s
tests / tests (pull_request_target) Successful in 1m20s
A design point, stated by the operator and worth writing into the tooling
rather than the habits: on a devbox, downtime is announced, work is drained
first, and it must not arrive as a side effect of a step described as
changing nothing. It is the difference from the sister project, where a
runner is rebuilt on a timer and nobody is told.

BOOTSTRAP COULD RESTART DOCKER UNDER A LIVE SESSION. `apt-get install
docker.io` on a box that already has it is not a no-op: if the repositories
offer a newer version it upgrades, which restarts the daemon and kills every
running container. From a program documented as idempotent and safe to
re-run, which is exactly when it would happen. It now installs only what is
absent and says so; keeping packages current is `apt upgrade`, in a window
somebody chose.

Every note declares `downtime: none | window`, and status prints it, so a
window can be planned before anyone opens a note. Where a note needs one it
says WHICH step - an operator who has to guess schedules the whole procedure
into the window instead of the one part that needs it. In practice that is
the snapshot and only the snapshot: console rules apply immediately and touch
nothing in the guest.

Two tests pin it: every note declares the key, and a note requiring a
snapshot must declare a window, since a clean snapshot means stopping the VM.

AND THE HOW-TO WAS BURIED, which is the same mistake fixed once already.
"Doing an upgrade" sat below eighty lines of how versioning works, and the
README's upgrade section sat below "How it fails" under a heading nobody
skimming for "updating" would stop at. The how-to is now first in
docs/upgrading.md, and the README section sits between Use and How it fails -
where every returning operator is going.
2026-08-27 22:17:44 -07:00
branderson 31c462a249 Do not assume /usr/local/sbin is on the PATH sudo hands you
tests / runner-env (pull_request_target) Successful in 40s
tests / tests (pull_request_target) Successful in 1m16s
Reported from a real box: after installing the tool, `sudo dejima-ci-upgrade
set-tier privileged` was not found. The same exposure is here, and it is
wider than the upgrade tool.

Everything installs to /usr/local/sbin and every instruction in this
repository reads `sudo dejima-devbox-...`. That works only where sudoers sets
a secure_path including sbin. Without one, sudo inherits the caller's PATH -
and Debian leaves every sbin directory off a non-root PATH, which is exactly
what the reporting operator's PATH showed. The result is a correct install
whose documented commands all report "command not found", read as a broken
install rather than as a PATH.

Nothing checked it. Bootstrap verified the file it wrote was executable,
which is a different question from whether anything can find it.

It now checks reachability and says so. The check is honest rather than a
proxy: bootstrap runs under sudo, so its own PATH is the one the operator's
later sudo commands will get, and asking `command -v` there is the real
question.

The v2 note uses the full path for the two commands that immediately follow
an install, because that is where a PATH problem first bites and where naming
the path costs nothing. Elsewhere the bare name stays, since it works wherever
secure_path is set and the full path everywhere would be noise.

`bats test/` 155 passing.
2026-08-27 21:57:50 -07:00
branderson cc8ee744e7 v4 claimed a check that checks something else, and never recorded its node half
An audit of the stack from a v1 box, asking whether each note's commands can
run at the point it tells you to run them.

TOOLING AVAILABILITY IS FINE. Every command is either a ./bin/ form or comes
after a bootstrap in the same note, and a v1 box with no state file gets the
"predates version tracking" path rather than an error. The linear v1 -> v4
walk works.

THREE OTHER THINGS DID NOT.

v4 declared `verify-node: dejima-devbox-audit drift`, and drift verifies
nothing about v4's node work. That work is a log level on six rules in the
hypervisor console; drift hashes files, units and ports in the guest. It
would have reported `ok` on a box whose console was never touched, and
mark-node would have recorded "verified" on the strength of unrelated
evidence - which is the exact failure this design exists to prevent. Removed.
Nothing in the guest can see a log level on the node, so v4 declares no check
and the note says to look at pve-firewall.log instead.

v4 never said `mark-node 4`. The node half would have shown outstanding
forever. Added as its own step, which is also where the no-check point
belongs.

And status ran that bogus check on every invocation, because status now runs
verify commands for OUTSTANDING node work as well as recorded work - so every
`status` was hashing /etc. Removing the wrong verify-node fixes it; the
feature is right and the check was not.

PROSE ECONOMY, same audit as the docs, applied to what these PRs added:

  drift's comments      55 -> 39 lines, against 39 of code
  snippets.bats header  41 -> 28 lines, against 47 of code
  upgrades/0004        964 -> 812 words

Nothing removed but restatement. The load-bearing parts - the partial-state
trap, why the watchlist is short, why placeholders are substituted before
parsing - are all still there and are easier to find for being shorter.

`bats test/` 155 passing.
2026-08-27 21:57:50 -07:00
branderson ca41c94ebd Let the upgrade tool find its own checks, and tell "cannot" from "failed"
Both fixes come from the sister project, where they were found on a real
runner: `sudo ./bin/dejima-ci-upgrade mark-node 2` answered "cannot check -
dejima-ci-upgrade check-lan is not installed here". The same two bugs were
latent here, since every verify-node command names a sibling in bin/.

VERIFY COMMANDS ARE NOW LOOKED UP ON $PATH FIRST, THEN BESIDE THIS PROGRAM.
The notes tell an operator to run from a checkout on a box where nothing is
installed yet, and a PATH-only lookup fails there on the very program doing
the looking. $PATH still wins where both exist, because the installed copy is
what the machine runs.

AND EXIT 2 NOW MEANS "COULD NOT DETERMINE" RATHER THAN "NOT IN FORCE".
Collapsing the two would have mark-node tell an operator their console work
is missing on the strength of a check that never ran - wrong in the alarming
direction, about the one thing they cannot see for themselves. reach and
drift return 2 on INDETERMINATE to honour it, which is a contract rather than
a detail and is now written down in three places.

status also stops reporting console work as outstanding when the upgrade
declares a check and that check passes. A snapshot restore takes the record
and leaves the rules on the node, and reporting that as work to do sends an
operator to redo something already done.

One test had to change rather than being fixed: "a missing verify command is
not reported as a failed check" relied on dejima-devbox-audit being
unfindable, which the sibling fallback now makes false. It runs from a lonely
copy instead, which is the condition it was always trying to describe.

`bats test/` 155 passing, 4 new.
2026-08-27 21:57:50 -07:00
branderson 70f03c49ab Report what changed, and log what the node dropped
Steps 1 and 2 of the intrusion-detection design. Neither is detection, and
both say so where a reader will see it rather than only in the design note.

CREDENTIAL AGES in the radius report. Not detection at all - a bound on what
a foothold is worth. A devbox cannot evict an intruder it has not found, and
rotation bounds the value of one without needing to find them, which is the
only bound this VM has that a compromise cannot remove. From mtime, which is
the wrong question asked of the only thing available: a token issued a year
ago and copied here yesterday reads as a day old. It is a floor, and the
report says so.

DRIFT, a third audit subcommand. Hashes the paths where persistence actually
lives - /etc, /usr/local/{bin,sbin}, the .ssh directories - plus the enabled
units and the listening ports, because `systemctl enable` writes a symlink
and a listener is not a file at all. Compares against a baseline and reports
CLEAN, DRIFT or INDETERMINATE. dpkg --verify runs alongside it, absolute
rather than baselined.

THE FAILURE THAT WOULD HAVE SHIPPED: run without the privilege to read every
watched path, the state comes back partial and diffs against a full baseline
as a list of REMOVALS - which reads as an intruder deleting things. That is
worse than a false pass, because it is a false alarm shaped exactly like the
thing being looked for. It now refuses to compare and says so.

NODE DROP LOGGING, on rules 7 to 12 and nowhere else. A packet matching a
DROP is this VM reaching for your network, which on a working devbox is close
to never, so the log is quiet by construction and a burst in it is the
loudest signal a compromised devbox makes. It is also written by the
hypervisor, about traffic the hypervisor refused - outside the guest, where
every other observation this project makes is not. The policies stay nolog:
one would record the machine's entire internet traffic and the other
broadcast chatter.

Found while building: `trap ... RETURN` for temp cleanup fires when a NESTED
function returns once the caller has functrace on, which bats does - deleting
the working files partway through a run. Replaced with explicit cleanup at
each exit. The first attempt at that patched cmd_reach instead, because it
ends with the same two lines as cmd_drift; caught by the suite.

`bats test/` 152 passing, 14 of them new.
2026-08-27 21:57:50 -07:00
branderson 0b7751e20e Merge main into versioning: keep main's ntfy work, keep the restructure
tests / runner-env (pull_request_target) Successful in 41s
tests / tests (pull_request_target) Successful in 1m15s
main gained three PRs while this branch was open, and one of them - #12,
"Say what dejima's notifications cost" - covers the same ground as the ntfy
pinhole here. Both reached the same conclusion independently: a rule naming a
reverse proxy grants every service that proxy routes, because the firewall
matches an address and a port and cannot see the Host header.

main's version is the better one and is what survives. It is tighter, and its
runbook half ships the nginx to build a dedicated listener where this branch
only cross-referenced the idea. The generalised "one port per service" section
here is removed rather than merged: it framed a pattern for services that do
not exist yet, and main's ntfy-specific section already carries the insight
that mattered.

What this branch keeps is the versioning system, and the runbook restructure -
the map at the top, the two verification passes, the reasoning moved out of
the steps.

FIXED WHILE RESOLVING: main says "Nothing follows rule 10" and "rules 7 to 10
drop" while its own table ends at 12. Stale prose left behind when the CGNAT
and IPv6 rules landed in a separate PR from the one that renumbered around
them - the same class of error this branch already fixed once, arriving on
main by a route neither PR could see. Corrected to 12.

upgrades/0003 now links main's heading rather than the one removed here.

`bats test/` 139 passing.
2026-08-27 21:43:30 -07:00
branderson 7dc48c794d Merge pull request 'Bound the LAN over IPv6, and over the range a tailnet uses' (#16) from ipv6-boundary into main
Reviewed-on: #16
Reviewed-by: branderson <brad@branderson.io>
2026-08-26 00:48:32 -07:00
branderson a8569baf1b Version the deployed state, so an existing devbox can be brought forward
tests / runner-env (pull_request_target) Successful in 38s
tests / tests (pull_request_target) Successful in 1m7s
A devbox is a long-lived VM pinned to the day it was built. Half its
controls were typed into a hypervisor console no `git pull` can reach, the
other half were installed from a checkout that has since moved on, and a
snapshot restore can move it backwards without anyone deciding to. Nothing
about it converges on the current version by itself.

dejima needs none of this and the difference is worth stating rather than
inheriting: its containers are short-lived, only the tip of main is
supported, and a pull closes any gap that opens.

Integer versions. VERSION holds one number, upgrades/000N-slug.md is what an
operator does to reach N from N-1, and there is a note for every version
including ones that need nothing - an absent file is indistinguishable from
one nobody wrote.

TWO NUMBERS ARE TRACKED, in /etc/dejima-devbox/state. guest_version is
written by bootstrap from the checkout's VERSION, so it records something
this repository did. node_version is written by an operator saying they did
the console work, because nothing in the guest can read the hypervisor's
rules - which is the whole reason those rules are the primary control. They
are kept apart because they are known in different ways and because their
disagreeing is informative: node behind guest is console work outstanding,
guest behind node is a restored snapshot, and one number would have averaged
both into silence.

An upgrade may declare a verify-node command that passes only if the console
work is in force. v2's is `dejima-devbox-audit reach`, whose IPv6 half tests
the exact rule v2 asks for. mark-node runs it and refuses to record a claim
the machine contradicts; --force records it as unverified so status keeps
saying so. status re-runs the checks for everything already recorded, so an
assertion that was true in June and is false now is found by a timer rather
than by an incident.

The tool refuses to be reassuring about what it cannot see. Run from a
checkout it knows the current versions; run from the installed copy it knows
only what shipped with it, and says so instead of reporting "up to date"
about versions it has never heard of. A host with no state file is not
assumed to be v1, because an untracked host and a deleted state file look
identical from in here.

Tested with `bats test/` (133 passing, up from 114). The new suite covers
both failure modes it exists to prevent, plus note/VERSION consistency so a
version bump cannot be half-done.
2026-08-25 16:38:58 -07:00
branderson 7cba3932c2 Bound the LAN over IPv6, and over the range a tailnet uses
tests / runner-env (pull_request_target) Successful in 39s
tests / tests (pull_request_target) Successful in 1m2s
The node firewall's DROPs named IPv4 ranges only, so the control this
project exists to provide did not apply to IPv6 at all. That is open by
default on every network including ones with no IPv6 in them: every Linux
host brings up an fe80:: address on every interface whether or not anything
routes v6, and a daemon bound to :: answers on it. Neighbours were reachable
across the segment with all four DROPs in force, `ip -4 route` showed
nothing unusual, and `dejima-devbox-audit reach` reported PASS.

Dropped whole rather than enumerated, because IPv6 has no RFC 1918. A v6
network is numbered out of a globally routable delegated prefix, so no
equivalent range list can be written here - fc00::/7 catches ULAs most
networks do not use and misses the prefix a neighbour is actually on. On the
node rather than in the guest, for the same reason as every other rule here:
a sysctl is a setting root on the guest puts back.

Also drops 100.64.0.0/10. A tailnet is numbered there and is none of the
three RFC 1918 ranges, so a devbox on one reached every machine on the
overlay.

The audit gets a matching check: it asks ff02::1 who is on the link and
counts the replies that are not its own. No positive control, because both
reasons for v6 silence are the outcome wanted - unlike the v4 case, where a
working firewall and a broken network look identical. It does report
INDETERMINATE when the probe cannot run at all, which is why bootstrap now
installs iputils-ping; without it the check would have passed vacuously,
which is the one thing this program is built not to do.

Docs: the threat model gains a "Not bounded" list collecting the negative
space in one place, a section on the two address families, two new entries
under "Rejected, with reasons", and a quarterly review cadence with the
conditions to check rather than re-argue.

Tested with `bats test/` (114 passing) and by exercising the probe against
recorded ping output, including its own reply and DUP! lines.
2026-08-25 14:40:23 -07:00
branderson 51024cb70a Check for a harness without deciding which one dejima runs
tests / runner-env (pull_request_target) Successful in 35s
tests / tests (pull_request_target) Successful in 55s
dejima is growing support for more than one, and both halves of this were
written as though Claude Code were the only answer: the runbook named ~/.claude
as the state directory and .credentials.json as the credential, and bootstrap
tested those two paths and reported their absence as a step the operator had
skipped.

The requirement generalises even though the paths do not. A harness installed
for the account, run once so its state directory exists to be mounted, and
signed in so the host-side proxy has something to hold: that shape is the same
whichever harness it is, and it is what the runbook now states. Claude Code
stays as the worked example of where those two live, marked as an example, with
the set of supported harnesses left where it belongs.

Bootstrap keeps a check, because the failure it catches is real and arrives with
nothing else pointing at it. What changes is the table it reads from and the
verdict when nothing matches. The table is recognition rather than a statement
of support, so a harness missing from it produces "this cannot see one", which
is not a finding, followed by the requirement in harness-neutral terms. Three
outcomes rather than two, on the same reasoning that gives the audit its
INDETERMINATE: a check that reports a failure it cannot distinguish from its own
ignorance is worse than no check, because the operator either believes it or
stops reading it.

A copied list of what dejima supports would have been the other option. It is
the contract-kept-here problem this repository argues against everywhere else,
and it goes stale in the direction that tells somebody their working setup is
missing.
2026-08-17 15:36:23 -07:00
branderson 903983d63f Say the harness step where the operator is standing when they skip it
tests / runner-env (pull_request_target) Successful in 34s
tests / tests (pull_request_target) Successful in 54s
The runbook now states the prerequisite, and bootstrap's closing output is
where it gets ignored: that text hands over to install.sh and, until now,
implied that was the last thing between the operator and a session.

So it reports the harness alongside the audit result, and reports it three
ways, because one message for every state is how a check stops being read. No
~/.claude and the harness has never run as that account, which matters beyond
the credential: dejima mounts paths under that directory, and a bind mount
whose source is missing is a root-owned empty directory in somebody's home
rather than an error naming what is absent. The directory but no
.credentials.json and it has never signed in here, which is the state that
fails at the API with the container looking like the broken part, so the API
key route is named there too. Both present and it says what it actually knows,
which is that a file exists rather than that a token works.

It checks and does not fix. The sign-in belongs to the account and writes a
credential this program has no business handling, and §7 of the runbook is the
argument for keeping root's hands off it.
2026-08-17 15:29:18 -07:00
branderson a4c90efd7b Hand off with dejima's dependencies met and its source on the box
tests / runner-env (pull_request_target) Successful in 37s
tests / tests (pull_request_target) Successful in 57s
A devbox is a machine that runs dejima, and this repository set one up
without installing anything dejima needs. Bootstrap finished on a host
with no container runtime at all.

It now installs docker.io and docker-cli from Debian, compose by whichever
route the release offers, and python3, jq and git - dejima's own stated
prerequisites, the first two for its host-side credential proxy and its
installer. The compose ladder is the sister repository's, unchanged in
substance: distribution package, then backports, then Docker's own apt
repository last and said out loud, because it is a third-party source on a
machine whose value is a small, known surface.

bin/dejima-devbox-clone then puts dejima's source at ~/dejima, and stops
there. It does not run dejima's install.sh: that step writes into
~/.claude, ~/.local/bin and ~/.config/dejima, and what it does there is
dejima's to define and to change. A copy of another project's setup
contract kept here would go stale with nothing to notice. What is worth
doing on this side is the ordering - source has to arrive over a network
that is about to be locked down, so the clone happens before the
addressing step and before the node rules go on.

An existing directory is never modified. A checkout somebody has been
working in is not this program's to pull into, and a directory that is not
a checkout is more likely to be theirs than to be a mistake.

Two smaller things follow from the same change:

The account. dejima runs as a person, not as root, so bootstrap asks which
one, adds it to the docker group and records it. The docker group is root
on this VM in one command - docs/threat-model.md already treats that as a
grant rather than a lapse, and the blast radius there is written assuming
it.

The audit's radius report was inventorying the wrong home. It runs as root,
from the timer or under sudo, where HOME is root's - and root's home on a
devbox holds nothing, so the report read "none found" on a box whose
credentials sat one directory over. It now resolves the recorded account,
falling back to $HOME so a host set up before this reports what it always
did.

Tested: 98 tests pass. test/clone.bats clones over file:// from a fixture
repository, so it needs no network: the clone, DEJIMA_DIR, an existing
checkout left alone with its uncommitted work intact, a non-checkout
directory refused, an empty one accepted, a bad URL failing loudly, and
that nothing is written into the account it clones for. test/audit.bats
covers the home resolution. test/units.bats now checks that every program
in bin/ is executable, which dejima-devbox-clone was not.
2026-08-17 00:08:05 -07:00
branderson c784e0cc88 Report findings, not reasoning, and fix the kernel currency check
tests / runner-env (pull_request_target) Successful in 34s
tests / tests (pull_request_target) Successful in 48s
Three things, all about what the tool says when it runs.

The audit's output explained itself at every step: why a positive control
makes a timeout mean something, why an INDETERMINATE is not a pass, why
keeping the two kernel versions equal is "not a detail". The reasoning is
worth having and it belongs in the source, where it now lives. Output is
for findings and for the next action.

The kernel check was wrong on ordinary Debian hosts. Debian ships the
signed image as linux-image-<version> and the unsigned one as
linux-image-<version>-unsigned; uname prints neither suffix, so comparing
the package name against uname -r reported a pending restart on every host
running an unsigned image, whatever it had booted. dpkg-query was also
counting packages removed but not purged, which could announce a restart
into a kernel that is not on the disk. Both are fixed in newest_kernel,
with tests, and the line now reads yes/no rather than asking the reader to
compare two version strings themselves.

Bootstrap ended with a fixed paragraph saying the audit reports FAIL until
the node rules are applied. On a host where they already were, that
contradicted the PASS three lines above it. It now branches on the audit's
result: rules in force, or the exact command to re-run after applying
them. Exit status stays 0 either way - what this program installs is in
the guest, and a FAIL from a hypervisor that has not been configured yet
is the expected reading at that point in the runbook, not a failed install.

Tested: 83 tests pass. New coverage for the unsigned-image comparison, for
removed-but-not-purged packages, for both readings of the kernel check,
and for a host where no packaged kernel is found.
2026-08-16 21:37:17 -07:00
branderson 67db46f3c6 Say when the audit is describing the wrong machine
tests / runner-env (pull_request_target) Successful in 31s
tests / tests (pull_request_target) Successful in 47s
The program reports on the machine it runs on. Inside a container it reports on the container, and every reading is real: the gateway probed is the bridge, the credential found is whatever is mounted there, the group memberships are the image's. None of it is about the devbox and nothing in the output said so.

Detected rather than declared, which is the opposite of test/devbox.bats, and the reason is the direction each fails in. A test that mis-detects skips and asserts nothing, so it takes a declared variable and refuses to guess. This one mis-detects and prints an extra paragraph. Loud and wrong costs a paragraph; quiet and wrong is what the declared variable exists to prevent.

It warns rather than refusing. Someone reading the output while developing the tool has a reason to be there, and refusing would teach them to work around the check.

The tests are about the warning and not about the environment: whether it fires when detection says so, whether it comes before the findings rather than after them, whether it warns without blocking. Those run anywhere. Asserting anything about a real devbox from CI would be asserting nothing, which is the trap this repository keeps finding in its own tests.
2026-08-11 18:34:10 -07:00
branderson 2f499914ef Merge pull request 'Say which way the console orders rules, and fix the numbering' (#5) from console-order into main
Reviewed-on: #5
Reviewed-by: branderson <brad@branderson.io>
2026-08-11 18:32:22 -07:00
branderson d2d00dba90 Correct the escape argument, and state how poisoning is handled
tests / runner-env (pull_request_target) Successful in 35s
tests / tests (pull_request_target) Successful in 49s
Two claims in docs/threat-model.md were false rather than loose. An escape would be a downgrade, and the account already holds more than the escape would grant: root on the VM is strictly more capability than a process inside a container with a trimmed capability set. Root on this VM is already a row in the blast radius table, which is the case the model is drawn around, and the hypervisor firewall bounds it identically however root was reached.

The escape section names what an escape reaches that the container did not, and it is not one thing. Every other project on the box becomes readable, and uncommitted work in those exists nowhere else. Beyond that, the model API credential: dejima keeps it out of the container deliberately, so a session can use the API while it runs and cannot carry the token away, and the proxy holding it runs on the devbox. That makes an escape onto the devbox the step that turns temporary use into a token someone keeps.

It is not the only loss that reaches past the machine, and no longer says so. Source and uncommitted work leave just as easily. What puts the credential first in the queue is that it is a standing capability rather than a copy of data, and that it sits at a known path under a known name, which makes it among the easiest things on the box to find.

That also corrects the rootless rejection, which claimed the set it would protect was close to empty. It is small but not empty, and the control covers neither half of it: the proxy runs as whichever account runs the session, and one account owns every project, so moving the session to a dedicated account moves the projects with it.

The threat model no longer rests on the adversary being unsophisticated. Real supply-chain malware fingerprints its environment, stages, beacons and can hand a session to a person, and a prompt injection borrows the agent as its adaptive component. What the model rests on instead is that every one of those moves needs egress, which the node firewall bounds whether the thing being bounded is a script or a person driving it. Being undirected changes what is likely, not what is possible.

One person's work per devbox is stated as a requirement of the threat model rather than an observation, and cross-project context poisoning is stated as handled in dejima, in the present tense.
2026-08-11 18:25:33 -07:00
branderson 037fb800aa Say which way the console orders rules, and fix the numbering
tests / tests (pull_request_target) Successful in 1m50s
tests / runner-env (pull_request_target) Successful in 33s
The runbook said the console appends new rules at the bottom and that entering them in the documented order was all it took. It inserts them at the top. Entering the table in order therefore produces the reverse of it: the four DROPs above every pinhole, and a devbox that cannot reach its forge.

That made the instructions produce the exact failure the same section warns about, and it is the failure with no symptom - the VM boots, the console shows the rules present, and nothing looks wrong until something tries to reach the forge. The runbook now names the behaviour, gives both ways through it, and says to read the finished list against the table before trusting it, because nothing else catches this.

The template and the audit's FAIL message said the same wrong thing and are corrected. So is test/firewall.bats's explanation of why it exists, which is now stronger: getting the order wrong is the default outcome of following the table rather than an unusual mistake.

Separately, the rule numbers in that section were inherited from the sister project's eight-row table and never updated for this one's ten rows. It cited rules 5-8 as the DROPs, rules 2-4 as the LAN exposure and rule 8 as the last. Those are 7-10, 3-6 and 10 here. The Values that must agree table further down was already correct, which is what made the mismatch survive a read.
2026-08-10 16:57:38 -07:00
branderson 6ce8c9e6c0 Report the addressing mode, and catch a lease that stopped renewing
tests / runner-env (pull_request_target) Successful in 1m40s
tests / tests (pull_request_target) Successful in 1m54s
The design spec asked for this and the first implementation left it out. It targets the worst failure in the whole design: a DHCP guest behind these rules renews through a path the firewall may be dropping, nothing breaks at boot because the first lease predates the rules, and the VM falls off the network up to a full lease time later looking nothing like a firewall rule.

Static is decided before any lease file is consulted, and that order is the correctness of the function rather than a detail. A host pinned by dejima-devbox-static-ip keeps the leases from whatever it did before, so letting those decide would tell every pinned host it needs rules it does not need. A setup matching neither is reported as unknown rather than assumed to be the friendlier answer.

No state is kept between runs. The spec's own suggestion was to record the expiry each run and watch whether it advances, which would have made this program start writing files for the first time. It does not need to: dhclient rewrites the lease file on every successful renewal, so the file's mtime against the renewal time it records answers the question in one pass. Past T1 with an mtime older than T1 means dhclient asked and got nothing back.

One test failed on a variable shadow rather than on logic: cmd_radius declares , which shadowed the fixture path the stub closed over, so the stub returned an empty string. The fixtures now use a name production code has no reason to pick, with a comment, because the symptom pointed at the parser and the cause was three functions away.
2026-08-10 00:17:47 -07:00
branderson eeecfd619c Add the single entry point, which verifies what it writes
One command. There is no install.sh: the sister repository split bootstrap from install, and the split produced two commands that had to be run in the right order with no host ever wanting one without the other.

The path to the installed program is written in exactly one place. In the sister repository it was written in two, they disagreed, and a timer that had never worked on any host failed with 203/EXEC every time it fired without anyone noticing. Here the marker is substituted and then the result is checked for being executable before anything is enabled.

It asks rather than assumes on addressing, and states what each mode costs at the boundary instead of choosing. It validates the forge hostname, warns rather than dies when the name does not resolve yet - the resolver rule may not be in place at this point in the runbook - and says plainly at the end that the firewall itself is not something a guest can apply to itself. That is the whole reason the rules are worth having.

Smoke-tested end to end against stub directories and a stub systemctl: the program installs, both units are written with the marker substituted, the config is written, the audit runs once and correctly reports FAIL where no hypervisor rules exist.
2026-08-09 23:21:24 -07:00
branderson 86bb6b1280 Carry over static addressing, the mode needing no DHCP rules
Copied from the sister repository and renamed. It pins the host to the address it already has, read off the running interface rather than typed, and rolls the file back if what it wrote will not parse.

It is carried over on the strength of being the tightest of the three addressing modes at the boundary: a guest with a static address never speaks to a DHCP server, so the rule set stays at four DROPs plus the named exceptions and needs neither half of the DHCP pair. It is offered rather than imposed, because an address reserved at the router is one source of truth instead of two and that is a real argument on somebody else's network.

All fifteen of its tests pass unchanged after the rename, which is the check that it depended on nothing CI-specific.
2026-08-09 23:19:17 -07:00
branderson be59f607d2 Report what root on this box would get, without printing any of it
Paths and modes for credentials, docker group membership, how many project directories a session could reach, and the running kernel against the newest installed one. Never contents.

This is the inventory the audit refuses to keep about the network, and the difference is where the information lives. A list of other hosts describes topology unobtainable from this VM without scanning loud enough to notice. An inventory of this box describes a box the attacker is already standing on, and reveals nothing a single find would not.

Two things found by running it rather than by reading it. The report died halfway through with no error at all: dpkg-query exits non-zero when no kernel package matches, and under set -e with pipefail that killed the function mid-report, so it just stopped. A test now asserts the last section rather than the first, because a truncated report from a tool whose job is telling you what you are exposed to is the worst available failure. And a blank field where the newest kernel should be read as a bug, so it now says which question went unanswered and when that answer is suspicious.

Also dropped a claim I could not honour: the runbook said this reports the address count of the inbound rules. It cannot. The rules are on the node and the audit runs in the guest, so it can no more read the inbound list than the DROPs. Inbound exposure is only checkable from outside, by connecting, and this tool does not go looking for things to connect to. The runbook now does that arithmetic itself and says why the audit does not.
2026-08-09 23:13:35 -07:00
branderson 0634f8d533 Make a timeout mean something, with controls that prove packets move
A timeout to the gateway is the firewall working or the network being broken, and nothing in the probe itself separates them. The positive controls do: if the forge resolves or answers on 443, packets are moving and the tap is up, so silence from the gateway is a filter. Either control alone suffices, and they exercise different things - resolution the resolver exception, a handshake layer 4 and the forge exception - so an unrelated outage in one does not produce a report the operator learns to ignore.

Config is one key. Everything else comes off the machine. There is no list of what this VM must not reach, and etc/config.example says why there should never be one.

Two tests in this commit were passing against a stub. setup() faked positive_controls_ok for every test, including the two whose entire purpose was to check it, so they asserted nothing. The stub now lives in the one verdict test that needs it. Two others were passing on exit status 127 from a function that did not exist yet; they assert on the output as well now, so a missing command cannot satisfy them.
2026-08-09 23:10:44 -07:00
branderson d3d465570a Probe the gateway, and report three outcomes rather than two
The rules protecting this VM live on the node's tap device and cannot be read from inside it, so the audit tests behaviour. The gateway is the target because its address is already in the routing table, it is guaranteed alive where an arbitrary address is not, and it sits inside the ranges the firewall drops - so it must be unreachable as a destination while traffic through it is untouched.

Three states, not two. A timeout means the firewall dropped it or the path is broken, and reading that as a pass is a false negative in a check whose whole value is that someone believes it. PASS requires silence AND corroboration that packets are moving; without the corroboration the answer is INDETERMINATE and the exit status is non-zero.

A refusal counts as reachable, because a closed port still proves the packet arrived. Port 53 is never probed: the resolver exception may legitimately allow it, so a rule working as designed would read as a failure.

Recorded a limit found by running it: bash's /dev/tcp cannot separate ECONNREFUSED from EHOSTUNREACH, so a REJECT rule looks like a live host refusing and produces FAIL. That is the safe direction and it is loud, but it is why the template specifies DROP.
2026-08-09 23:08:40 -07:00