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.
dejima-guard does not always run in Docker, and v7's note gave only the
prefixed form, mentioning the other as a trailing aside. An operator with a host
install was told to type something that will not work there.
This repository wrote the rule down in .dejima.md two days ago, and the test
that was supposed to hold it only asked whether the container form was present -
which v7 satisfied by having nothing else. It now asks for both, and for the
plain one first, which is the order v5 and v6 use.
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.
The lesson from dejima-ci-hardener's per-pool migration, where every step that
asked an operator to type something was a step that went wrong on a real host,
and every fix was to stop asking rather than to ask more clearly.
Recorded here rather than only there because the failure shape is the same in
both: a note that names a path, a network or a count is a note somebody runs
twice, changing the obvious part and leaving the rest. This repository already
gets it right in one place - bootstrap records the guest version, so nothing
asks an operator to - which is the example worth reaching for before adding
another prompt.
The header came over with the file from dejima-ci-hardener and described that
repository's split: a bats job and a compose job needing a real Docker daemon,
"because the thing it checks is precisely what a stub cannot: whether what
dejima-ci-runners emits is a compose file Docker will accept." There is no
compose job here and no dejima-ci-runners either. The split that does exist is
tests against the branch and runner-env against the machine, which each job
explains at its own definition, so the header names it and stops.
It also opened by attributing the trigger behaviour to `pull_request`, three
lines above a comment explaining at length why this is `pull_request_target`
and why the difference is the whole security posture of the file.
And it counted two runner-env jobs, which is true of the sister repository and
not of this one.
Comments only; the diff touches no line that is not one.
This repo had no .dejima.md. The container-form rule is the immediate reason for
one: an agent writing the next note will reach for `dejima-guard accept`,
because that is what the guard's own docs say, and will not think about the
deployment shape of another project.
Four sections, all of them rules this repository learned by shipping the wrong
thing and having an operator run it:
- an upgrade note is a procedure somebody follows verbatim, and the three ways
the steps here have failed: naming a command the tool does not have, naming
one the operator's installed copy has not got, and naming one in a form their
deployment does not have;
- a note that runs dejima-guard needs the container form, with the reason it
matters more than a missing binary: the bare name can find a second
deployment with its own baseline and answer as though nothing were wrong;
- which of the two version numbers an operator asserts, and why bootstrap
records the other;
- that changing what drift collects changes every deployed baseline, so it
earns a version bump and a re-accept.
Each says which test holds it, so the next agent extends the test rather than
rediscovering the rule.
v5 and v6 both end by telling the operator to run `dejima-guard accept` on the
machine running the guard. That is the host form. dejima-guard is usually a
container, and on that host there is no dejima-guard at all - or worse, one from
a second deployment with its own baseline and its own idea of what normal looks
like. Either way the note ends in command-not-found on the step meant to confirm
the upgrade landed.
Both notes name the container form and point at the guard's own README for the
rest, rather than growing a copy of another project's interface that would go
stale here without anything noticing.
A test holds it for any future note that runs the guard.
The note said `dejima-devbox-upgrade mark-guest 6`. There is no mark-guest and
there never has been: bootstrap records the guest version itself, because that
is the half a box can know about itself, and mark-node exists for the half it
cannot see. So the note ended on an error, on the step that was supposed to
confirm the upgrade had landed.
A test now checks every dejima-devbox-upgrade subcommand named in a note
against the ones the tool actually dispatches. The existing test in this area
asked whether a note invoking the tool also runs bootstrap, which this note did,
so it passed while telling the operator to run something that does not exist.
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.
Three places, each for a different reader. The line under "it does not prevent
root" is where somebody learns the limit, so it is where the thing that notices
belongs. A section before Tests makes the case, because the reader has just
finished How it fails and is holding exactly the right question. A row in
Further reading puts it beside the other sister project, where anyone looking
for the shape of the family will find it.
The pitch leads with this repository's own blind spot rather than the guard's
features: every check here runs on the box being checked, when you run it, and
the baseline drift compares against lives on the machine whose drift it
describes. That is the gap, and it is more honest than a list of what the guard
does.
It also says what adopting it costs, in one sentence, since the question after
any pitch is whether this is a weekend.
v5's verification step said to run `dejima-guard check` and watch it succeed. A
guard with no baseline now refuses to check rather than quietly recording
whatever it finds, so following v5 verbatim on a fresh guard reports a failure
that is not one, on the step whose only job is to confirm the upgrade worked.
The note names the three commands in the order they have to run, and says why
accept comes first: a baseline taken later records whatever is on the box by
then as normal.
The install-key line runs as the guard's own account now, since the host path
gained one and that account owns the key it installs and has to read it
afterwards. The guard's README stays authoritative for that machine, and this
says so rather than growing a second copy of its setup.
A test holds the ordering. Nothing here can see the other project's code, so
what it can check is that the two commands are named in the order that works,
which is the half that goes stale silently when that project changes.
House style across the organisation, and the punctuation most reliably read as
machine-written. The shell in this repository already used " - " throughout;
only the prose disagreed. Replaced by what each one stood in for rather than by
a single substitute: a colon where an explanation follows, a comma before a
conjunction, brackets where a pair was fencing a parenthetical.
A blind pass corrupted the firewall rule table. Its "not applicable" cells were
em-dashes, so `| - | - |` became `|: |: |` in the one table documenting the
primary control, and nothing about the rendered page looked wrong enough to
notice. Repaired to ASCII hyphens, with a test for cells holding punctuation
and nothing else.
Two more artefacts the same pass introduced, both at line wraps, where the
conjunction rule could not see across the break: a colon before "and", and a
dash that opened a line becoming a colon that opened one.
The em-dash test builds the character from its bytes rather than writing it,
since a check containing what it looks for reports itself and can only pass by
exempting the file it lives in.
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.
The line carried the account name of the box the runbook was written on, which
is an operator detail in a published deliverable and reads as instruction
rather than example - the next person pastes it and grants sudo to an account
that does not exist there.
Section 7 opened with a block that read guard_key.pub on the devbox. The key is
generated on the guard and never leaves it, so there is no such file there and
the first command fails for everyone who follows the section as written.
The key half now runs on the guard, via dejima-guard-install-key, which writes
the restricted line in one append rather than leaving an unrestricted key live
in the gap between copying it and adding the restriction. The sudoers half stays
here, and is done first so the guard's check does not fail on a rule that is
not yet there.
The four hand-run checks the section listed carried the same ssh -i bug the
installer had: -i adds a key to those offered rather than restricting ssh to it,
so the operator's own key authenticates, no forced command applies, and the
restriction reads as missing when it is fine.
Links in by number were fragile in the way described: the headings are numbered,
so a section inserted above this one renames it and orphans the v5 note and the
reference from dejima-guard, which is a separate repository and does not get
renumbered with it. The section carries an explicit guard-access anchor now,
and links.bats recognises explicit ids so it stays checked.
Reported from the real box: following the note gave
sudo /usr/local/sbin/dejima-devbox-upgrade mark-node 5
dejima-devbox-upgrade: no upgrade note for v5.
and the only way through was running bootstrap, which the note never
mentions. True of that copy, not of this repository: the installed one holds
the notes it was installed with, and nothing in v5 installs newer ones.
It says to run from the checkout now, which is what the tool's own
documentation says everywhere else and what v5 should have said.
THE TEST THAT WAS MEANT TO CATCH THIS EXEMPTED IT. It asked whether a note's
`layers` mentioned the guest, on the reasoning that a guest step means
bootstrap ran. v5 has a guest step - one line in authorized_keys - and
installs nothing, so it was waved through and shipped broken.
The question was never about the guest. It is whether bootstrap runs, so the
test asks that now, and it fails on v5 as it shipped.
`bats test/` 171 passing.
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.
`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.
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.
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.
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.
They were reachable only from docs/README.md and from a section called
"Versions, and the boxes already out there" - background, three quarters of
the way down, with no command in it. An operator with an existing devbox had
nothing to skim to.
There is now an "Updating an existing devbox" section, in the nav table where
someone with a box already will look, containing the thing they actually
need: how to ask what version it is at, and the instruction to walk the notes
in order because each assumes the one before and some install what a later
one uses.
Both runbooks now say at the top that they build a devbox rather than update
one, and point at it. That is the page someone lands on when they come back
to change a rule.
Also folds the "from a checkout, not the installed copy" caveat into the
flow, where it is acted on, rather than leaving it as a property of the tool
that a reader meets only after being surprised by it.
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.
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.
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.
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.
The runbooks are the product, and a command in one is not an illustration -
it is something an operator pastes into a root shell on a hypervisor while
following the page. A block that does not parse is a defect in the
deliverable, exactly as a dangling link is.
This exists because of a near-miss in the previous commit. Restructuring
prose around a fenced block turned a `\` line continuation into `\\`, which
is an escaped backslash rather than a continuation. The page rendered fine,
the diff read as prose, and the helper on the other side of it would have
failed for anyone who pasted it - reporting an error on the line after the
one that was wrong.
Nothing else here would have caught it. links.bats reads markdown structure,
addresses.bats reads for leaked literals, and neither looks inside a fence.
Review does not catch it either: nobody counts backslashes at end of line.
PLACEHOLDERS ARE SUBSTITUTED BEFORE PARSING, and that is load-bearing rather
than a convenience. `<name>` is this repository's idiom, not shell, and shell
reads it as redirection - so whether a block parsed would otherwise depend on
how many angle brackets landed on a line. `try <forge-ip> 443` parses by
accident as two redirects; `ssh <you>@<vm-ip>` does not, because the trailing
`>` has nothing after it. Without the substitution the test would push the
docs towards whichever placeholder spelling appeased the parser.
The pattern requires a letter after the `<`, which leaves `3<>/dev/tcp/...`
and `<<EOF` intact - both real shell, and both things a naive substitution
would corrupt into a passing test.
It parses and does not execute, which is the only safe thing to do with a
file full of `sudo iptables`. So it checks syntax, not whether a command is
correct or does what the prose around it claims.
Verified by reintroducing the original bug and watching it fail with the
right file and line, then removing it again. 138 passing.
The page opened with 682 words before the first click and no indication of
how much work was coming. That is most of what made it daunting: not the
length itself, but that the length was unmeasurable from the top.
It now opens with the whole job as a five-row table - what you do, where it
is, and roughly how much - so the shape and the scale are visible before any
of the reasoning. Four of the five rows are one setting or one form; the
table says so, and says that step 4 is the substance. Someone who has done
this before can go straight to the rule table from there.
The console-only argument moved to the end as "Why there is no script for
this page". It is the page's philosophy, not something a reader needs before
turning on a firewall switch.
Verification was one 1,000-word run of prose with the two halves separated by
a lowercase "and". It is now two labelled passes, each with its commands
first: pass one reads configuration, pass two tests behaviour, and the text
says once, at the top, that only the second is proof.
Also caught while moving it: the rewrite briefly turned the `try()` helper's
line continuation into an escaped backslash, which would have left anyone
pasting it with a broken function and no obvious cause. Repaired, and the
snippet is now checked with `bash -n` rather than by looking at it.
`bats test/` 136 passing. docs/building-a-devbox.md updated for the renamed
anchor.
An audit against attention budget, on the principle that a reader stops
within 100 words and then skims or leaves. Three findings, all self-inflicted
and mostly recent.
THE README SPENT ITS WHOLE BUDGET ON DISCLAIMERS. Twenty-five words of what
the tool does, then seventy-five of unreviewed, unaudited, unsupported,
out-of-spec. A reader learned what it is not before learning whether it
solved their problem. Same words, reordered: what it is, what a devbox is,
what it does not prevent, where to start - then the caveats, under a heading
that says they are caveats.
THE ARGUMENTS WERE RESTATED IN FULL THREE AND FOUR TIMES. "IPv6 has no
RFC 1918" appeared in the threat model, the runbook, the template and an
upgrade note, each in its own wording - four copies to keep right, which is
four to drift. The threat model is now canonical for security reasoning and
the template for rule design; the runbook and the notes link. That is this
repository's own stated rule, from the page that says of dejima's push
argument: "the argument is not restated here, so that there is one copy of it
to keep right".
THE RUNBOOK INTERLEAVED DOING WITH WHY. Section 3 was 4,481 words wrapped
around a fourteen-row table, read by someone at a console entering rule 7 of
12. Now 3,700, with the reasoning collapsed to a sentence and a link where
the full version already existed elsewhere.
Found while editing: "Then apply it, per VM" still said rules 7-10, "the four
DROPs", and "nothing follows rule 10". Stale since the IPv6 and ntfy rules
landed - I had updated the references I remembered and not these. Corrected,
and every other rule-number reference checked.
Net 1,098 words with no argument removed. `bats test/` 136 passing.
The ntfy rule was written as a special case. It is an instance of a pattern
this runbook did not have, and the pattern is the part worth documenting:
one port per service on the proxy, one rule per port on the node.
A rule here matches an address and a port and nothing else. Proxmox cannot
filter by hostname; a reverse proxy routes by one. So a rule naming a shared
proxy on a shared port reaches every service behind that port, and a rule
reading "the devbox may reach ntfy" actually says "the devbox may reach
whatever is on that port today" - a sentence that changes without anyone
touching the firewall.
Giving each service its own listen port closes that. The port is enforced on
the node, where the guest cannot reach it, so each rule grants exactly one
service and the list reads as what it claims to be.
The runbook section is now "Reaching a service behind a reverse proxy", with
ntfy as the worked example and a table to extend. The template block takes
the same shape, with a commented second line showing that further services
are the same address and a different port each time - never a second service
on a port already listed, which widens a rule without adding one.
Two proxy-side requirements are named because a rule is only worth what they
are, and both are already in dejima's docs/nginx-dejima.conf.example rather
than restated here: a reject default_server on each listen, or server_name
isolates nothing; and $http_host rather than $host on any port but 443, or a
service comparing Origin against Host refuses every request.
Tested with `bats test/` (136 passing). links.bats covers the new anchor,
since it globs every tracked markdown file.
Two corrections, both from a real deployment.
THE PORT IS NOT 443. A self-hosted ntfy usually sits behind a reverse proxy
on some other port, so the rule takes __NTFY_PORT__ alongside __NTFY_IP__ -
the same shape the front door already uses. Writing 443 would have been
right for nobody in particular and copied by everyone, and a pinhole to the
wrong port fails the same silent way the missing rule does.
AND THE DESTINATION IS USUALLY THE PROXY, WHICH CHANGES WHAT THE RULE
GRANTS. This rule matches an address and a port and nothing else. Proxmox
cannot filter by hostname; a reverse proxy routes by one. So a rule naming a
shared proxy reaches every service that proxy serves on that port - a
compromised devbox picks its own Host header and the firewall cannot tell
the difference.
"The ntfy server joins the blast radius" was therefore an understatement
whenever the two differ, which is the common case. The honest version is
that the exposure is whatever is behind that proxy on that port, that it
grows silently when someone adds an unrelated route, and that nothing in
this repository can see the list. Three ways to shrink it back to one
service are given, in order of preference.
Also notes that the rule may not be needed at all: if ntfy_url resolves to a
public address, internet egress from the VM is already unbounded and this is
redundant. Only a private address needs it.
Tested with `bats test/` (136 passing). The new test asserts the port stays
a placeholder, because a literal there is the kind of default that gets
copied rather than read.
dejima-notify-watch is a systemd user unit on the devbox, not something in a
container, so its push to ntfy leaves the guest and crosses the LAN. A
self-hosted ntfy is a private address, which the DROPs cover.
So on a devbox built to this runbook, dejima's remote access is configured,
enabled, and silently sends nothing. Nothing errors where the operator is
looking, the phone never buzzes, and the cause is in another repository's
documentation. The rule was always needed and was never written down.
Optional, and shaped like the DHCP pair: one host, one port, above the
DROPs, omitted entirely by anyone not using the feature.
The cost is written down rather than absorbed. The ntfy server joins the
VM's blast radius, so the rule names a host and not a subnet - a subnet rule
works identically from the operator's side and hands a compromised devbox
the LAN, which is the shortcut this documents against. It is not a new way
to phone home: internet egress from the VM is already unbounded and
deliberate, so this widens lateral reach and nothing else.
Nothing checks it, and the runbook says so. The audit verifies containment -
that what should be unreachable is - and a missing pinhole is the opposite
shape, so it passes either way. v3 therefore declares no verify-node and
mark-node will say the record is the operator's word for it.
The upgrade note assumes the operator may have added this by hand already,
because anyone using the feature probably has, and gives them the two things
to re-check: that it sits above the DROPs, and that it is not a subnet.
Also derives two upgrade tests from VERSION rather than a literal, so a
version bump breaks the tests that are about versions and no others.
Tested with `bats test/` (135 passing).
Adds a closing step. An upgrade that ends at 'take a snapshot' leaves the
operator without the one command that tells them both halves landed, and
without the habit of running it again later - which is when it earns its
keep, since status re-checks recorded assertions and a rule removed in six
months has nothing else to surface it.
Says to run it from the checkout rather than PATH, for the reason the tool
itself reports: an installed copy knows only the versions that existed when
it was installed.
A test now pins the rule that a note whose layers do not include 'guest'
cannot invoke the tool as if it were on PATH, because nothing in such a note
puts it there. It found a real instance in the sister project as soon as it
existed.
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.
The threat model and the runbook are descriptions of the current tool, not
a record of what moved. The IPv6 sections read as a changelog: what the rule
list used to name, what was reachable before, what the audit used to report.
That belongs in the pull request, which has it.
Same rules, same reasoning, stated in the present tense.
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.
State the requirement as Cline needing AVX2 rather than as a claim about
what a guest can fix from inside, which was both overstated and wrong:
the type is changeable from the console, just not while the VM runs.
Drop the harness count, which will drift as dejima adds harnesses.
Proxmox defaults a new guest to x86-64-v2-AES, which has no AVX. dejima
now supports Cline as a second harness, and Cline's CLI is a Bun-embedded
binary whose stock build needs AVX2: on a v2 guest it dies on SIGILL
before printing anything, with no baseline build to fall back to.
The runbook now asks for x86-64-v3 at creation time, says why not host
and why a flags allowlist cannot substitute, notes that changing it needs
a full stop/start rather than a guest reboot, and gives a /proc/cpuinfo
check for guests built before this.
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.
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.
The block does not use the directive and the section is about a firewall
pinhole. Explaining which nginx releases spell it which way asks the reader to
decide something unrelated to the rule they came here for, in the middle of the
part that matters.
Neither runbook said the account launching dejima needs a harness installed on
this VM and already signed in, and both halves fail in a way that points
somewhere else.
Signed in: the credential proxy runs host-side, reads this account's
~/.claude/.credentials.json and hands the container a placeholder. An account
that never signed in leaves it holding nothing, so sessions fail at the API and
the container is what looks broken.
Installed and run: the mounts under ~/.claude are host paths that have to exist
on this side first. A missing bind source is created as an empty root-owned
directory or refused outright depending on how it is declared, and neither of
those says the harness has never run here - the first quietly puts root-owned
directories in a home that is not root's.
So §5 now leads with the prerequisite and the order it implies, and states the
check in the harness's own terms - start it once as that account and see whether
it opens a session or asks you to log in - rather than duplicating an install
route that belongs to that project. The README's Use section carries the short
version, since it lists bootstrap and install.sh and until now implied those
were the whole of it.
Two corrections from following this section on a real box.
The notify service is a systemd user service on the VM, not something inside the
container, so the paragraph telling operators to add the name to dejima's
allowlist as well sent them to a list that never sees the post. What is worth
saying instead is the consequence: there is no second list, and nothing one
layer in narrows this pinhole the way that allowlist narrows what a session
reaches, so rule 6b's width is the whole answer. A failed post also surfaces in
a user unit's journal rather than in a session, which is a different place to
not be looking.
And the nginx block carried `http2 on;`, which is a startup error before nginx
1.25.1. The directive was spelled `listen ... ssl http2;` until then, so either
spelling breaks half the installs it is copied into and ntfy wants neither.
Dropped, with a line saying why and where to copy a working `listen` from.
Notifications post to an ntfy server on the LAN, which rules 7 to 10 drop, and
they drop it silently: no error in the session, no delivery, and a feature that
reads as switched off rather than as firewalled.
The rule that fixes it is one line and it is the widest exception on the page,
because on most networks ntfy is not a host. It is a name on the reverse proxy
that fronts everything else, so a rule naming that proxy on 443 grants every
service behind it. These rules match an address and a port and cannot see the
Host header or the SNI, which are the only things that tell one vhost from
another. Nothing catches it afterwards either: the audit probes the gateway, the
gateway is still dropped, and PASS keeps being reported on a box that can now
reach the whole proxy.
So the section states the requirement and then spends most of its length on the
version of it that gives the boundary away, with the two shapes that do not (a
dedicated port, or a dedicated address with every other vhost pinned off it),
the nginx for the first, and three probes from the VM where the third is the one
that says whether it worked. Two things the firewall cannot express are named
alongside it: a publish-scoped ntfy token, and dejima's own container allowlist,
which is a separate list that also has to have the name.
The template carries the rule as an optional placeholder line above the DROPs
with the same warning, and the "two hosts" claim it makes about the remaining
LAN exposure now says when it is three.
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.
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.
The node runbook never told anyone to install an operating system. It created a VM in section 2, configured a firewall in section 3, and ended section 3 by testing that firewall from inside the VM - a VM with no OS on it, because installing one is the guest runbook's section 1 and this page never pointed there.
The two documents interleave and neither said so. The guest runbook claimed the node page should be done through the end of its section 3 first, which cannot happen for the same reason.
There is now an order section near the top, an explicit handoff to install the OS at the end of section 2, and a handoff back to the guest runbook at the end of section 3. The guest runbook defers to that order rather than stating its own.
It also names the trap in the ordering, which is why the sequence is what it is rather than a preference. The Debian installer takes its address by DHCP whatever you intend to pin it to afterwards, and the lease reply needs the inbound rule from section 3. Apply the group before installing, without the DHCP pair, and the installer has no network - which presents as a broken mirror or a dead link, not as a firewall switched on ten minutes earlier. Ticking the Firewall checkbox in section 2 is safe on its own; the group and the policies are what start dropping things.
The link guard caught a wrong anchor while I was writing it, which is the second time it has paid for itself.
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.
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.
Two things dejima states canonically and this repository did not carry.
The maturity disclaimer, in the same three places dejima pins it: README, SECURITY.md and the document that argues at length for security properties. Its own wording covers all three repositories by name - these projects, any of these repositories - so a reader landing here would not have seen it. Duplicated rather than linked, which is the opposite of what attribution and the security policy do, and deliberately: a disclaimer behind a link is a disclaimer the reader does not see, and its whole value is being on the page where the argument is being made.
test/disclaimer.bats pins it in all three. The line is the one most likely to be dropped without anyone deciding to drop it, by a rewrite of a section that happened to contain it, and every other test here would still pass on a repository that had lost it. It earned its place immediately: a git checkout during development reverted the README paragraph and the guard caught the commit that would have shipped without it.
The push rule goes in the credentials section, where it belongs: a session's forge is the one you chose, and everywhere else is where its dependencies live. Linked rather than restated, because the argument has a canonical home and a second copy of an argument is a second copy to keep right.
The same badge and the same asset as dejima carries, placed where dejima places it: at the end of the opening block, before the first section heading.
test/links.bats only knew about <img src="...png">. The badge is markdown image syntax and an svg, so neither half of that test would have seen it - the assertion would have kept passing while pointing at nothing. It now walks every image the README references in either syntax and either format, and requires at least two so a README that lost one of them fails rather than passing with the other.
Checked by breaking the badge path and watching the guard name it.
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.
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.
The workflow asks for the confined tier on the grounds that bats over shell and markdown needs neither a Docker socket nor the host's network namespace, and that taking a privilege for no reason is what this repository is about.
But runs-on: is a request. Which runner answers it depends on which runners carry the label, and nothing in a job's log says whether the socket was mounted in. Without this file, 'we run confined' is a claim in a comment, and it would keep reading as true on a runner that grants everything.
The sister project has a file like this and it is doing a different job. There the runner is the product, and its failures name the program that should have configured it differently. This repository configures no runner and cannot fix one. What it checks is that the job got no more than it asked for, and its failures say so and point at dejima-ci-hardener rather than pretending otherwise.
Seven assertions: no Docker socket, not in the host network namespace, not masquerading as a devbox, and four on the allowlist this repository actually depends on for bats to install at all. The off-list probe is a public resolver rather than a documentation address on purpose, because an address that never answers would satisfy every negative check on a runner with no network, which is the exact failure this file exists to detect. DEJIMA_CI_TIER is declared by the workflow rather than sniffed, so landing on the wrong runner fails rather than quietly selecting a different set of assertions.
The checkout is bare, which is the property worth having: the assertions come from main, and a branch that could edit the file asserting it was not handed a Docker socket could assert anything it liked. There is no bootstrapping problem despite appearances, and the comment says so, because it looks real enough to be re-introduced by anyone who reasons about it briefly. Under pull_request_target the workflow is read from main too, so this job cannot run on the pull request that introduces it, and by the time it first fires main has both the job and the file.
This repository is mostly prose, and the prose is the product: the runbook is the thing being shipped rather than a description of something else. A link that goes nowhere is a defect in the deliverable.
It is also the kind of defect review does not catch, because a link reads as correct whether or not it resolves. The ones most likely to be wrong are forward references, which is every link in a runbook written in the order a reader will follow it - four in this repository were dangling at once while it was being built, each perfectly plausible on the page.
Anchors are the harder half and the reason this is mechanical rather than a habit. A heading renamed for clarity orphans every link to it, and nothing about either file looks different afterwards.
The fourth test asserts the guard fails on a broken link of each kind. A checker nobody has watched fail is a checker nobody should trust, and this file would otherwise be one more thing that is green for reasons no one has confirmed.
The README leads with what a devbox is and why it is a different problem from a CI runner: it holds uncommitted work, so it cannot be rolled back, and rollback is the entire containment story of the sister project. What this does not claim comes before what it does, because for a security tool an overstated claim is what turns a warranty disclaimer into an argument about misrepresentation.
CI runs on the CONFINED tier, not the privileged one the sister repository uses. Everything here is bats over shell, YAML and markdown; nothing needs a Docker socket or the host's network namespace, and asking for a runner that grants both would be taking a privilege for no reason in a repository whose subject is not doing that. The resolution diagnosis was rewritten to match: on this tier .dejima/allowed-domains is a possible cause, and it is named first because it is the one thing a change here can break.
The compose-schema and runner-env jobs are dropped. They assert on runner tiers this repository has no equivalent for.
The wordmark comment is copied verbatim, including the reasoning about Gitea's sanitizer stripping style attributes and why every space is a real U+00A0. Both files ship so a mirror on a light-themed host changes one line.
threat-model.md ranks the risks by what is actually likely rather than by what sounds serious, and puts lateral movement first because that is what a devbox is exposed to during ordinary use rather than under attack.
It argues the container-escape deprioritisation rather than asserting it, and names the CVEs so a reader can check them. The decisive point is that an escape would be a downgrade: a session already holds a forge credential and the container's mounts, so landing in an unprivileged account on the VM grants less than it already had.
The rejected options carry their reasons and the conditions that would overturn them. That section exists so a decision can be revisited on new information instead of re-argued from nothing, which is what happens when only the conclusion is written down.
building-a-devbox.md is the guest half and links to the node half rather than duplicating it, so another hypervisor can be added later without touching a guest-side step. Two snapshots, because preflight and clean answer different questions. Two sections with no counterpart in the sister project: keeping credentials off the box, and patching as the whole of the response to escape.
Every internal link and anchor was checked against the heading it targets.
Gated on DEJIMA_DEVBOX rather than on detecting a devbox. A detector that guesses wrong skips silently, and a suite passing by asserting nothing is worse than one that fails, because the first gets believed. The sister repository has DEJIMA_CI_TIER for the same reason, added after a job landed on the wrong runner and quietly checked nothing.
Three of the seven are not in the plan and earn their place. The unit's ExecStart is resolved from systemctl and checked for being executable, which is the 203/EXEC failure caught on a real host rather than in a template. The service is checked for running reach and not all, because a timer running the credential inventory would accumulate it in the journal where it outlives the run. And the public internet is checked as reachable, because without it the gateway assertion passes on a machine with no network at all: PASS has to mean contained, not unplugged.
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.
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.
The timer exists because the rules it checks are edited on the hypervisor, in a console, possibly by someone not thinking about this VM at the time. A rule dragged below the DROPs or a Firewall checkbox cleared while debugging something else produces no symptom here at all: the box keeps working, it is simply no longer contained. Hourly means nobody has to remember.
The service runs reach and not all. That is a security decision rather than a tidiness one: the radius report inventories credentials by path, and a timer would write that into the journal on a schedule, where it outlives the run and is readable by anything that later gets root.
units.bats is not a straight copy. It read BIN_DIR out of install.sh, and this repository has none by design, so it now reads the one entry point. Copied unchanged it would have passed by reading a file that does not exist. Its first test also passed vacuously with no units present, because grep failing to open a file gives the same non-zero status the test was looking for; it now asserts there is something to check before checking 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.
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.
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.
Section 4 no longer automates anything. A CI runner is rebuilt on a timer because nothing on it is worth keeping; a devbox holds uncommitted work, so a rollback on a cadence destroys the thing the machine exists to hold. What replaces it is pushing more often, knowing the rebuild cost by having paid it once, and keeping credentials off the box so a rebuild is the whole of the recovery.
The rules go from eight to ten. The forge gains tcp/22 because a devbox pushes over SSH, and inbound becomes two client-subnet lists rather than one management host, with the front door on the narrower of the two. That last one is not the intuitive way round, so it is argued rather than asserted: both services put a terminal in front of whoever reaches them, and they differ only in what stands in front, where a public key beats basic auth with no rate limiting.
The from-inside verification probes the default gateway rather than another host on the LAN. Naming another host would write down something worth attacking on the machine this page assumes will be owned; the gateway is already in the routing table, is guaranteed alive, and must be unreachable as a destination while traffic through it is untouched. It also carries the note that silence proves nothing when nothing else is getting through, which is why the audit reports three outcomes.
Copied from the sister repository and changed in four places: the boundary note says why a devbox has only one, inbound gains two client-subnet lists rather than a single management host, the forge gains tcp/22 because a devbox pushes over SSH, and section 4's rollback cadence has no counterpart here.
The policy_out: ACCEPT argument is copied intact. It was already right, and restating it in different words would only weaken it.
test/firewall.bats pins the thing the sister runbook itself calls the one thing that is easy to get wrong. Order decides whether the exceptions work at all, every exception here is to an address inside a range this file drops, and the console appends new rules at the bottom - so a dead rule is the default outcome of adding one later, not an unusual slip. It also pins the DHCP pair as both-or-neither, since the outbound half is the tempting one to add alone and on its own it renews nothing.
The guard comes first so every later commit is checked by it, and so the allowlist it carries is trimmed to what this repository can justify before there is anything to justify. The sister repository's entries for its job network and its fixtures are gone; the four ranges the node firewall drops and the three RFC 5737 ranges remain.
Private now and public later changes nothing about this. Redaction before the first commit is free. Redaction afterwards means rewriting history and living with whatever was already cloned.