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.
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.