Files
dejima-ci-hardener/.dejima.md
branderson 045f8d817f
tests / runner-env (confined) (pull_request_target) Successful in 38s
tests / tests (pull_request_target) Successful in 1m33s
tests / compose-schema (pull_request_target) Successful in 47s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
Write down what this migration taught, where the next agent will read it
This repository had no .dejima.md. The first rule in it is the one the v3
migration paid for: 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. --network and --dir became --pool; the count, the
label and the group name became things read from the project already in the
directory.

Three more, all from the same week and all invisible from reading the code:
run a migration before shipping the note describing it, since the suite passed
throughout while every step of that note failed; the scoping is the
registration and never the label, because a branch writes its own runs-on; and
a set is addresses with no memory of what put them there, which is why
revocation has to flush and why scoping the resolver alone would have bought
nothing.
2026-09-01 23:23:50 -07:00

4.1 KiB

Working on dejima-ci-hardener

Notes for an agent working on this repo. Not a description of what the tool does: README.md and docs/threat-model.md are that.

Remove the value an operator would type, rather than documenting it better

When a step needs a path, a name or a number typed in, the fix is almost never a clearer instruction. It is to take the value from wherever it is already recorded, and to have the command name the thing rather than describe it.

This repository is where the lesson was learned. The v3 migration asked for a network and a project directory that had to agree with each other and with a third file, once per pool:

sudo dejima-ci-runners --role confined --network dejima-ci-1 --dir ~/runners/one

Run a second time it gets copied, the path gets changed because the path is the part that obviously differs, and the network does not. Both pools then run on the first one's network, which is one pool holding another's allowlist and looks exactly like it worked. The instruction was accurate. The shape was wrong.

It became --pool <scope>, taking both values from the file that already knew them. The same pass removed three more: the runner count, the label and the group name now come from the compose project that is already in the directory, because an operator re-running the generator was reading their own count out of a compose file in another terminal to answer a prompt.

Where a value genuinely has to be given, make the wrong one refuse rather than proceed. --network still exists, and pointing a project at a different pool's network now stops with both names in the message. A check that catches the mistake is worth more than a paragraph describing it: the paragraph is read once, the check runs every time.

Run a migration before shipping the note that describes it

Every step of the v3 note failed on a real host, and none of it was visible from reading the code. The suite passed throughout.

  • The installer's runner prompt defaulted to yes, so pressing enter through a re-run walked into replacing existing pools.
  • The generator offered built-in defaults rather than the pool's own values.
  • A documented command omitted a required --role, and answered with usage.
  • The group name defaulted from the label, which is shared between pools, so accepting it would have orphaned the running containers and volumes.
  • The runners came up reading a stale config.yaml, because it is bind-mounted as a file and write_if_changed replaces the inode by rename, and compose will not recreate a container whose service definition did not change.

Before shipping a note, run its commands against a fixture in the state the note targets, which for an upgrade means the already-configured state rather than a fresh one. Where a step cannot be run here, say so in the pull request rather than presenting the note as tested.

The scoping is the registration, never the label

A pool's firewall set holds the union of every declaration it could be made to run, which is decided by what its runners are registered to: one repository, or an owner.

Labels cannot carry this. A branch writes its own runs-on, so a label is a request the job makes rather than a property the operator sets. Anything that starts to route by label for a security reason is heading somewhere that does not hold.

The corollary is that an owner-scoped pool holding its whole owner's names is correct rather than a compromise. That union is what the pool can already be asked to run.

A set is addresses, with no memory of what put them there

ipset holds resolved addresses. It cannot be asked which name added one, which decides more than it looks like it does:

  • removing a name from a declaration revokes nothing on its own, so a render that drops a name flushes the sets and lets the next lookup refill them;
  • scoping the resolver alone would have bought nothing, because a job that cannot resolve a name can still reach the address another pool's lookup put in a shared set.

Anything reasoning about "which project does this address belong to" is reasoning about something the kernel is not storing.