7d1e1839a8ca82827c594b0ada9112711ec64db0
23
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
58a6bc0632 |
Follow dejima-guard's PR 20 through this repository's notes
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. |
||
|
|
fddbeb119e |
Drop the em-dashes, and repair the table one of them was holding up
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. |
||
|
|
5ec2d22b88 |
Use a placeholder account in the sudoers example
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. |
||
|
|
58908e8b5c |
Install the guard's key from the machine that has it
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. |
||
|
|
2884608066 |
Let dejima-guard collect from this devbox, without giving it a shell
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.
|
||
|
|
71f6b0aabb |
Link the upgrade docs from the README, as a flow rather than a mention
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. |
||
|
|
0b7751e20e |
Merge main into versioning: keep main's ntfy work, keep the restructure
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. |
||
|
|
50a37c52a2 |
Give the runbook a map, and put the reasoning after the doing
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. |
||
|
|
7dc48c794d |
Merge pull request 'Bound the LAN over IPv6, and over the range a tailnet uses' (#16) from ipv6-boundary into main
Reviewed-on: #16 Reviewed-by: branderson <brad@branderson.io> |
||
|
|
7cba3932c2 |
Bound the LAN over IPv6, and over the range a tailnet uses
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. |
||
|
|
51024cb70a |
Check for a harness without deciding which one dejima runs
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. |
||
|
|
903983d63f |
Say the harness step where the operator is standing when they skip it
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. |
||
|
|
54085211c6 |
Say that the harness is installed and signed in here, before dejima runs
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. |
||
|
|
1d1c09addd |
Put the notify service where it actually runs, and drop http2
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. |
||
|
|
ce445fc8f4 |
Say what dejima's notifications cost, before someone pays it
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. |
||
|
|
a4c90efd7b |
Hand off with dejima's dependencies met and its source on the box
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. |
||
|
|
c784e0cc88 |
Report findings, not reasoning, and fix the kernel currency check
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. |
||
|
|
eee60942ab | Install dependencies | ||
|
|
e9651446bb | Fix placeholder forge address | ||
|
|
1f22689eda |
Send the reader between the two halves, in the order that works
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. |
||
|
|
d2d00dba90 |
Correct the escape argument, and state how poisoning is handled
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. |
||
|
|
783c508d97 |
Carry dejima's canonical statements: the disclaimer, and the push rule
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. |
||
|
|
8b86a4d4ad |
Write the threat model and the guest-side runbook
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. |