Bring across what the sister project grew, and write down the threat model #15

Merged
branderson merged 7 commits from parity into main 2026-08-10 22:36:41 -07:00
Collaborator

Summary generated by LLM agent

#16 merges first. This branch is stacked on it, so until that lands this PR's diff also shows the badge and the maturity disclaimer. Both disappear from it the moment #16 is in main; nothing needs re-pushing.

What is left here is the part that wants review.

A parity check against dejima-devbox-hardener found gaps in one direction only. This closes them.

The sister project inherited this repository's chassis and then grew things while being built. None of them came back, and one is a real defect rather than a missing nicety.

The runbook asked you to write down a target

proxmox/README.md's verification step read:

try <some-other-lan-ip> 22     # must be blocked - any LAN host but the two

That asks the operator to name something worth attacking, on the machine the same page tells them to treat as already lost. It is exactly the thing dejima-devbox-hardener refuses to do, and it was here first.

The replacement probes the default gateway, which is better on three counts rather than merely safer: the address is already in the routing table so naming it reveals nothing; it is guaranteed to be alive, which an arbitrary host is not; and it sits inside the ranges the DROP rules cover, so with the rules applied it must be unreachable as a destination while traffic through it to the internet is untouched.

The note that a blocked first probe means nothing when the other three also fail comes with it. Silence proves nothing when nothing is getting through.

test/firewall.bats

This repository's own runbook calls rule ordering "the one thing here that is easy to get wrong", and nothing checked it.

Every exception in the template is to an address inside a range the same file drops. The console appends new rules at the bottom. So a dead rule is the default outcome of adding one later, not an unusual slip — and a dead rule is silent: the VM boots, the console shows the rules present, and the runner simply cannot reach its forge.

It also pins policy_out: ACCEPT, since inverting it is the tempting change the template argues against at length, and the DHCP pair as both-or-neither, since the outbound half is the one that looks sufficient alone.

test/links.bats

This repository has more documentation than the sister one, which had four dangling links simultaneously while it was being built. The fourth test asserts the guard fails on a broken link of each kind, because a checker nobody has watched fail is not worth trusting.

units.bats had a test that could assert nothing

Its first test passes when systemd/ is empty or absent, because grep failing to open a file produces the same non-zero status the assertion was looking for. In a file whose entire subject is a check that silently did nothing on every host, that was worth fixing.

docs/threat-model.md

New, and written for this project rather than copied. The adversary here is a pull request, and the premise is that CI executes attacker-controlled code deliberately, because that is what CI is for.

The two tiers have different boundaries and the table says which. A privileged job reaches root by design, with no exploit, which is why that tier's containment is entirely external. Rollback is named as the distinctive control and explicitly as not a detection mechanism: it destroys evidence along with the intruder and will happily rebuild a VM compromised through a route that is still open.

On container escape it states the accurate thing rather than the quotable one. An escape grants more than the container had — root is more than a job container has, and any claim that it would be a downgrade is wrong. What is true is that it does not leave the model: root on the runner VM is already a row in the table, reached on the privileged tier with no exploit at all, and bounded by the same two controls however it was obtained.

Cross-project context poisoning is named as not applying here, since a runner mounts no ~/.claude and runs no dejima session. It is mentioned only so a reader comparing the two documents does not wonder whether it was missed.

What the audit found going the other way

Nothing. Everything dejima-devbox-hardener lacks from this repository — the allowlist, the runner generator, the DOCKER-USER firewall, examples/, install.sh — it lacks deliberately, and each has a reason recorded there.

Verified

bats test/ passes, 97 ok and 0 failed, up from 87. The address guard and the new link guard both cover the new document.

A note on what is not in the prose

The documents describe the current state and not how it got there. No closed tickets from other repositories, no "this used to say", no arguments against wordings an earlier draft used. A reader arriving now was never in those windows, and the explanation costs them more than it gives.

What is in #16 instead

The Developed in dejima badge and the maturity disclaimer, both independent of everything above. This branch adds the two pieces that could not go with them: docs/threat-model.md does not exist on main, so the disclaimer's third location and the matching entry in test/disclaimer.bats arrive here, and test/links.bats arrives here too, carrying the widened image test that would catch a broken badge reference.

**Summary generated by LLM agent** **#16 merges first.** This branch is stacked on it, so until that lands this PR's diff also shows the badge and the maturity disclaimer. Both disappear from it the moment #16 is in `main`; nothing needs re-pushing. What is left here is the part that wants review. A parity check against `dejima-devbox-hardener` found gaps in one direction only. This closes them. The sister project inherited this repository's chassis and then grew things while being built. None of them came back, and one is a real defect rather than a missing nicety. ## The runbook asked you to write down a target `proxmox/README.md`'s verification step read: ```sh try <some-other-lan-ip> 22 # must be blocked - any LAN host but the two ``` That asks the operator to name something worth attacking, on the machine the same page tells them to treat as already lost. It is exactly the thing `dejima-devbox-hardener` refuses to do, and it was here first. The replacement probes the **default gateway**, which is better on three counts rather than merely safer: the address is already in the routing table so naming it reveals nothing; it is guaranteed to be alive, which an arbitrary host is not; and it sits inside the ranges the DROP rules cover, so with the rules applied it must be unreachable *as a destination* while traffic *through* it to the internet is untouched. The note that a blocked first probe means nothing when the other three also fail comes with it. Silence proves nothing when nothing is getting through. ## `test/firewall.bats` This repository's own runbook calls rule ordering "the one thing here that is easy to get wrong", and nothing checked it. Every exception in the template is to an address inside a range the same file drops. The console appends new rules at the bottom. So a dead rule is the *default* outcome of adding one later, not an unusual slip — and a dead rule is silent: the VM boots, the console shows the rules present, and the runner simply cannot reach its forge. It also pins `policy_out: ACCEPT`, since inverting it is the tempting change the template argues against at length, and the DHCP pair as both-or-neither, since the outbound half is the one that looks sufficient alone. ## `test/links.bats` This repository has more documentation than the sister one, which had four dangling links simultaneously while it was being built. The fourth test asserts the guard fails on a broken link of each kind, because a checker nobody has watched fail is not worth trusting. ## `units.bats` had a test that could assert nothing Its first test passes when `systemd/` is empty or absent, because `grep` failing to open a file produces the same non-zero status the assertion was looking for. In a file whose entire subject is a check that silently did nothing on every host, that was worth fixing. ## `docs/threat-model.md` New, and written for this project rather than copied. The adversary here is a pull request, and the premise is that CI executes attacker-controlled code deliberately, because that is what CI is for. The two tiers have different boundaries and the table says which. A privileged job reaches root **by design**, with no exploit, which is why that tier's containment is entirely external. Rollback is named as the distinctive control and explicitly as *not* a detection mechanism: it destroys evidence along with the intruder and will happily rebuild a VM compromised through a route that is still open. On container escape it states the accurate thing rather than the quotable one. **An escape grants more than the container had** — root is more than a job container has, and any claim that it would be a downgrade is wrong. What is true is that it does not leave the model: root on the runner VM is already a row in the table, reached on the privileged tier with no exploit at all, and bounded by the same two controls however it was obtained. Cross-project context poisoning is named as **not applying here**, since a runner mounts no `~/.claude` and runs no dejima session. It is mentioned only so a reader comparing the two documents does not wonder whether it was missed. ## What the audit found going the other way Nothing. Everything `dejima-devbox-hardener` lacks from this repository — the allowlist, the runner generator, the `DOCKER-USER` firewall, `examples/`, `install.sh` — it lacks deliberately, and each has a reason recorded there. ## Verified `bats test/` passes, 97 ok and 0 failed, up from 87. The address guard and the new link guard both cover the new document. ## A note on what is *not* in the prose The documents describe the current state and not how it got there. No closed tickets from other repositories, no "this used to say", no arguments against wordings an earlier draft used. A reader arriving now was never in those windows, and the explanation costs them more than it gives. ## What is in #16 instead The `Developed in dejima` badge and the maturity disclaimer, both independent of everything above. This branch adds the two pieces that could not go with them: `docs/threat-model.md` does not exist on `main`, so the disclaimer's third location and the matching entry in `test/disclaimer.bats` arrive here, and `test/links.bats` arrives here too, carrying the widened image test that would catch a broken badge reference.
dejima-agent changed target branch from main to disclaimer-and-badge 2026-08-10 20:47:30 -07:00
dejima-agent changed target branch from disclaimer-and-badge to main 2026-08-10 20:47:51 -07:00
dejima-agent added 9 commits 2026-08-10 20:47:51 -07:00
The same asset and markup dejima carries, placed where dejima places it: at the end of the opening block, before the first section heading.
Pin the maturity disclaimer on every entry point
tests / runner-env (confined) (pull_request_target) Successful in 1m40s
tests / tests (pull_request_target) Successful in 2m8s
tests / compose-schema (pull_request_target) Successful in 1m50s
tests / runner-env (privileged) (pull_request_target) Successful in 1m37s
c61eebb5b5
dejima carries this and its wording covers all three repositories by name: these projects, any of these repositories. 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 here, 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 security argument is being made. Everything else that gets linked is reference material someone goes looking for; this is the one thing that has to arrive uninvited.

test/disclaimer.bats pins it. The line is the one most likely to be dropped without anyone deciding to drop it - by a rewrite of some section that happened to contain it - and every other test here would still pass on a repository that had lost it.
A parity check against dejima-devbox-hardener found gaps in one direction only. This closes them.

The runbook's verification step asked the reader to probe a named host on their own LAN, which means writing down something worth attacking on a machine the same page says to treat as already lost. It now probes the default gateway: already in the routing table so it reveals nothing, guaranteed alive where an arbitrary host is not, and inside the ranges the DROPs cover so it must be unreachable as a destination while traffic through it is untouched. The note that a blocked first probe means nothing when the other three also fail comes with it.

test/firewall.bats is new. This repository's own runbook calls rule ordering the one thing here that is easy to get wrong and nothing checked it. Every exception is to an address inside a range the same file drops, the console appends new rules at the bottom, and a dead rule is silent. It also pins policy_out: ACCEPT, since inverting it is the change the template argues against at length, and the DHCP pair as both-or-neither.

test/links.bats is new, and this repository has more documentation than the sister one.

units.bats had a test that passes when systemd/ is empty or absent, because grep failing to open a file gives the same non-zero status the assertion wanted. In a file whose subject is a check that silently asserted nothing, that was worth fixing.

docs/threat-model.md is new and written for this project. The adversary is a pull request. The tiers have different boundaries and the table says which. A privileged job reaches root by design, with no exploit, which is why that tier's containment is entirely external. Rollback is named as the distinctive control and as explicitly not a detection mechanism, since it destroys evidence along with the intruder.

On escape it says the accurate thing: an escape grants more than the container had, because root is more than a job container has, and it does not leave the model, since root on the runner VM is already a row in the table reached on the privileged tier with no exploit at all.
The same badge and asset as dejima carries, placed where dejima places it: at the end of the opening block, before the first section heading.

The image test in this commit's sibling only knew about <img src="...png">. The badge is markdown image syntax and an svg, so neither half of it would have seen the badge - 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 fails rather than passing with the other.
The entry said a human approving a diff they have not read is a control in name only, and that reviewers who do read it are the bottleneck the agent workflow removes. That is wrong twice: it is not the reason, and it reads as a judgement that code review is too weak to gate CI, which implies CI could be gated on review if you were willing to wait.

It cannot. dejima's docs/ci.md works through every version of an approval gate and each fails mechanically rather than through inattention. Changing the trigger does not help because the workflow file lives in the tree the branch controls. Dispatching by hand does not, because a token that can open a pull request generally covers the dispatch endpoint. Leaving the runner stopped does not, because a job that cannot land on your runner lands on someone else's. Gitea's own approval requirement comes closest and applies to pull requests from forks, which is exactly what a session pushing a branch to the same repository is not.

Review is downstream of the run by construction. The chain from a session writing code to a runner executing it needs no operator action at any step, so by the time there is a diff to read it has already run.

The entry now also names the control that does work and that the old wording obscured: not granting the session push access at all. No push, no branch, no pull request, no run. It is why dejima blocks GitHub push by default and it is a legitimate answer for some projects. This repository exists so it is not the only one - granting push access should be a decision about how you want to work rather than a decision to accept unbounded code execution on your network.
The approval-gate entry explained that review cannot gate a CI run, and stopped there. Read on its own that invites the conclusion the tool is meant to replace reviewing what you merge, which is the opposite of dejima's stated position and of what this repository actually relies on.

dejima-ci-allowlist reads .dejima/allowed-domains from origin/main, never from a branch or a local checkout, and treats what it finds there as already reviewed. That is the entire mechanism by which a branch cannot widen its own egress: the answer comes from somewhere a branch cannot write. The only way content reaches main is a pull request somebody merged, so the allowlist is exactly as trustworthy as the merges are, and merging a diff unread is not a lapse this tool compensates for - it is how a widened allowlist arrives, with the tool then serving it faithfully.

A What this tool assumes section says that, in the same place the sister project states its own precondition, and links to dejima's responsible-use.md for the safe-against-responsible distinction. The approval-gate entry now says explicitly that it is about the run and nothing else: review still gates the merge, that is still the operator's job, and neither control substitutes for the other.
The entry said not granting push access is a real answer and that it is why dejima blocks GitHub push by default. The second half was a wrong attribution: it read the default as a mitigation for the CI chain, which is this document's concern and not the reason the default exists.

The reason is narrower and holds whether or not CI exists anywhere: a session not being used to push to a forge should not be able to push to it. The case that makes it concrete is an eager agent rather than a compromised one - it finds a real bug in an unrelated project and helpfully pushes a fix branch to somebody else's repository. Nothing about that is an attack and the change might even be good, and it is still something the operator should have decided rather than discovered. Closing the CI chain falls out of that principle rather than being the point of it.

dejima's default is stated as the observable behaviour it is - GitHub push blocked at the network layer, port 22 unreachable without --allow-github-push, so there is no transport regardless of what any guard catches - rather than as evidence for a motive its own documentation does not claim.
The push-access entry carried its own copy of the argument, and one line of it had already drifted from dejima's: it said the eager agent's pull request is something the operator should have decided rather than discovered, which puts the cost on the operator and implies approving it would be fine. dejima's version puts the cost where it lands - on maintainers already triaging a flood of unsolicited AI-generated pull requests - and says the block exists so it cannot happen, not so you get to approve it first.

That is the drift a second copy produces, found within a day of writing it. The entry now keeps only what this document needs, states that closing the CI chain follows from the principle rather than being the reason for it, and links to dejima's section for the argument so there is one copy to keep right.

The maturity disclaimer is carried in the same three places dejima and the sister project use, with test/disclaimer.bats pinning it. Duplicated rather than linked, which is the opposite of the decision above and deliberate: a disclaimer behind a link is one the reader does not see, and its whole value is being on the page where the security argument is being made.
Restate how the push block is enforced, not just that it exists
tests / runner-env (confined) (pull_request_target) Successful in 1m40s
tests / tests (pull_request_target) Successful in 2m13s
tests / compose-schema (pull_request_target) Successful in 1m54s
tests / runner-env (privileged) (pull_request_target) Successful in 1m35s
e7360de7ad
The entry linked out for the whole of it, which left a reader unable to judge the control without leaving the page. Whether not granting push access counts as a control at all depends on how it is enforced, and that is a fact rather than an argument: GitHub's ranges are allowlisted on 443 only, so a session clones and calls the API normally while port 22 is unreachable and an SSH push has no transport, whatever git-push-guard.sh catches.

The argument stays behind the link and the mechanism comes back. Arguments drift when they are copied - this repository proved that within a day - and a port number does not.
branderson approved these changes 2026-08-10 22:36:35 -07:00
branderson merged commit b6a6151512 into main 2026-08-10 22:36:41 -07:00
Sign in to join this conversation.