Compare commits

...
Author SHA1 Message Date
branderson c637d1dee6 Name the script that joins the forge's hosts into the allowlist
tests / confined (conversation) (pull_request_target) Successful in 1m2s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (providers) (pull_request_target) Successful in 2m0s
tests / confined (launch) (pull_request_target) Successful in 2m4s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / confined (share) (pull_request_target) Successful in 1m59s
tests / confined (remote) (pull_request_target) Successful in 2m55s
tests / confined (shellcheck) (pull_request_target) Successful in 1m17s
tests / confined (project) (pull_request_target) Successful in 3m28s
tests / confined (audit) (pull_request_target) Successful in 4m16s
tests / confined (static) (pull_request_target) Successful in 4m6s
tests / privileged (pull_request_target) Successful in 4m35s
tests / runner-env (privileged) (pull_request_target) Successful in 43s
tests / confined (config) (pull_request_target) Successful in 7m43s
`entrypoint.sh` did that until the egress control moved to a container of its
own. It still exists and still runs; what it no longer does is this.

The claim around it is unchanged and is the point of the paragraph: a line
added to a forge manifest widens the egress boundary on the next run. Only the
mechanism named had gone stale.

Found by the documentation half of the release pass, which is what that half
is for.
2026-09-11 12:17:52 -07:00
branderson 6a0ec12d41 Merge pull request 'Add [mounts] read_write_per_project, so a centralized directory is not a shared one' (#335) from rw-per-project into development
tests / confined (conversation) (pull_request_target) Successful in 1m6s
tests / confined (providers) (pull_request_target) Successful in 1m59s
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (shellcheck) (pull_request_target) Successful in 1m13s
tests / confined (share) (pull_request_target) Successful in 1m53s
tests / confined (audit) (pull_request_target) Successful in 4m16s
tests / confined (static) (pull_request_target) Successful in 3m13s
tests / privileged (pull_request_target) Successful in 4m36s
tests / runner-env (privileged) (pull_request_target) Successful in 48s
tests / confined (project) (pull_request_target) Successful in 3m31s
tests / runner-env (confined) (pull_request_target) Successful in 48s
tests / confined (remote) (pull_request_target) Successful in 2m45s
tests / confined (config) (pull_request_target) Successful in 5m56s
Reviewed-on: #335
Reviewed-by: branderson <brad@branderson.io>
2026-09-11 11:18:31 -07:00
branderson 44bcee193f Recommend the list that does not make a shared pile
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (share) (pull_request_target) Successful in 1m53s
tests / confined (shellcheck) (pull_request_target) Successful in 1m13s
tests / confined (project) (pull_request_target) Successful in 3m8s
tests / confined (launch) (pull_request_target) Successful in 1m57s
tests / confined (remote) (pull_request_target) Successful in 2m45s
tests / confined (audit) (pull_request_target) Successful in 3m27s
tests / confined (static) (pull_request_target) Successful in 2m50s
tests / privileged (pull_request_target) Successful in 4m36s
tests / confined (config) (pull_request_target) Successful in 4m45s
tests / runner-env (privileged) (pull_request_target) Successful in 46s
tests / confined (conversation) (pull_request_target) Successful in 1m3s
tests / confined (providers) (pull_request_target) Successful in 1m53s
tests / confined (proxy) (pull_request_target) Successful in 1m10s
Both places that told an operator to use read_write for design docs now name
read_write_per_project, and the reference table carries a row for it.

The thing that decides between them is one question, so the docs ask it:
should another project's session read what this one leaves there? For a build
cache, yes. For anything written to be read later as background, no - and a
shared one is a channel from any project into every other.

The read_write example is a toolchain cache now. It was ~/specs, which is the
case that belongs in the other list.
2026-09-10 23:07:52 -07:00
branderson c88240b4b6 Test the slice, the split, and every list that must refuse the root
Eight cases. The two that carry the feature are the mount itself - this
project's slice at the path named, and the whole directory NOT mounted
alongside it - and two workspaces getting two directories, which one launch
cannot show and which is the entire point.

The rest are the refusals: a per-project root named in read_write or in
readonly, a per-project entry overlapping a guarded store, a root that does
not exist, and the same path in two lists.

Mutation-checked: mounting the root instead of the slice fails two, removing
the root refusal fails three, and removing the existing-directory check fails
one.
2026-09-10 23:07:52 -07:00
branderson c6a0a3ff77 Add [mounts] read_write_per_project, so a centralized directory is not a shared one
docs/configuration.md recommended `read_write` for design docs and scratch
notes - anything an agent writes at a fixed host path to be read later. That
is the arrangement the share_dir split removed: every session seeing every
other project's, across projects. A spec is written to be read as background,
which makes it threat-model item 4 with nobody attacking anything.

An operator declares ~/specs; the container gets ~/specs, backed by
~/specs/projects/<slug> rather than by the whole thing. The property that
separates these lists from share_dir survives - a mount arrives at the path it
names, so "put specs in ~/specs" goes on working - and the pile does not.

Its own key rather than a marker on read_write, which is what makes it small:
a key that did not previously parse has nothing in it on the day it lands.
Nothing moves, nothing is stranded, no migration. read_write stays right for
what it is good at, since some writable mounts are meant to be shared.

Three things the wiring could not skip:

The briefing gets a sentence of its own. The existing one says these are
"host directories ... at their own paths", which is untrue of a sliced mount
and would tell a session it can see what it cannot.

The exit audit gets the SOURCE paths, not the container ones. That report
names what on the host a session could have written, and naming the root there
would claim a blast radius this feature exists to have narrowed.

Naming a per-project root's `projects` directory in readonly or read_write is
refused, with a message about the pile rather than about approval stores.
That refusal is also what catches the same path appearing in two lists, since
a path named in both is an ancestor of its own projects root - so there is no
separate duplicate check, because one could never fire.

dejima creates the slice and never the root: a path that is not there is a
typo, and creating it would turn the typo into a directory. The slice is
created on the launch path rather than where it is resolved, so composing a
briefing with --print-briefing still leaves nothing behind.
2026-09-10 23:07:52 -07:00
branderson e953bfa5fa Name the per-project layout for what it is, not for its first user
`share_projects_root` and `share_dir_for` describe a layout rather than a
share: a host directory sliced per project holds one `projects` subdirectory
and one directory per slug inside it. [core] share_dir was the first thing to
want that; [mounts] read_write_per_project is the second.

So the layout gets the generic names and share_dir keeps its own spelling of
them, which is what lets bin/dejima-share go on talking about a share rather
than about "a per-project root". One definition either way, which is this
file's stated reason for existing: two callers disagreeing about where a
project's directory is means an operator dropping a file where nothing reads
it.
2026-09-10 23:07:52 -07:00
branderson 5e0a390839 Merge pull request 'Run the egress filter in a container the session cannot reach' (#328) from filter-container into development
Reviewed-on: #328
Reviewed-by: branderson <brad@branderson.io>
2026-09-10 22:07:10 -07:00
branderson 88d54cab78 Merge pull request 'Let PID 1 signal the session, which it has not been able to since #330' (#334) from tini-kill into filter-container
tests / confined (conversation) (pull_request_target) Successful in 59s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (share) (pull_request_target) Successful in 1m51s
tests / confined (providers) (pull_request_target) Successful in 1m53s
tests / confined (shellcheck) (pull_request_target) Successful in 1m13s
tests / runner-env (confined) (pull_request_target) Successful in 48s
tests / confined (remote) (pull_request_target) Successful in 2m36s
tests / confined (project) (pull_request_target) Successful in 2m57s
tests / confined (audit) (pull_request_target) Successful in 3m20s
tests / confined (static) (pull_request_target) Successful in 2m53s
tests / runner-env (privileged) (pull_request_target) Successful in 46s
tests / confined (config) (pull_request_target) Successful in 4m31s
tests / privileged (pull_request_target) Successful in 4m30s
Reviewed-on: #334
Reviewed-by: branderson <brad@branderson.io>
2026-09-10 21:47:38 -07:00
branderson d6f7f8c9d7 Read the agent socket from the session, which is the only process that has it
tests / confined (conversation) (pull_request_target) Successful in 1m5s
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (providers) (pull_request_target) Successful in 2m1s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (share) (pull_request_target) Successful in 1m58s
tests / confined (remote) (pull_request_target) Successful in 2m50s
tests / confined (project) (pull_request_target) Successful in 3m16s
tests / confined (launch) (pull_request_target) Successful in 2m3s
tests / confined (shellcheck) (pull_request_target) Successful in 1m14s
tests / confined (audit) (pull_request_target) Successful in 3m36s
tests / confined (static) (pull_request_target) Successful in 2m58s
tests / privileged (pull_request_target) Successful in 4m33s
tests / confined (config) (pull_request_target) Successful in 4m50s
tests / runner-env (privileged) (pull_request_target) Successful in 46s
The last /proc/1/environ read, and the one I left when I repointed the other
four - on the reasoning that tini's environment is the container's, so
SSH_AUTH_SOCK would be in it.

Wrong about this variable. Docker does not pass it: entrypoint.sh creates it,
by starting an agent, in a process that is tini's CHILD. So under `init: true`
PID 1 is the one process in the container that has never heard of it, and the
exec got an empty socket path and an ssh-add that found no agent.

Which is the whole lesson of this branch arriving twice: every environment
read in this file meant the SESSION's environment, and PID 1 only answered
correctly while it happened to be the session.
2026-09-10 21:37:34 -07:00
branderson 881e6343e5 Run an init in the fixtures, so the suite has a tini that can fail
tests / confined (audit) (pull_request_target) Successful in 3m26s
tests / confined (config) (pull_request_target) Successful in 4m37s
tests / confined (conversation) (pull_request_target) Successful in 59s
tests / confined (launch) (pull_request_target) Successful in 2m1s
tests / confined (project) (pull_request_target) Successful in 3m6s
tests / confined (providers) (pull_request_target) Successful in 1m55s
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (remote) (pull_request_target) Successful in 2m44s
tests / confined (share) (pull_request_target) Successful in 1m50s
tests / privileged (pull_request_target) Failing after 4m51s
tests / confined (static) (pull_request_target) Successful in 2m48s
tests / confined (shellcheck) (pull_request_target) Successful in 1m11s
tests / runner-env (confined) (pull_request_target) Successful in 50s
tests / runner-env (privileged) (pull_request_target) Successful in 43s
Nothing in this file ran one. Every container started with a bare `docker run`,
so PID 1 was the session itself and there was nothing forwarding signals to
it - which is how a floor that left tini unable to signal its own child went
green through a privileged run.

I knew the fixtures differed from production here: when a capability
assertion read /proc/1/status, I made the assertion PID-1-independent rather
than making the fixture faithful, because that was the smaller change. The
smaller change left the difference in place, and this is what it cost.

The session container gets --init, matching `init: true`. Four reads of
/proc/1/environ meant the SESSION's environment and now say so - under an init
PID 1 holds the environment Docker gave the container, which still carries the
empty ANTHROPIC_* pair one of them asserts the entrypoint unset, so they would
have started passing for the wrong reason.

And the failure itself, reproduced: `docker kill --signal=WINCH` delivers to
PID 1 through the same door the kernel uses, so the container must still be
running afterwards and the session must still be in it. Its own pair, because
a failure here would otherwise take every later test in the file with it.
2026-09-10 20:49:50 -07:00
branderson 668c3feccf Let PID 1 signal the session, which it has not been able to since #330
A tmux split ends the session with:

  [FATAL tini (1)] Unexpected error when forwarding signal: 'Operation not
  permitted'

`init: true` makes PID 1 an init that forwards every signal it receives to its
child. The entrypoint changes uid in place before exec'ing the session, so
that child's real AND saved uids are the sandbox user's while tini is uid 0.
kill(2) permits a signal only with CAP_KILL or a uid match, and there is
neither - so every forwarded signal is EPERM, and tini treats a failed forward
as fatal. PID 1 exiting ends the container.

The signal that arrives in practice is SIGWINCH: the kernel sends it to the
pty's foreground process group on any terminal resize, tini included. So
resizing a pane kills the session.

Measured in the running container: PID 1 is uid 0 holding
chown,dac_override,fowner,setgid,setuid,setpcap, and its child is uid
1000/1000/1000/1000.

KILL was in this list for `dejima-allow --container`, which signals dnsmasq.
That reason moved to the filter container with dnsmasq and the capability went
with it - and this second reason, which had been quietly carried by the same
line for as long as `init: true` has been set, went unnoticed.
2026-09-10 20:49:50 -07:00
branderson 8fb4a4d39b Merge pull request 'Take either half of the pair, because a briefing hands over the filter's id' (#332) from allow-pair into filter-container
tests / confined (conversation) (pull_request_target) Successful in 1m2s
tests / confined (proxy) (pull_request_target) Successful in 1m6s
tests / confined (share) (pull_request_target) Successful in 1m49s
tests / confined (shellcheck) (pull_request_target) Successful in 1m12s
tests / confined (remote) (pull_request_target) Successful in 2m32s
tests / confined (config) (pull_request_target) Successful in 4m27s
tests / confined (providers) (pull_request_target) Successful in 1m49s
tests / runner-env (confined) (pull_request_target) Successful in 49s
tests / confined (project) (pull_request_target) Successful in 2m57s
tests / confined (audit) (pull_request_target) Successful in 3m15s
tests / confined (static) (pull_request_target) Successful in 2m45s
tests / privileged (pull_request_target) Successful in 4m26s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
Reviewed-on: #332
Reviewed-by: branderson <brad@branderson.io>
2026-09-10 19:47:33 -07:00
branderson 3b6c5c016f Merge pull request 'Fixes from the review of the filter-container stack' (#333) from review-fixes into allow-pair
tests / confined (conversation) (pull_request_target) Successful in 1m5s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / runner-env (confined) (pull_request_target) Successful in 49s
tests / confined (remote) (pull_request_target) Successful in 2m42s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / confined (project) (pull_request_target) Successful in 3m3s
tests / confined (audit) (pull_request_target) Successful in 3m23s
tests / privileged (pull_request_target) Successful in 4m15s
tests / confined (launch) (pull_request_target) Successful in 1m54s
tests / confined (providers) (pull_request_target) Successful in 1m54s
tests / confined (share) (pull_request_target) Successful in 1m50s
tests / confined (static) (pull_request_target) Successful in 2m50s
tests / confined (config) (pull_request_target) Successful in 4m36s
tests / runner-env (privileged) (pull_request_target) Successful in 45s
Reviewed-on: #333
Reviewed-by: branderson <brad@branderson.io>
2026-09-10 01:55:26 -07:00
branderson 0a6b55b5b9 Give the privileged job the compose plugin it was missing
tests / confined (conversation) (pull_request_target) Successful in 1m3s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (providers) (pull_request_target) Successful in 1m52s
tests / confined (launch) (pull_request_target) Successful in 1m57s
tests / confined (shellcheck) (pull_request_target) Successful in 1m13s
tests / confined (project) (pull_request_target) Successful in 3m7s
tests / runner-env (privileged) (pull_request_target) Successful in 45s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (share) (pull_request_target) Successful in 1m51s
tests / confined (remote) (pull_request_target) Successful in 2m45s
tests / confined (audit) (pull_request_target) Successful in 3m32s
tests / confined (static) (pull_request_target) Successful in 2m55s
tests / privileged (pull_request_target) Successful in 4m23s
tests / confined (config) (pull_request_target) Successful in 4m44s
`docker compose` is a CLI plugin, so without it the subcommand does not exist
at all - which is why the compose-file test failed there with "unknown
shorthand flag: 'f'" and a usage listing with no compose in it.

This job already installs its own docker CLI and buildx by hand, from
download.docker.com and github.com. compose comes from the same release host
as buildx, so this needs no allowlist entry - and the privileged tier has no
per-pool allowlist in any case, since its jobs run in the host network
namespace. Nothing outside this repository had to change.

Checked rather than assumed, the same way buildx is: an error page chmods as
well as a binary does, and here the symptom would be a test that skips rather
than one that fails. The asset is named for `uname -m`, like the CLI tarball
and unlike buildx, which uses the Debian architecture name.

What it buys is that CI parses the compose file at all. Every launch on a real
host goes through `docker compose`, and no job had ever run one.
2026-09-10 00:35:50 -07:00
branderson e250c705ed Skip the compose parse where there is no compose, and say what that means
tests / confined (conversation) (pull_request_target) Successful in 1m3s
tests / confined (launch) (pull_request_target) Successful in 2m4s
tests / runner-env (confined) (pull_request_target) Successful in 49s
tests / confined (shellcheck) (pull_request_target) Successful in 1m13s
tests / confined (audit) (pull_request_target) Successful in 3m34s
tests / confined (static) (pull_request_target) Successful in 2m54s
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (providers) (pull_request_target) Successful in 1m55s
tests / confined (share) (pull_request_target) Successful in 1m55s
tests / confined (remote) (pull_request_target) Successful in 2m50s
tests / confined (project) (pull_request_target) Successful in 3m14s
tests / privileged (pull_request_target) Successful in 4m21s
tests / runner-env (privileged) (pull_request_target) Successful in 44s
tests / confined (config) (pull_request_target) Successful in 4m50s
The privileged runner has the docker CLI and a socket and no compose plugin,
so the test I added to parse the compose file failed there on `unknown
shorthand flag: 'f'`. It skips when the plugin is absent.

A test that always skips is worth little, so the skip says the thing that
matters more than the test does: no CI job runs `docker compose` at all, which
means no CI job exercises the launch path bin/dejima actually takes. Every
container runtime.bats starts, it starts with plain `docker run`.

That is a coverage gap this test cannot close from inside CI. It closes it on
any host that has the plugin, which is every host a session is launched from.
2026-09-09 23:59:32 -07:00
branderson 2cbf1a414c Parse the compose file in a test, since nothing ever had
tests / confined (conversation) (pull_request_target) Successful in 1m1s
tests / confined (providers) (pull_request_target) Successful in 1m52s
tests / runner-env (confined) (pull_request_target) Successful in 49s
tests / confined (remote) (pull_request_target) Successful in 2m45s
tests / confined (shellcheck) (pull_request_target) Successful in 1m12s
tests / confined (project) (pull_request_target) Successful in 3m9s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (launch) (pull_request_target) Successful in 1m56s
tests / confined (share) (pull_request_target) Successful in 1m51s
tests / confined (audit) (pull_request_target) Successful in 3m29s
tests / confined (static) (pull_request_target) Successful in 2m52s
tests / runner-env (privileged) (pull_request_target) Successful in 44s
tests / privileged (pull_request_target) Failing after 4m16s
tests / confined (config) (pull_request_target) Successful in 4m38s
Nothing in this repo runs `docker compose` against a real daemon: runtime.bats
builds with plain `docker build`, and every launch test elsewhere stubs docker
entirely. A compose file that stopped interpolating would therefore reach an
operator before it reached a test, and it would present as a failing BUILD,
because `build` and `run` both parse the whole file first.

Which is how the claim that `network_mode` does not affect the build came to
be reasoned rather than observed. It is observed now, on the privileged
runner, along with what the default resolves to when nothing supplies a
namespace to join.

Every name the file interpolates without a default is supplied, read out of
the file rather than listed here. Two shapes and both matter: `${VAR:?}` fails
the parse outright, and a bare `${VAR}` resolves to the empty string, which is
a volume line with no source - `SANDBOX_SHARE_DIR` is one of those, and
supplying only the first kind would have failed on it.

Run by hand, `docker compose build sandbox` fails on four of those today. That
is correct - bin/dejima sets all of them well before it builds - and it is
exactly why this had never been checked.
2026-09-09 21:56:00 -07:00
branderson 3100a5f454 Check that a variable the filter container is given is one it can receive
tests / confined (static) (pull_request_target) Successful in 3m11s
tests / confined (config) (pull_request_target) Successful in 5m43s
tests / privileged (pull_request_target) Successful in 4m20s
tests / confined (audit) (pull_request_target) Successful in 4m15s
tests / confined (conversation) (pull_request_target) Successful in 1m3s
tests / confined (launch) (pull_request_target) Successful in 1m58s
tests / confined (project) (pull_request_target) Successful in 3m28s
tests / confined (providers) (pull_request_target) Successful in 1m57s
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (remote) (pull_request_target) Successful in 2m33s
tests / confined (share) (pull_request_target) Successful in 1m53s
tests / confined (shellcheck) (pull_request_target) Successful in 1m11s
tests / runner-env (confined) (pull_request_target) Successful in 1m13s
tests / runner-env (privileged) (pull_request_target) Successful in 45s
The drift guard derived the names the firewall path reads and asserted each
appears as `-e NAME` on the filter container's run line. That catches a script
that starts reading a new variable, and misses the other half of the same
failure: `-e NAME` takes its value from the launcher's environment, so a name
that is assigned and never exported passes an empty one.

The filter container then allowlists less than it was told to, and what an
operator sees is a domain they approved being unreachable, with nothing
pointing here.

Not hypothetical - `export DEJIMA_FORGE_HOSTS` had to be added by hand when
the filter container was introduced, because until then that variable had only
ever needed to reach the sandbox as `-e NAME=value`, which carries its own
value. Nothing would have noticed its absence.

All nine are exported today, so this guards the next one. Continuations are
flattened before matching, and `export` is matched anywhere on a line rather
than at its start, because two of the nine are exported after a `;` on the
line that assigns them - a form that a start-anchored check reports as missing
and would have made this fail on correct code.
2026-09-09 20:25:51 -07:00
branderson b32c8921c2 Fail closed when nothing supplies a namespace to join
`network_mode: ${SANDBOX_NETNS_CONTAINER:-bridge}` fell back to a real network
with no filter on it. Only a `docker compose` invocation by hand reaches that
default - bin/dejima always exports the variable - but what it produced there
was a working sandbox with unfiltered egress, and nothing about it looked
wrong. `none` fails the same case visibly instead.

The static guard is scoped to the key and the value together. Excluding every
`network_mode` line would hide a real forge name written on one, and excluding
the bare word `none` - which is also a shipped forge - would hide it anywhere
in the file. Checked in all three directions.
2026-09-09 20:25:51 -07:00
branderson e151faf4ec Read a network's creation time in whatever shape Docker rendered it
The reconciler reaped filter containers and never a single network, which is
the half the address pool depends on.

`Created` is not the same type on the two structs it asks about: a string on a
container, a time.Time on a network. A Go template prints the second through
String(), which appends a timezone NAME, and date(1) refuses that outright -
so `_epoch_age` returned nonzero, `|| continue` fired, and every network
looked too new to touch. Forever.

That is the same failure the container half had already moved off
`{{.CreatedAt}}` to avoid. Its comment even claimed every caller here read RFC
3339, which was true of one of them - assuming the format is what broke this,
so the fix stops assuming rather than assuming a different one. A trailing
timezone name is stripped, which leaves RFC 3339 untouched and makes this
independent of which struct a caller asked about.

An unparseable value still yields no age rather than a huge one, so the
failure stays "nothing was reaped" and never becomes "everything was".

The stub answered both queries in the container's format, which is why the
test asserting `network rm` passed against code that never issued one. It
answers each in the shape Docker really uses, and reverting the fix now turns
that test red.
2026-09-09 20:25:36 -07:00
branderson 251beaab45 Assert which container each half of the command reaches
tests / confined (conversation) (pull_request_target) Successful in 56s
tests / confined (proxy) (pull_request_target) Successful in 1m4s
tests / confined (providers) (pull_request_target) Successful in 1m54s
tests / confined (launch) (pull_request_target) Successful in 1m57s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (remote) (pull_request_target) Successful in 2m46s
tests / confined (audit) (pull_request_target) Successful in 3m32s
tests / privileged (pull_request_target) Successful in 4m3s
tests / confined (config) (pull_request_target) Successful in 4m44s
tests / confined (share) (pull_request_target) Successful in 1m57s
tests / confined (shellcheck) (pull_request_target) Successful in 1m16s
tests / confined (project) (pull_request_target) Successful in 3m12s
tests / confined (static) (pull_request_target) Successful in 3m2s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
On the calls rather than the messages: which container the ipset add and the
reachability probe run in is the whole point, and a message could name either
while the exec went elsewhere.

Both directions - the filter's id accepted and resolved to its session, and a
container that is neither half refused with somewhere to go instead.

Mutation-checked by removing the filter branch, and by leaving each of the two
execs pointed at the wrong container in turn.
2026-09-09 19:02:57 -07:00
branderson af6f0e4aa7 Take either half of the pair, because a briefing hands over the filter's id
A session asked for its own container id reads /etc/hostname, and Docker names
a container that joined another's network namespace after the container it
joined. So what a session reads there - and what every briefing substitutes
into the `dejima-allow --container` line it tells the agent to hand over - is
the FILTER container's id.

Given that id, dejima-allow found no `container:` in its NetworkMode and
refused, telling the operator to relaunch a session that was working fine.
That is the one command every briefing advertises, failing on the id the
briefing itself supplied.

Either half is accepted now, and the other derived: a session container names
its filter directly, and a filter container is matched against the running
container in its namespace. The allowlist change still goes to the filter and
the reachability probe still runs in the session, because those are facts
about where dnsmasq lives and about whose reachability is being claimed.

A container that is neither gets told what to do instead rather than to
relaunch something that may already be gone.

The renderer's comment claimed /etc/hostname is "the container's own ID",
which is what made this easy to miss. It says what the value is.
2026-09-09 19:02:57 -07:00
branderson 3a6c233a0f Merge pull request 'Reclaim the filter containers an interrupted launch leaves behind' (#331) from reconcile into filter-container
tests / confined (conversation) (pull_request_target) Successful in 1m2s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (shellcheck) (pull_request_target) Successful in 1m15s
tests / confined (providers) (pull_request_target) Successful in 1m51s
tests / confined (share) (pull_request_target) Successful in 1m51s
tests / runner-env (confined) (pull_request_target) Successful in 48s
tests / confined (remote) (pull_request_target) Successful in 2m38s
tests / confined (project) (pull_request_target) Successful in 3m1s
tests / confined (audit) (pull_request_target) Successful in 3m22s
tests / privileged (pull_request_target) Successful in 4m18s
tests / confined (static) (pull_request_target) Successful in 3m8s
tests / confined (config) (pull_request_target) Successful in 4m34s
tests / runner-env (privileged) (pull_request_target) Successful in 42s
Reviewed-on: #331
Reviewed-by: branderson <brad@branderson.io>
2026-09-09 18:40:33 -07:00
branderson 924dd4ed51 Test each branch that decides not to remove something
tests / confined (conversation) (pull_request_target) Successful in 1m2s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (providers) (pull_request_target) Successful in 1m54s
tests / confined (launch) (pull_request_target) Successful in 1m59s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (share) (pull_request_target) Successful in 1m55s
tests / confined (remote) (pull_request_target) Successful in 2m48s
tests / confined (shellcheck) (pull_request_target) Successful in 1m15s
tests / confined (project) (pull_request_target) Successful in 3m12s
tests / confined (audit) (pull_request_target) Successful in 3m34s
tests / confined (static) (pull_request_target) Successful in 2m58s
tests / privileged (pull_request_target) Successful in 4m15s
tests / runner-env (privileged) (pull_request_target) Successful in 42s
tests / confined (config) (pull_request_target) Successful in 4m47s
Every one of these tests would pass against a reconciler that did nothing at
all, so the first asserts the mechanism fires and the rest assert what it
spares: a filter container with a session still in its namespace, and one
younger than the grace. Both are the cases where being wrong costs somebody
else's running agent.

Plus the two properties that are not about which container: the removal is
announced, because a leak that is cleaned up silently is a leak nobody learns
they are producing; and a docker that cannot answer any of it does not refuse
the launch.

Mutation-checked by removing the call, the joiner check, the grace, the
announcement, and the `|| true` in turn - each fails the test that names it.
2026-09-09 18:07:16 -07:00
branderson 271d7843fe Reclaim the filter containers an interrupted launch leaves behind
Step 6 of #305, and the last. A launch removes its own pair from the exit
trap, which covers every exit the trap reaches; a SIGKILL, a restarted host or
a docker daemon restart reach none of them, and each leaves a filter container
holding a network open.

The leak is inert - a filter container filters a namespace nothing is in - so
this is hygiene rather than a second control. What makes it worth doing is the
address pool: Docker carves a bounded number of networks out of its default
pool, and exhausting it fails a launch with an error about address space that
says nothing about the cause.

A candidate is reaped when nothing is in its namespace AND it is older than
the window in which a pair is still being built. That window is real: between
`docker run` for the filter container and `docker compose run` for the
session, a perfectly healthy filter container has no joiner, for as long as
the firewall takes to come up. Reaping one out from under a concurrent launch
costs that launch; waiting costs one more pass.

Ages come from `inspect`'s `.Created` rather than `ps --format {{.CreatedAt}}`,
which prints a trailing timezone NAME that date(1) refuses outright. Measured,
not assumed - and the refusal would have been silent, leaving every candidate
looking newly made and nothing ever reaped. An unparseable timestamp yields no
age rather than a huge one, so that failure stays "nothing was reaped" instead
of becoming "everything was".

Networks need less care, because `docker network rm` refuses one that still
has an endpoint, but they get the same grace: a network is created a moment
before its filter container attaches, and that moment is a launch a concurrent
one could otherwise pull the network out from under.

Never fatal, and it runs before the launch makes anything of its own. A daemon
that cannot answer this is about to fail the launch a few lines down for a
reason worth reporting, and a tidy-up pass should not be what reports it.
2026-09-09 18:07:16 -07:00
branderson 9b6ad238f3 Merge pull request 'Take the network capabilities off the container that runs the session' (#330) from caps-split into filter-container
tests / confined (conversation) (pull_request_target) Successful in 1m3s
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (providers) (pull_request_target) Successful in 1m51s
tests / confined (share) (pull_request_target) Successful in 1m51s
tests / confined (shellcheck) (pull_request_target) Successful in 1m11s
tests / runner-env (confined) (pull_request_target) Successful in 46s
tests / confined (remote) (pull_request_target) Successful in 2m38s
tests / confined (project) (pull_request_target) Successful in 2m59s
tests / confined (audit) (pull_request_target) Successful in 3m23s
tests / confined (static) (pull_request_target) Successful in 2m55s
tests / privileged (pull_request_target) Successful in 4m32s
tests / confined (config) (pull_request_target) Successful in 4m35s
tests / runner-env (privileged) (pull_request_target) Successful in 50s
Reviewed-on: #330
Reviewed-by: branderson <brad@branderson.io>
2026-09-09 17:33:58 -07:00
branderson 19fb87c1f8 Assert the refusal, not just the declaration
tests / confined (conversation) (pull_request_target) Successful in 1m0s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (launch) (pull_request_target) Successful in 1m23s
tests / confined (providers) (pull_request_target) Successful in 1m53s
tests / confined (shellcheck) (pull_request_target) Successful in 1m14s
tests / runner-env (confined) (pull_request_target) Successful in 48s
tests / confined (share) (pull_request_target) Successful in 1m49s
tests / confined (remote) (pull_request_target) Successful in 2m37s
tests / confined (project) (pull_request_target) Successful in 3m1s
tests / confined (audit) (pull_request_target) Successful in 3m19s
tests / confined (static) (pull_request_target) Successful in 2m48s
tests / privileged (pull_request_target) Successful in 4m20s
tests / confined (config) (pull_request_target) Successful in 4m30s
tests / runner-env (privileged) (pull_request_target) Successful in 43s
The claim the whole arrangement rests on needs a real kernel to settle, so
runtime.bats asks it directly: root in the session container running
`iptables -F` and `ipset list` must be refused, with the filter read back
afterwards to show there were rules there to flush - a negative that would
look identical against an empty table.

As root deliberately. `node cannot run iptables directly` beside it asserts
the unprivileged case, and would pass just as well on a container that held
the capability and a user who did not.

The sysctl gets the same treatment: declared on the filter container, read
back from the session container, because the session container is where it
has to be true.

Statically, both floors are asserted in both directions - each of NET_ADMIN,
NET_RAW and KILL absent from the session's list and present in the filter's.
One direction alone is satisfied by a launch that has no filter container to
hold them either.

Two existing guards asserted the old shape and are repointed rather than
loosened: the capability floor no longer requires NET_ADMIN in compose, and
the bind-:443 precondition reads the sysctl where it is set.
2026-09-09 14:49:20 -07:00
branderson 75eb427e62 Take the network capabilities off the container that runs the session
Step 4 of #305, and what the previous three were for. The session container
declares no network capability at all - not NET_ADMIN, not NET_RAW. It sits
inside a namespace the filter container owns and has already installed the
rules in, and capabilities are per process, so uid 0 in there is refused by
the kernel rather than by policy.

KILL goes with dnsmasq. It was only ever there so `dejima-allow --container`
could signal it, and dnsmasq is in the filter container - so bin/dejima
declares it beside the daemon that needs it.

The sysctl goes for the same reason: net.ipv4.ip_unprivileged_port_start is a
property of a network namespace, and the session container does not own the
one it is in. Set once, by the container that does, and it applies to the
session container that joins it - which is where the credential proxy's TLS
terminator binds :443 as the unprivileged sandbox user.

That leaves the session container with what its root phase actually does
before the session starts: write and chown files, drop privilege once, and
spend SETPCAP taking the rest away.
2026-09-09 14:49:20 -07:00
branderson aa3a198e26 Say what the arrangement is, not what it stopped being
tests / confined (conversation) (pull_request_target) Successful in 1m1s
tests / confined (proxy) (pull_request_target) Successful in 1m6s
tests / confined (share) (pull_request_target) Successful in 1m47s
tests / confined (providers) (pull_request_target) Successful in 1m51s
tests / confined (shellcheck) (pull_request_target) Successful in 1m14s
tests / runner-env (confined) (pull_request_target) Successful in 48s
tests / confined (remote) (pull_request_target) Successful in 2m34s
tests / confined (project) (pull_request_target) Successful in 2m58s
tests / confined (audit) (pull_request_target) Successful in 3m18s
tests / confined (static) (pull_request_target) Successful in 2m48s
tests / privileged (pull_request_target) Successful in 4m21s
tests / confined (config) (pull_request_target) Successful in 4m25s
tests / runner-env (privileged) (pull_request_target) Successful in 42s
A pass over the prose this branch added. Documentation is a description of
current state, and several passages had been written as a diff against the
previous one: "it runs in the filter container now rather than", "two things
follow that were previously arguments", "concurrent sessions no longer share
a link layer". A reader arriving today has no previous arrangement to hold
these against.

remote-access.md's sentence needed nothing at all - it was already about which
host address a sandbox can reach, which is unchanged, and the clause about
where the rule lives was history added to a fact.

Two comments in docker-compose.yml were worse than stale phrasing: the
capability floor still described this container as running the firewall and
holding a root log daemon, neither of which is true of it.
2026-09-09 14:14:22 -07:00
branderson 1de83f8a16 Read the push grant's announcement from the container that prints it
tests / confined (conversation) (pull_request_target) Successful in 1m0s
tests / confined (proxy) (pull_request_target) Successful in 1m4s
tests / confined (providers) (pull_request_target) Successful in 1m49s
tests / confined (share) (pull_request_target) Successful in 1m47s
tests / confined (shellcheck) (pull_request_target) Successful in 1m13s
tests / runner-env (confined) (pull_request_target) Successful in 44s
tests / confined (remote) (pull_request_target) Successful in 2m32s
tests / confined (project) (pull_request_target) Successful in 2m54s
tests / confined (audit) (pull_request_target) Successful in 3m17s
tests / confined (static) (pull_request_target) Successful in 3m4s
tests / privileged (pull_request_target) Successful in 4m20s
tests / confined (config) (pull_request_target) Successful in 4m27s
tests / runner-env (privileged) (pull_request_target) Successful in 45s
The one test left failing on the privileged runner. init-firewall.sh prints
"ALLOW_GITHUB_PUSH=1: opening port 22", and init-firewall.sh is what moved -
so the poll was watching the session container's log for a line that is now
written to the filter container's.

The connect check above it stays where it is, for the opposite reason:
reachability is a claim about the session, and it is the half of this test
that would still pass on a grant nothing ever announced.

Checked for the rest of the class rather than fixing the one CI named: of
every string this suite asserts on, exactly one is printed by the firewall
stack, and this was it.
2026-09-09 00:20:02 -07:00
branderson bd96919504 Build the pair in runtime.bats, since a session container alone has no firewall
tests / confined (conversation) (pull_request_target) Successful in 1m25s
tests / confined (proxy) (pull_request_target) Successful in 1m31s
tests / confined (share) (pull_request_target) Successful in 2m8s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / runner-env (confined) (pull_request_target) Successful in 46s
tests / confined (remote) (pull_request_target) Successful in 2m51s
tests / confined (project) (pull_request_target) Successful in 3m14s
tests / confined (audit) (pull_request_target) Successful in 3m34s
tests / confined (static) (pull_request_target) Successful in 2m40s
tests / confined (config) (pull_request_target) Successful in 4m43s
tests / privileged (pull_request_target) Failing after 9m13s
tests / runner-env (privileged) (pull_request_target) Successful in 45s
The privileged job failed on this branch: fifteen of fifty-one, every one a
firewall, DNS or logging test. The cause is the change itself and I should
have done this with it. entrypoint.sh stopped bringing the egress control up,
because the container beside it does - and every fixture here starts one
container.

start_pair builds what bin/dejima builds: a filter container that owns the
network namespace, waited for on the same readiness marker the launcher waits
for, and a session container that joins it. Twenty-two fixtures go through it.
Both capability floors come from the code that declares each - the session's
from docker-compose.yml as before, the filter's from bin/dejima's own run -
so neither is a copy that can drift.

The environment goes to both halves, which is deliberately not what bin/dejima
does. A fixture that varies ALLOW_GITHUB_PUSH or a forge host is varying what
the FIREWALL does, and making twenty-two call sites each declare which half
their variables belong to would be twenty-two chances to send one to the wrong
container - a mistake that fails by passing, since a firewall told nothing
simply allowlists less.

Six tests reach into the container the egress control is in rather than the
one the session is in: ip6tables, the ipsets, the dnsmasq signal, ulogd2's
user, and the log shipper. dexec_filter is that, and the three per-test
fixtures that inspect dnsmasq config get a filter name beside their container
name.

Two fixtures stay single deliberately and say so: one must refuse to start
without SETPCAP, the other must abort at the transport check, which is well
before anything needs a network.

Teardown had to learn about both. The per-test sweep is anchored on the run
suffix, so filter names end with it - which makes the file-wide filter match
too, and removing that after the first test would take the network namespace
out from under every later one.
2026-09-08 23:35:23 -07:00
branderson a721e3d1fb Snapshot the registry on the session's own run, not on every docker call
tests / confined (conversation) (pull_request_target) Successful in 1m0s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (providers) (pull_request_target) Successful in 1m51s
tests / privileged (pull_request_target) Failing after 2m37s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / confined (share) (pull_request_target) Successful in 1m49s
tests / confined (project) (pull_request_target) Successful in 3m19s
tests / confined (audit) (pull_request_target) Successful in 4m9s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (remote) (pull_request_target) Successful in 2m27s
tests / confined (static) (pull_request_target) Successful in 2m44s
tests / confined (config) (pull_request_target) Successful in 5m43s
A launch makes several docker calls now - a build, this launch's filter
container, and two removals from the exit trap. This stub snapshotted on each
and cleared the previous one first, so the last call won, and the two removals
run after the registry entry is gone. What the test then read was an empty
directory.

Matched on the compose run, which is the call the entry is meant to exist
during, and the one run_line already identifies in this file.
2026-09-04 15:52:36 -07:00
branderson d7e4c6be0a Describe the pair, since the boundary is no longer one container
tests / confined (conversation) (pull_request_target) Successful in 53s
tests / confined (proxy) (pull_request_target) Successful in 1m1s
tests / confined (share) (pull_request_target) Successful in 1m45s
tests / confined (providers) (pull_request_target) Successful in 1m47s
tests / confined (shellcheck) (pull_request_target) Successful in 1m9s
tests / runner-env (confined) (pull_request_target) Successful in 46s
tests / confined (remote) (pull_request_target) Failing after 2m35s
tests / privileged (pull_request_target) Failing after 2m42s
tests / confined (project) (pull_request_target) Successful in 3m9s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (audit) (pull_request_target) Successful in 4m4s
tests / confined (static) (pull_request_target) Successful in 3m50s
tests / confined (config) (pull_request_target) Successful in 6m59s
security.md gains the arrangement and what follows from it: the two claims
that were previously arguments - no window between an interface and a filter,
and a killed filter container leaving loopback alone rather than open egress -
are now properties of how the pair is built.

development.md's file table names the three new scripts and stops saying the
entrypoint brings the firewall up. remote-access.md's bridge-gateway rule is
unchanged in what it permits and changed in who can alter it, which is worth
one clause rather than a rewrite.
2026-09-04 15:52:17 -07:00
branderson 2c31d83a49 Test how a launch is arranged, and make CI run the suite that does
test/launch.bats: the containers a launch starts, in what order, on what
network, and what it takes down. Against a stubbed docker, so the ordering and
the flags are the subject - whether the filter filters stays runtime.bats's
question and needs a real one.

The load-bearing one is the environment test. The sandbox gets its environment
from docker-compose.yml; the filter container is started with `docker run` and
gets nothing not named on that command line. So a firewall script that starts
reading a new variable does not fail - it reads an empty one and allowlists
less than it was told to, which presents as an approved domain being
unreachable rather than as anything about this. Derived from the scripts.

Adding a suite turned up that the CI matrix is enumerated by hand while
run-confined.sh discovers suites from the directory, so a suite added to the
tree runs locally and never runs in CI: a green set of checks covering less
than it appears to, with nothing watching for it. Asserted now in both
directions, reading run-confined.sh's own privileged list rather than
restating which suites are confined.

The step 2 guard flips: the session's entrypoint must NOT bring the firewall
up, and the filter container's must, with the readiness marker after it. Three
stub dockers in project.bats needed teaching that a session shares a filter
container's namespace, and one new test covers the refusal when it does not.
2026-09-04 15:52:17 -07:00
branderson 077cd241a7 Point dejima-allow at the container the allowlist is now in
The dnsmasq and the ipsets moved with the filter, so every mutation goes
there. The reachability probe stays in the session's container, because that
is the container whose reachability this command claims to have changed - one
run in the filter container would pass on an arrangement where the session
still could not reach anything.

Derived from HostConfig.NetworkMode rather than recorded: Docker already knows
which container holds the namespace, because joining one is how the pair is
built. A session that shares no namespace is told to relaunch, rather than
finding out through a docker exec that fails somewhere inside a heredoc.
2026-09-04 15:52:04 -07:00
branderson 4e47c30457 Run the egress filter in a container the session cannot reach
Step 3 of #305, and the one that flips the arrangement. A launch starts two
containers now: a filter container that owns the network namespace and
installs the packet filter, and the sandbox, which joins that namespace rather
than getting one of its own.

Capabilities are per process, not per namespace, so a container that declares
no network capability cannot alter a filter installed in a namespace it is
inside of. Measured on the host rather than reasoned: iptables and ipset both
answer a joiner without NET_ADMIN with Operation not permitted.

The ordering is what this buys today. The namespace has its rules before
anything is created in it, so the window where a session holds an interface
and no filter does not exist rather than being bounded by how fast an
entrypoint runs. filter-entrypoint.sh writes a readiness marker only on the
success path; bin/dejima waits for it and refuses the launch with the filter
container's own output when it never comes.

The network is per launch because it has to be: dns-proxy.sh forwards to
Docker's embedded resolver, which exists only on a user-defined network.
Concurrent sessions no longer sharing a link layer comes with that.

The filter container runs the base image, never a project's derived one. It is
the privileged half of the pair and has no use for a project's dependencies.

Two corrections to the session container. It no longer brings the firewall up,
because the container beside it did and this one declares nothing it would
take. And it points its own /etc/resolv.conf at the namespace's resolver:
that file is per container even when the namespace is shared, so dns-proxy.sh
repointing the filter container's says nothing about this one - and left
alone, every lookup would go around dnsmasq while the packet filter still
held, so the DNS allowlist would apply to nothing and nothing would fail.

The sandbox still declares NET_ADMIN. Narrowing both floors is step 4.
2026-09-04 15:52:04 -07:00
branderson 5b7250f8f0 Merge pull request 'Extract the egress control into a script of its own' (#327) from firewall-up into development
Reviewed-on: #327
Reviewed-by: branderson <brad@branderson.io>
2026-09-04 14:22:36 -07:00
branderson f1f2fe4b02 Point the drift guards at the files, and cover the fragments
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 1m3s
tests / confined (share) (pull_request_target) Successful in 1m41s
tests / confined (providers) (pull_request_target) Successful in 1m43s
tests / confined (shellcheck) (pull_request_target) Successful in 1m9s
tests / runner-env (confined) (pull_request_target) Successful in 44s
tests / confined (remote) (pull_request_target) Successful in 2m29s
tests / confined (project) (pull_request_target) Successful in 2m50s
tests / confined (audit) (pull_request_target) Successful in 3m11s
tests / confined (static) (pull_request_target) Successful in 2m44s
tests / privileged (pull_request_target) Successful in 3m54s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (config) (pull_request_target) Successful in 4m39s
Three static tests read entrypoint.sh for things that now live in
firewall-up.sh and caps-lib.sh. They caught the move, which is what they are
for; repointed rather than loosened.

Two new ones. The entrypoint must still call firewall-up.sh, unguarded - an
extraction that left nothing calling it is a sandbox with no egress control,
and every other check here would keep passing because they all read the files
it moved to. `|| true`, an `if` and a background `&` each fail it too, since
each turns a firewall that failed to come up into a session that runs anyway.

And shellcheck now covers sourced fragments under docker/. Discovery is by
shebang, so caps-lib.sh would have been dropped silently - the failure that
file's own comment calls the worst version of. fixed-domains.sh was already
uncovered for the same reason. Matched by directory rather than by name,
because a list is a thing to forget to add to.
2026-09-04 13:51:01 -07:00
branderson a4f4d1428f Extract the egress control into a script of its own
Step 2 of #305's filter container, and the plan's version of this step was
wrong: it said to move the firewall block out with the container still unused,
"nothing changes behaviour yet". Moving it out with nothing running the filter
container is a sandbox with no firewall. Extracted rather than moved, so the
arrangement is unchanged and one caller becomes two later.

firewall-up.sh is the DNS proxy, the packet filter and the two log producers.
Its own script because what it does is a property of a network namespace, and
everything else an entrypoint does is a property of a container - and the two
need not belong to the same one. No executable line changed in the move,
confirmed by diffing the block against its original.

caps-lib.sh carries drop_caps and the SETPCAP precondition, now that the
entrypoint is not the only caller. Sourced rather than copied: what drop_caps
does is the difference between a session holding nothing and a session holding
the whole floor, which is not a thing to have two of.

log-shipper.sh reparents to the container's init when firewall-up.sh exits.
Started from the entrypoint it was a child of the very session process it
exists to keep an eye on, which was never the intent.
2026-09-04 13:51:01 -07:00
branderson a301bae1c4 Merge pull request 'Bind everything the exit trap removes before the trap can fire' (#326) from run-id into development
Reviewed-on: #326
Reviewed-by: branderson <brad@branderson.io>
2026-09-04 13:24:25 -07:00
branderson 849ec326a2 Merge pull request 'Empty the capability bounding set for everything that outlives the root phase' (#324) from caps-floor into development
Reviewed-on: #324
Reviewed-by: branderson <brad@branderson.io>
2026-09-04 13:24:01 -07:00
branderson a0b45bdacc Launch with both names inherited, and check the canary survives
tests / confined (conversation) (pull_request_target) Successful in 54s
tests / confined (proxy) (pull_request_target) Successful in 1m4s
tests / confined (providers) (pull_request_target) Successful in 1m47s
tests / confined (share) (pull_request_target) Successful in 1m45s
tests / confined (shellcheck) (pull_request_target) Successful in 1m7s
tests / runner-env (confined) (pull_request_target) Successful in 45s
tests / confined (remote) (pull_request_target) Successful in 2m31s
tests / confined (project) (pull_request_target) Successful in 2m51s
tests / confined (audit) (pull_request_target) Successful in 3m10s
tests / confined (static) (pull_request_target) Successful in 2m41s
tests / privileged (pull_request_target) Successful in 3m36s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (config) (pull_request_target) Successful in 4m18s
The static tests assert where the assignments are. This asserts what that
buys: with remote access off and DEJIMA_STATE_DIR and DEJIMA_RUN_ID both set
in the environment, a launch must not remove the files that join names.

Two canaries rather than one, because the removal needs both halves to come
from the environment - either name being dejima's own is enough to miss the
path, and a single file would pass on a fix that only covered one of them.
2026-09-04 01:27:52 -07:00
branderson 77e65513d9 Check the invariant bin/dejima states about its own exit trap
Two tests, and neither is the one already there. That one asks whether a name
cleanup() reads is BOUND, which is about set -u aborting the trap, and it
accepts `${NAME:-}` as the answer. Guarding against unbound does nothing about
a name arriving from the caller's environment already carrying a value, which
the trap then removes - and that is the invariant bin/dejima's own comment
states, so three names had drifted out of it with nothing to say so.

The first test asserts it for every argument to an rm in cleanup().

The second exists because the first has an exception. SANDBOX_GITCONFIG_DIR is
assigned below the trap and is safe there, but on a claim about its window
rather than about the name: no exit sits between the two lines. An edit that
puts one there turns the exception into the bug it is exempted from, and
nothing about that edit would look like it touched this.
2026-09-04 01:27:52 -07:00
branderson a41aebb09f Bind everything the exit trap removes before the trap can fire
Step 1 of #305's filter container, and a defect the step turned up.

The run id is generated for every launch now, not only a remote-enabled one.
It is the launch's identity and it was derived from whether one optional
feature is configured; the filter container needs it to label a network and a
container that exist either way.

That matters on its own, because cleanup() joins it to DEJIMA_STATE_DIR and
removes the result. With remote access off nothing assigned either name, so
both came from the caller's environment if it had them and the EXIT trap
removed a path of the environment's choosing.

bin/dejima already has a block for exactly this hazard, above `trap cleanup
EXIT`, and states the rule: every name cleanup() removes but does not itself
create belongs in it. Three more had drifted out of it - SANDBOX_BRIEFING_FILE,
SANDBOX_CREDENTIAL_PROXY_DIR and SANDBOX_FAKE_CREDENTIALS_FILE are first
assigned below the trap with ten, twelve and twelve ordinary `exit` paths in
between. The second is an `rm -rf` argument. Bound above the trap as well now,
rather than moved: their later sites read as what each feature starts from and
are correct as that.

SANDBOX_GITCONFIG_DIR stays the one exception and its comment stops claiming
uniqueness it had lost. What makes it safe is its window holding no exit, which
is a property worth asserting rather than re-deriving.
2026-09-04 01:27:41 -07:00
branderson b872fabdf6 Merge pull request 'Set the update notice apart from the launch output around it' (#323) from update-notice-visibility into development
Reviewed-on: #323
Reviewed-by: branderson <brad@branderson.io>
2026-09-04 01:17:02 -07:00
branderson 1e788548a0 Separate the two claims the capability floor was making at once
tests / confined (conversation) (pull_request_target) Successful in 57s
tests / confined (proxy) (pull_request_target) Successful in 1m1s
tests / confined (share) (pull_request_target) Successful in 1m45s
tests / confined (providers) (pull_request_target) Successful in 1m47s
tests / confined (shellcheck) (pull_request_target) Successful in 1m10s
tests / runner-env (confined) (pull_request_target) Successful in 44s
tests / confined (remote) (pull_request_target) Successful in 2m27s
tests / confined (project) (pull_request_target) Successful in 2m49s
tests / confined (audit) (pull_request_target) Successful in 3m7s
tests / confined (static) (pull_request_target) Successful in 2m40s
tests / confined (config) (pull_request_target) Successful in 4m15s
tests / privileged (pull_request_target) Successful in 10m42s
tests / runner-env (privileged) (pull_request_target) Successful in 42s
"No path back to root" and "no capability if there were one" were one sentence
resting on the other. They are independent now, so say so, and say what the
floor actually describes: the container's root phase, which under `init: true`
PID 1 goes on holding for the life of the session. Narrowing that means
narrowing the floor itself, which is #305.
2026-09-04 00:50:56 -07:00
branderson 3734a614bd Assert the capability posture against the process that has it
Three changes, and the first is a fidelity bug the other two rest on.

`the real running node process has NET_ADMIN/NET_RAW stripped` read
/proc/1/status. PID 1 is the session here only because these fixtures use a
bare `docker run`; docker-compose.yml sets `init: true`, so in a real launch
PID 1 is the init that reaps for the session, running as root with the whole
floor effective. The assertion was about one process in the suite and a
different one in production. It now finds the session by what it is, and the
floor is read from a fresh `docker exec`, which is where the container's
declared set is observable rather than whatever the entrypoint left behind.

Then the bounding set, which is the claim with teeth: an empty effective set
is what changing uid gives you anyway.

And the two states that were untested. The container must refuse to start
without SETPCAP, because the alternative is a session running with the full
set and nothing anywhere saying so. The log shipper must still be root, and
must hold nothing while it is - which also exercises that it can read both
sources on ordinary permissions, since it says so itself when it cannot.

Its own container for that one: the shipper exits immediately without
LOKI_URL, so the file-wide fixture never started one and nothing in the suite
had ever observed it running.
2026-09-04 00:50:56 -07:00
branderson 55c33a8cc5 Say when the shipper cannot read a source, instead of shipping nothing
`tail -F` discards its stderr because it is meant to wait for a file that does
not exist yet and complains once a second while it does. That also swallows a
permission error, which is not transient and which presents identically to a
quiet session: a stream carrying no lines.

That mattered less while the shipper held DAC_OVERRIDE. It now reads both logs
on their ordinary permissions and nothing else, so the case is reachable and
the symptom is an oversight log that silently stops.
2026-09-04 00:50:56 -07:00
branderson 95b90761dd Empty the bounding set for everything that outlives the root phase
The session ran as an unprivileged user with an empty effective set, inside
a tree whose bounding set still held all seven capabilities the startup
sequence needed. A bounding set survives execve and a uid-0 process recovers
its permitted set from it, so "the session holds nothing" was a statement
about its effective set and about nothing else.

Measured in a live container rather than inferred: the session process, PID 1
and every long-lived daemon each carry the full floor in CapBnd. Only their
effective sets differ.

The session and log-shipper.sh - the one root process that outlives the
entrypoint - now start through capsh with an empty one. That costs SETPCAP in
the floor, which is worth stating plainly: a capability added so the rest can
be taken away. It can only reduce, since Linux 4.3 grants nothing a process
does not already hold, and it goes with everything else before the session
starts.

capsh rather than `setpriv --bounding-set`, which is the obvious tool and
silently the wrong one. Without CAP_SETPCAP setpriv exits 0 having changed
nothing, so a floor that lost the capability would run every session with the
full set and report success at every step; capsh refuses out loud. Confirmed
both ways against a real process. The entrypoint checks for the capability up
front as well, so the reason names the file the floor lives in rather than
arriving as an abort partway through startup.
2026-09-04 00:50:40 -07:00
branderson a438bf368b Declare libcap2-bin, rather than inheriting capsh from iproute2
capsh is about to become load-bearing: entrypoint.sh uses it to hand the
session a container it holds no capabilities in, and refuses to start
without it. It was present only as a dependency of iproute2, which is the
kind of thing an unrelated package change takes away.
2026-09-04 00:50:30 -07:00
branderson 2a20ae62cc Assert the notice names install.sh
tests / confined (conversation) (pull_request_target) Successful in 58s
tests / confined (proxy) (pull_request_target) Successful in 1m4s
tests / confined (providers) (pull_request_target) Successful in 1m51s
tests / confined (share) (pull_request_target) Successful in 1m48s
tests / confined (shellcheck) (pull_request_target) Successful in 1m13s
tests / runner-env (confined) (pull_request_target) Successful in 46s
tests / confined (remote) (pull_request_target) Successful in 2m32s
tests / confined (project) (pull_request_target) Successful in 2m59s
tests / confined (audit) (pull_request_target) Successful in 3m19s
tests / privileged (pull_request_target) Successful in 3m34s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (static) (pull_request_target) Successful in 3m19s
tests / confined (config) (pull_request_target) Successful in 4m25s
The fixture's own path, not ./install.sh, because the whole point of the
instruction is that it works from wherever the operator is standing.
2026-09-04 00:13:49 -07:00
branderson 2efb93c644 Point the update notice at install.sh, by absolute path
Pulling is only half an update: install.sh is what links bin/ onto PATH,
and a release can add or rename tools, so a checkout updated by pull
alone keeps the old links. The notice now says to re-run it after the
pull, joined with && so the whole line pastes as one command.

Both paths absolute, as the pull already was: the operator reading this
is not necessarily standing in the checkout it names.
2026-09-04 00:13:49 -07:00
branderson cdad2fb1e9 Assert the update notice keeps a blank line either side
tests / confined (conversation) (pull_request_target) Successful in 1m0s
tests / confined (proxy) (pull_request_target) Successful in 1m6s
tests / confined (providers) (pull_request_target) Successful in 1m49s
tests / confined (share) (pull_request_target) Successful in 1m47s
tests / confined (shellcheck) (pull_request_target) Successful in 1m10s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (remote) (pull_request_target) Successful in 2m28s
tests / confined (project) (pull_request_target) Successful in 2m53s
tests / confined (audit) (pull_request_target) Successful in 3m14s
tests / confined (static) (pull_request_target) Successful in 2m41s
tests / privileged (pull_request_target) Successful in 3m37s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (config) (pull_request_target) Successful in 4m21s
The whitespace is the fix, so it is pinned: a reformat that drops the
blank lines restores the bug exactly. Read from the file the check wrote
rather than from bats' $output, because run strips the blank lines at
either end of what it captures - they are invisible to a run-based
assertion in both directions.
2026-09-03 21:59:37 -07:00
branderson b1d7975aee Set the update notice apart from the launch output around it
The notice printed at the margin, in the body colour, between the
launch's own startup lines and the image build - which starts scrolling
immediately after - so its two lines read as part of whichever stream the
operator was already watching. Everything it has to say is "the dejima
you are about to wait through a build for is not current", which is
worth noticing before the build rather than after it.

A blank line either side, the two-space indent the other startup
warnings carry, and C_WARN for the notice line: the same treatment the
"not registered for" guards warning gets. The blank lines come from the
check itself rather than from bin/dejima because the check is silent
when the checkout is current, and the separation must not appear
without the notice.
2026-09-03 21:59:35 -07:00
branderson f2f40caa6b Merge pull request 'Say what a green check means, since that is the state agents actually see' (#322) from ci-green into development
Reviewed-on: #322
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 18:43:13 -07:00
branderson 6f4e176400 Say what a green check means, since that is the state agents actually see
tests / confined (conversation) (pull_request_target) Successful in 1m0s
tests / confined (proxy) (pull_request_target) Successful in 1m6s
tests / confined (providers) (pull_request_target) Successful in 1m47s
tests / confined (share) (pull_request_target) Successful in 1m46s
tests / confined (shellcheck) (pull_request_target) Successful in 1m10s
tests / runner-env (confined) (pull_request_target) Successful in 44s
tests / confined (remote) (pull_request_target) Successful in 2m29s
tests / confined (project) (pull_request_target) Successful in 2m56s
tests / confined (audit) (pull_request_target) Successful in 3m17s
tests / privileged (pull_request_target) Successful in 3m37s
tests / confined (static) (pull_request_target) Successful in 2m59s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (config) (pull_request_target) Successful in 4m27s
.dejima.md warned not to read "CI covers it" as "it passed", which is true
of a queued job and not of a green one. The forge does not show a check
green until every job has finished and passed, and runtime.bats is one of
those jobs - so there is no state where the checks are green and the
privileged half was skipped.

Read as written, the warning taught every agent working here to hedge a
result that is not in doubt, on every pull request. That is the error that
actually happens: the operator sees the hedge, knows the run was green, and
disregards the sentence - which costs the warning its force for the case it
was written for.

So it says what each colour means and what to report for it. Green is
verified end to end and should be reported that way. Yellow or grey is a run
that has not happened, and reporting one as passing is what the paragraph is
for. Both halves, because leaving either out is how this goes wrong in one
direction or the other.

docs/release.md and test/release-pass.sh carried the same framing and now
carry the same rule. The test that pinned the script's warning asserts both
halves, against the script flattened to one line so that rewrapping a
message is not a failure about what it says.
2026-09-03 18:20:15 -07:00
branderson bc5e5dc05b Merge pull request 'Compare the release against the base that is actually released' (#320) from release-pass-base into development
Reviewed-on: #320
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 18:07:38 -07:00
branderson 3a26bd2618 Compare the release against the base that is actually released
tests / confined (share) (pull_request_target) Successful in 1m39s
tests / confined (remote) (pull_request_target) Successful in 4m49s
tests / confined (config) (pull_request_target) Successful in 18m22s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (conversation) (pull_request_target) Successful in 1m32s
tests / confined (providers) (pull_request_target) Successful in 2m24s
tests / confined (proxy) (pull_request_target) Successful in 1m21s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / confined (project) (pull_request_target) Successful in 5m54s
tests / privileged (pull_request_target) Successful in 3m53s
tests / runner-env (privileged) (pull_request_target) Successful in 42s
tests / confined (audit) (pull_request_target) Successful in 8m27s
tests / confined (static) (pull_request_target) Successful in 7m46s
test/release-pass.sh took `main` - the local branch - as its default base.
Everything it prints is derived from base..head: which manual checks apply
comes from the paths the diff touches, and the rollback list comes from the
config keys it adds. A base behind the real one therefore reports on a
different release, and reports it confidently.

Not hypothetical. Two passes ran here against a local main 260 commits
behind origin/main, and both named config keys that had shipped releases
earlier and a trust-anchor domain this release does not add. One of those
reports was written into a handoff and read by the next agent as fact.

The default is the tracking ref now, and a base named explicitly is refused
when it is behind its own upstream - `main` is the ordinary thing to type
and is exactly how both stale runs happened, so catching only the default
would catch neither.

It exits rather than warning. A gate that says something is wrong and then
prints its report anyway is one whose output still ends up in a release PR.
2026-09-03 17:42:25 -07:00
branderson 483e44978d Merge pull request 'Ask git where the push is going, not where the fetch comes from' (#319) from secreview into development
Reviewed-on: #319
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 16:02:41 -07:00
29 changed files with 2557 additions and 424 deletions
+17 -7
View File
@@ -100,13 +100,23 @@ locally is not a reason to stop, to hedge, or to leave a change unfinished. Run
the confined suites, open the PR, and say plainly in the PR body that the
privileged half was not run locally.
**But do not read "CI covers it" as "it passed."** That job is pinned to
`runs-on: dejima-privileged`, a label only the one configured self-hosted runner
carries — it needs the host network namespace, the Docker socket, and a shared
volume, none of which a workflow can request. If that runner is offline the job
**queues instead of failing**, and the PR sits with the runtime half neither
green nor red. Check the PR's actual checks before treating it as verified, and
if the job is still queued, say so rather than implying it ran.
**Green means it ran.** The forge does not show a check green until every job
in it has finished and passed, and `runtime.bats` is one of those jobs — so a
green PR is one where the privileged half executed. There is no state in which
the checks are green and that suite was skipped.
What a queued run looks like is yellow or grey, not green. That job is pinned
to `runs-on: dejima-privileged`, a label only the one configured self-hosted
runner carries — it needs the host network namespace, the Docker socket and a
shared volume, none of which a workflow can request — so if that runner is
offline the job **queues instead of failing**, and the PR sits unfinished
rather than passing.
So: read the colour, and say what it says. Green is verified end to end and
should be reported that way. Yellow or grey is a run that has not happened yet,
and reporting it as passing is the thing to avoid. Hedging a green one is the
opposite error and is the more common one here — it reads as doubt about a
result that is not in doubt.
If a change touches what gets mounted, whether the firewall runs, or which
credentials cross the container boundary, that runtime run is the only thing
+22
View File
@@ -80,6 +80,7 @@ jobs:
matrix:
suite:
- static
- launch
- project
- remote
- audit
@@ -327,9 +328,30 @@ jobs:
echo "resolves for this architecture." >&2
exit 1
}
# compose, from the same place and for a reason of the same kind: it
# is a plugin, so without it `docker compose` is not a command at
# all. Every launch on a real host goes through one, and nothing in
# CI did - so the compose file was the one input to a launch that no
# job had ever parsed.
#
# Its assets are named for `uname -m`, like the CLI tarball above and
# unlike buildx, which uses the Debian architecture name.
curl -fsSL -o /usr/local/lib/docker/cli-plugins/docker-compose \
"https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-$(uname -m)"
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
# Checked for the same reason buildx is: an error page chmods just as
# well as a binary, and the symptom would be a test that skips rather
# than one that fails.
docker compose version || {
echo "The compose plugin did not load, so the compose-file test will skip" >&2
echo "instead of running. Check the download URL above still resolves for" >&2
echo "this architecture." >&2
exit 1
}
env:
DOCKER_CLI_VERSION: 27.5.1
BUILDX_VERSION: 0.36.0
COMPOSE_VERSION: 5.5.1
# Fails here, with the fix in the message, rather than thirty tests deep
# in a suite that cannot work. Both of these are runner configuration,
+417 -6
View File
@@ -645,6 +645,10 @@ unset _tools_wanted _tool
AUDIT_DIR="$(config_get audit dir)"
BASELINE_DIR="$(config_get audit baselines_dir)"
# Read before either guard runs, because what this list declares becomes
# something the other two lists are refused for naming - see PP_PROJECT_ROOTS.
SANDBOX_RW_PP_MOUNTS="$(config_get mounts read_write_per_project)"
SANDBOX_EXTRA_MOUNTS="$(config_get mounts readonly)"
SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
${EXTRA_RO_FLAGS[@]+"${EXTRA_RO_FLAGS[@]}"})"
@@ -745,6 +749,30 @@ _harness_host_config
# arrives at ~/shared without going through this list; naming the root that
# holds every project's would put the pile back, which is the thing the split
# removed.
# Refuse $2 if it reaches a per-project root. Its own check rather than a row
# in GUARDED_STORES, because the generic refusal there is about approval and
# audit stores and says so - an operator who wrote the same path into two
# mount lists would be told their specs directory lets a session approve its
# own domains, which is both untrue and no help.
_refuse_pp_root() {
local label="$1" path="$2" real root root_real
_pp_project_roots
[ ${#PP_PROJECT_ROOTS[@]} -gt 0 ] || return 0
real="$(readlink -m "$path")"
for root in "${PP_PROJECT_ROOTS[@]}"; do
root_real="$(readlink -m "$root")"
_paths_overlap "$real" "$root_real" || continue
echo "Refusing $label: $path" >&2
echo "It reaches $root_real, which holds one directory per project for a" >&2
echo "[mounts] read_write_per_project entry." >&2
echo "" >&2
echo "Handing that over is handing over every project's, which is the pile" >&2
echo "slicing it removed. If this path is meant to be shared by every" >&2
echo "project, it belongs in one list or the other and not in both." >&2
exit 1
done
}
GUARDED_STORES=()
_guarded_stores() {
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
@@ -753,6 +781,28 @@ _guarded_stores() {
"$(share_projects_root "$(config_get core share_dir)")")
}
# The `projects` subdirectory of every read_write_per_project root: the
# directory that holds one slice per project, exactly as share_dir's does.
#
# Guarded separately from the stores above because the two lists need
# different answers. For `readonly` and `read_write`, naming one of these is
# the mistake the share guard already names: handing over the root that holds
# every project's directory puts back the pile the split removed. For the
# per-project list itself it is not a mistake at all - it is where that list
# puts things - so that loop consults GUARDED_STORES alone.
PP_PROJECT_ROOTS=()
_pp_project_roots() {
[ ${#PP_PROJECT_ROOTS[@]} -eq 0 ] || return 0
local path
[ -n "$SANDBOX_RW_PP_MOUNTS" ] || return 0
IFS=':' read -ra _pp_declared <<< "$SANDBOX_RW_PP_MOUNTS"
for path in "${_pp_declared[@]}"; do
[ -n "$path" ] || continue
PP_PROJECT_ROOTS+=("$(projects_root "$path")")
done
unset _pp_declared
}
# Refuse $2 if it overlaps any of the paths in $3.., in either direction: an
# ancestor of a store exposes it just as surely as the store itself.
_refuse_guarded_mount() {
@@ -802,6 +852,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
# epitaph store be mountable from here after it was added to one of
# them. A store added to _guarded_stores is now guarded on both
# paths without anyone remembering to do it twice.
_refuse_pp_root "$(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path")" "$path"
_guarded_stores
for guarded in ${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"} \
${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"} \
@@ -837,6 +888,78 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
done
fi
# [mounts] read_write_per_project: the same idea as read_write, sliced the way
# [core] share_dir is. An operator declares ~/specs; the container gets ~/specs,
# backed by ~/specs/projects/<slug> on the host rather than by the whole thing.
#
# Which keeps the property that separates these lists from share_dir - a mount
# arrives at the host path it names, so a project instruction saying "put specs
# in ~/specs" goes on working - while removing the one share_dir was split to
# remove: every session seeing every other project's, across projects. A spec
# is written to be read as background, which makes that threat-model item 4
# with nobody attacking anything.
#
# Its own key rather than a marker on read_write, and that is what makes it
# small: a key that did not previously parse has nothing in it on the day it
# lands. Nothing moves, nothing is stranded, and there is no migration. Some
# writable mounts are meant to be shared - a cache, a bin directory - and
# read_write stays right for those.
RW_PP_MOUNT_SOURCES=()
RW_PP_MOUNT_TARGETS=()
DEJIMA_RW_PP_MOUNTS=""
# Every host path this launch makes writable, which for a plain read_write
# entry is the path itself and for a per-project one is the slice. The audit
# reads this; the briefing reads the two target lists.
DEJIMA_RW_SOURCES="$DEJIMA_RW_MOUNTS"
if [ -n "$SANDBOX_RW_PP_MOUNTS" ]; then
IFS=':' read -ra rw_pp_paths <<< "$SANDBOX_RW_PP_MOUNTS"
for path in "${rw_pp_paths[@]}"; do
[ -n "$path" ] || continue
# The declared root must exist, like a read_write entry: dejima creates
# the slice below it, never the root itself, so a typo stays a typo
# rather than becoming a new empty directory nobody meant.
if [ ! -d "$path" ]; then
echo "[mounts] read_write_per_project entry is not an existing directory: $path" >&2
echo "dejima creates the per-project directory inside it, but not the" >&2
echo "directory itself - so a path that is not there is a typo, not a" >&2
echo "layout to be built." >&2
exit 1
fi
pp_real="$(readlink -m "$path")"
# GUARDED_STORES alone, deliberately: PP_PROJECT_ROOTS holds this
# list's own `projects` directories, which the other two lists are
# refused for naming and this one exists to write into.
_guarded_stores
for guarded in ${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"} \
${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"} \
"$HOME/.local/bin" "$REPO_ROOT" "$WORKSPACE_DIR"; do
guarded_real="$(readlink -m "$guarded")"
_paths_overlap "$pp_real" "$guarded_real" || continue
echo "Refusing [mounts] read_write_per_project entry: $path" >&2
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
echo "Slicing it per project does not change what the session could reach" >&2
echo "through it - only how much of it there is." >&2
exit 1
done
# Resolved here and created later, on the launch path. This block runs
# before --print-briefing so the briefing it composes names the same
# mounts a launch would, and a diagnostic that prints something should
# not leave a directory behind - which is why share_dir's own mkdir
# sits after that exit too.
pp_source="$(project_dir_for "$path" "$WORKSPACE_DIR")"
RW_PP_MOUNT_TARGETS+=("$path")
RW_PP_MOUNT_SOURCES+=("$pp_source")
# Two lists, because the two readers want different answers and for a
# per-project mount they differ. The briefing tells the SESSION what it
# can write, which is the path inside the container. The exit audit
# tells the OPERATOR what on their host this session could reach, and
# naming the root there would claim a blast radius this feature exists
# to have narrowed.
DEJIMA_RW_PP_MOUNTS="${DEJIMA_RW_PP_MOUNTS:+$DEJIMA_RW_PP_MOUNTS }$path"
DEJIMA_RW_SOURCES="${DEJIMA_RW_SOURCES:+$DEJIMA_RW_SOURCES }$pp_source"
done
fi
# --print-briefing composes and exits, the same way --print-config does -
# before anything is built or mounted, and before a workspace argument is
# required. It exists so the composed briefing can be inspected (and, per
@@ -884,6 +1007,12 @@ if [ "$(manifest_get forge "$DEJIMA_FORGE_NAME" hosts_allowlisted_by_core)" = "o
else
DEJIMA_FORGE_HOSTS="$_forge_all_hosts"
fi
# Exported as well as passed to the sandbox with `-e NAME=value`. The filter
# container is started with `docker run` and takes this the `-e NAME` way, so
# it has to be in this process's environment - and that form is what keeps a
# value out of a command line, which is the rule the credential beside it in
# that list follows.
export DEJIMA_FORGE_HOSTS
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
DEJIMA_PUSH_HOSTS="$_forge_all_hosts"
else
@@ -1222,6 +1351,30 @@ SANDBOX_AUDIT_STATE="$(mktemp "${TMPDIR:-/tmp}/dejima-audit.XXXXXX")"
# and SANDBOX_DERIVED_CONTEXT is one of the `rm -rf` arguments.
DEJIMA_NOTIFY_DIR=""
DEJIMA_SESSION_FILE=""
# The debounce removal in cleanup() joins this to a run id and removes the
# result, so it belongs here for the same reason as the two above and was
# missing: with remote access off nothing assigns it, and an operator with
# DEJIMA_STATE_DIR exported would have had the trap remove a path of their
# environment's choosing rather than one dejima made.
DEJIMA_STATE_DIR=""
# These three are assigned again further down, where each is read - the
# briefing file when it is composed, the other two among the credential
# proxy's own variables. Both of those sites are BELOW `trap cleanup EXIT`,
# with ten and twelve ordinary `exit` paths between, so on any of those exits
# the trap ran against whatever the environment held. SANDBOX_CREDENTIAL_PROXY_DIR
# is an `rm -rf` argument.
#
# Bound here as well rather than moved, because the later sites are read as
# "what this feature starts from" and are correct as that. This block is a
# different statement: what the trap must never inherit.
SANDBOX_BRIEFING_FILE=""
SANDBOX_CREDENTIAL_PROXY_DIR=""
SANDBOX_FAKE_CREDENTIALS_FILE=""
# The same rule, for two names cleanup() hands to `docker rm` rather than to
# `rm`. An inherited value there removes a container or a network belonging to
# something else on this host, which is a worse thing to get wrong than a file.
SANDBOX_FILTER_CONTAINER=""
SANDBOX_RUN_NETWORK=""
SANDBOX_NVIM_LOCKFILE=""
SANDBOX_DERIVED_DOCKERFILE=""
SANDBOX_DERIVED_CONTEXT=""
@@ -1230,6 +1383,17 @@ SANDBOX_TTY_SUSPEND_PID=""
SANDBOX_TTY_MODES=""
SANDBOX_CLIENT_PID=""
# This launch's identity, generated for every launch rather than only for a
# remote-enabled one, and here rather than further down for the same reason as
# the block above: cleanup() reads it, so it must be dejima's own value by the
# time the trap is installed and not whatever arrived in the environment.
#
# It names the registry entry and the marker directory when remote access is
# on, and nothing when it is off - but it is the launch's identity either way,
# and deriving it from whether one optional feature is configured made it
# absent exactly where an inherited value could stand in for it.
DEJIMA_RUN_ID="$(od -An -tx1 -N8 /dev/urandom | tr -d ' \n')"
# Names each host-side task in cleanup() as it starts. Between the container
# exiting and the operator's shell prompt returning, dejima audits every
# surface the session could have written to and reads back the transcripts it
@@ -1266,10 +1430,15 @@ cleanup() {
#
# SANDBOX_GITCONFIG_DIR additionally carries a `:-` default because it is
# the one name here first assigned BELOW `trap cleanup EXIT` rather than
# above it. Nothing in that window can exit today (it holds only
# comments, and a failed `VAR="$(mktemp -d)"` still binds VAR to the
# empty string before set -e fires), so this is the invariant every other
# name here already keeps, not a live fix - see the static test.
# above it. What makes that safe is not the `:-` but the window: it holds
# only comments, so there is no exit between the trap and the assignment
# for the trap to fire on (and a failed `VAR="$(mktemp -d)"` still binds
# VAR to the empty string before set -e fires).
#
# That is a property of this one name and of nothing else here, so the
# static test asserts it by exception: every other name the trap removes
# is bound above the trap, and adding a second name to the window fails
# the test rather than resting on someone re-deriving the argument.
# SIGTERM, which the shipper traps: it scans once more and flushes before
# exiting, so the tail of the session - the part worth having - is shipped
# rather than dying with the batch. Reaped for the same reason the proxy
@@ -1364,6 +1533,21 @@ cleanup() {
# dejima-notify-watch's marker consumption.
rm -rf "${DEJIMA_NOTIFY_DIR:-}" \
|| echo "warning: could not remove the marker directory ${DEJIMA_NOTIFY_DIR:-}" >&2
# This launch's egress control, and the network under it. The sandbox has
# already exited by the time the trap runs, so nothing is left holding the
# namespace - Docker permits removing an owner from under a live joiner
# anyway, which is why the order here is deliberate rather than incidental.
#
# `|| true` on both, like every other removal in this trap: a Docker daemon
# that has gone away must not replace the session's exit status. What that
# leaves behind is inert - a filter container filters a namespace nothing
# is in - and a later launch reconciles it.
if [ -n "${SANDBOX_FILTER_CONTAINER:-}" ]; then
docker rm -f "$SANDBOX_FILTER_CONTAINER" >/dev/null 2>&1 || true
fi
if [ -n "${SANDBOX_RUN_NETWORK:-}" ]; then
docker network rm "$SANDBOX_RUN_NETWORK" >/dev/null 2>&1 || true
fi
exit_step "session ended - keeping what it compacted away"
# Keep whatever the session compacted away, into a store nothing mounts.
# `|| true` for the same reason every other line in this trap has it: a
@@ -1396,7 +1580,11 @@ cleanup() {
# read it, so this function's own cleanup can't race the audit that
# needs it - and its removal is guarded too, so it stays truly last.
exit_step "checking what changed on the host while it ran"
DEJIMA_RW_MOUNTS="$DEJIMA_RW_MOUNTS" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
# DEJIMA_RW_SOURCES, not DEJIMA_RW_MOUNTS: this report names what on the
# HOST the session could have written, and for a per-project mount that is
# the slice rather than the root it sits in. Naming the root would claim a
# blast radius the per-project split exists to have narrowed.
DEJIMA_RW_MOUNTS="$DEJIMA_RW_SOURCES" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
rm -f "$SANDBOX_AUDIT_STATE" || true
}
trap cleanup EXIT
@@ -2223,6 +2411,7 @@ if [ -n "$SANDBOX_EXTRA_MOUNTS" ]; then
# [mounts] read_write has been guarded since it was added; this one
# never was, so `readonly = ~/.config/dejima` mounted the approval
# store into the container and nothing said a word (#15).
_refuse_pp_root "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" "$path"
_guarded_stores
_refuse_guarded_mount "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" \
"$path" "${GUARDED_STORES[@]}"
@@ -2245,6 +2434,17 @@ fi
for path in ${RW_MOUNT_PATHS[@]+"${RW_MOUNT_PATHS[@]}"}; do
extra_mounts+=(-v "$path:$path")
done
# And the per-project ones, which are the only mounts in dejima whose source
# and target differ on purpose: the slice on the host, the declared path in
# the container. Indices are paired, filled together in one loop above.
for _i in ${RW_PP_MOUNT_TARGETS[@]+"${!RW_PP_MOUNT_TARGETS[@]}"}; do
# dejima's own layout below a directory the operator made, so the slice is
# created and the root never is. Here rather than where it was resolved,
# because this is the launch path and that was also --print-briefing's.
mkdir -p "${RW_PP_MOUNT_SOURCES[$_i]}"
extra_mounts+=(-v "${RW_PP_MOUNT_SOURCES[$_i]}:${RW_PP_MOUNT_TARGETS[$_i]}")
done
unset _i
# The session briefing, composed host-side from the core text and the
# selected workflow/harness fragments (see briefing_compose) and mounted
@@ -2583,7 +2783,6 @@ export SANDBOX_CREDENTIAL_TRANSPORT
# nothing below runs, no state is created, and nothing is added to the
# compose invocation.
if remote_enabled; then
DEJIMA_RUN_ID="$(od -An -tx1 -N8 /dev/urandom | tr -d ' \n')"
DEJIMA_STATE_DIR="$(remote_state_dir)"
# The state dir's parent (e.g. /tmp, when XDG_RUNTIME_DIR is unset - the
@@ -3069,6 +3268,218 @@ if [ -n "$pending_crit" ] && [ ! -t 0 ]; then
fi
fi
# The egress control runs in a container of its own, which owns this launch's
# network namespace. The sandbox joins that namespace rather than getting one
# of its own, so the filter bounding it is installed somewhere it has no
# capability to reach - capabilities are per process, and the sandbox declares
# none of the network ones.
#
# Ordering is the point, and it is why this is here rather than inside the
# sandbox's own startup. The namespace has its rules before anything is created
# in it, so the window where a session holds an interface and no filter does
# not exist, rather than being bounded by an argument about how fast an
# entrypoint runs.
#
# The base image, never the per-project derived one: this is the privileged
# half of the pair and has no use for a project's declared dependencies.
SANDBOX_RUN_NETWORK="dejima-run-$DEJIMA_RUN_ID"
SANDBOX_FILTER_CONTAINER="dejima-filter-$DEJIMA_RUN_ID"
# A launch removes its own pair on the way out, which covers every exit the
# trap reaches. It does not cover SIGKILL, a host reboot, or a docker daemon
# restart, and each of those leaves a filter container holding a network open.
#
# The leak is inert - a filter container filters a namespace nothing is in - so
# this is hygiene rather than a second control. What makes it worth doing is
# the address pool: Docker carves a bounded number of networks out of its
# default pool, and exhausting it fails a launch with an error about address
# space that says nothing about the cause.
#
# Never fatal. A daemon that cannot answer any of this is one that is about to
# fail the launch a few lines down for a reason worth reporting, and a tidy-up
# pass should not be what reports it.
# How old a filter container or network has to be before an absent session
# counts as abandoned rather than as not started yet.
#
# Between `docker run` for the filter container and `docker compose run` for
# the session there is a window in which a healthy pair has no joiner, and it
# lasts as long as the firewall takes to come up - seconds, or half a minute
# when the GitHub ranges fetch retries. The same window exists for a network
# between its creation and its first endpoint. Minutes against seconds, because
# reaping a concurrent launch out from under itself costs that launch, while
# waiting costs one more pass.
DEJIMA_ORPHAN_GRACE=300
# Seconds since $1, a timestamp as Docker rendered it, or nothing at all if it
# cannot be read as one.
#
# Two renderings arrive here and only one is RFC 3339, because the field is not
# the same type on both of the structs this asks about: a container's `Created`
# is a string, a network's is a `time.Time`. A Go template prints a `time.Time`
# through String(), which appends a timezone NAME - "+0000 UTC" - and date(1)
# refuses that outright.
#
# So the name is stripped rather than the format assumed. Assuming the format
# is what broke this: the comment here used to say every caller read RFC 3339,
# which was true of one of them, and the network half therefore reaped nothing
# at all while looking exactly like the half that worked.
#
# An unparseable value still yields no age rather than a huge one, so the
# failure stays "nothing was reaped" and never becomes "everything was".
_epoch_age() {
local made stamp="$1"
# "2026-09-09 18:00:00.1 +0000 UTC" -> "2026-09-09 18:00:00.1 +0000", and
# an RFC 3339 stamp, which has no spaces, through unchanged.
case "$stamp" in
*' '[A-Z]*) stamp="${stamp% *}" ;;
esac
made="$(date -d "$stamp" +%s 2>/dev/null)" || return 1
[ -n "$made" ] || return 1
printf '%s' "$(( $(date +%s) - made ))"
}
reconcile_orphans() {
local candidates joined id name created age
# The candidates first, so a host with no filter containers at all asks one
# question and stops. The query below is the expensive one - it inspects
# every running container on the host, dejima's or not - and there is no
# point paying for it to answer about nothing.
candidates="$(docker ps -aq --filter 'label=dejima.filter' 2>/dev/null \
| xargs -r docker inspect -f '{{.Id}} {{.Name}} {{.Created}}' 2>/dev/null)"
if [ -n "$candidates" ]; then
# Whose namespace something is still in. Read once: asking per
# candidate would be a docker call per candidate, on every launch.
#
# NetworkMode is `container:<id>` for a joiner - the id, even where the
# launcher passed a name, so what is compared below are ids.
joined="$(docker ps -q 2>/dev/null | xargs -r docker inspect \
-f '{{.HostConfig.NetworkMode}}' 2>/dev/null | sed -n 's/^container://p')"
fi
while read -r id name created; do
[ -n "$id" ] && [ -n "$created" ] || continue
grep -qxF "$id" <<< "${joined:-}" && continue
age="$(_epoch_age "$created")" || continue
[ "$age" -gt "$DEJIMA_ORPHAN_GRACE" ] || continue
echo "dejima: removing a filter container an earlier session left behind (${name#/})" >&2
docker rm -f "$id" >/dev/null 2>&1 || true
done <<< "$candidates"
# Networks defend themselves twice over: `docker network rm` refuses one
# that still has an endpoint, and the grace covers the moment between a
# network being created and its filter container attaching to it.
while read -r name created; do
[ -n "$name" ] && [ -n "$created" ] || continue
age="$(_epoch_age "$created")" || continue
[ "$age" -gt "$DEJIMA_ORPHAN_GRACE" ] || continue
docker network rm "$name" >/dev/null 2>&1 || true
done < <(docker network ls -q --filter 'label=dejima.run' 2>/dev/null \
| xargs -r docker network inspect -f '{{.Name}} {{.Created}}' 2>/dev/null)
}
reconcile_orphans || true
# One network per launch, for two reasons, and the first is not optional:
# Docker's embedded resolver at 127.0.0.11 exists only on a user-defined
# network, and dns-proxy.sh forwards to it. The second is that concurrent
# sessions are then separated at the link layer as well, rather than only by
# each namespace's own egress default-deny.
docker network create --label "dejima.run=$DEJIMA_RUN_ID" "$SANDBOX_RUN_NETWORK" >/dev/null
# Everything the firewall path reads, passed as `-e NAME` so a value is taken
# from this process's environment rather than written into a command line every
# process on the host can read - GH_TOKEN is in this list.
#
# Derived from nothing: this IS the declaration, because the filter container
# is started with `docker run` and so gets none of the environment
# docker-compose.yml declares for the sandbox. A static test reads the same
# names back out of the firewall scripts, so a script that starts reading a new
# one fails rather than silently allowlisting less than it was told to.
filter_env=(
-e ENABLE_FIREWALL # whether to install rules at all
-e ALLOWED_DOMAINS # the operator's approved names
-e DEJIMA_FORGE_HOSTS # the configured forge's, resolved host-side
-e ALLOW_GITHUB_PUSH # whether port 22 to GitHub opens
-e GH_TOKEN # authenticates the GitHub ranges fetch
-e LOKI_URL # its host is allowlisted, and the shipper needs it
-e SANDBOX_HARNESS_DOMAINS
-e SANDBOX_BACKEND_PROXIED_DOMAIN
-e SANDBOX_CREDENTIAL_PROXY_SOCKET
)
# The capabilities the egress control needs, and the sysctl the namespace it
# owns needs. Every one of these used to be in docker-compose.yml, and none of
# them belongs to a container running a session:
#
# NET_ADMIN, NET_RAW the packet filter and the ipsets
# KILL dnsmasq drops to its own user, and dejima-allow
# signals it to add a domain mid-session
# CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID
# dns-proxy.sh's log file, and the two daemons that
# drop privilege after opening what they need
# SETPCAP spent taking the rest away from log-shipper.sh
#
# The sysctl lets the credential proxy's TLS terminator bind :443 as the
# unprivileged sandbox user. It is a property of the network namespace rather
# than of a container, so it is set here, by the container that owns one, and
# it applies to the session container that joins it.
docker run -d --init \
--name "$SANDBOX_FILTER_CONTAINER" \
--label "dejima.filter=$DEJIMA_RUN_ID" \
--network "$SANDBOX_RUN_NETWORK" \
--cap-drop ALL \
--cap-add NET_ADMIN --cap-add NET_RAW \
--cap-add KILL \
--cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER \
--cap-add SETUID --cap-add SETGID \
--cap-add SETPCAP \
--security-opt no-new-privileges:true \
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
${filter_env[@]+"${filter_env[@]}"} \
--entrypoint /usr/local/bin/filter-entrypoint.sh \
"${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}" >/dev/null
# Named, not the id `docker run -d` printed. Both work for `container:`, and a
# name we chose is one this launcher already knows without reading anything
# back - which is also what lets the teardown below name it after a failure
# that happened before any output could be captured.
export SANDBOX_NETNS_CONTAINER="container:$SANDBOX_FILTER_CONTAINER"
# Waited for, rather than assumed. The filter container resolves domains and
# fetches GitHub's published ranges, which is seconds on a good day and retries
# for half a minute on a bad one - so "it will be up by the time compose has
# started the sandbox" is a guess, and the thing it guesses about is whether
# the session gets a filter.
#
# Two ways out, and the second is why this is a loop rather than a sleep: the
# marker appears, or the container is gone. A firewall that refused to come up
# exits the container, and without that check this would wait out the whole
# timeout before reporting a failure that had already been decided.
echo "dejima: bringing up this session's egress control..." >&2
_filter_ready=0
for _ in $(seq 1 240); do
if docker exec "$SANDBOX_FILTER_CONTAINER" \
test -f /run/dejima-firewall-ready 2>/dev/null; then
_filter_ready=1
break
fi
if [ "$(docker inspect -f '{{.State.Running}}' "$SANDBOX_FILTER_CONTAINER" 2>/dev/null)" != "true" ]; then
break
fi
sleep 0.5
done
if [ "$_filter_ready" -ne 1 ]; then
echo "" >&2
echo "The egress control for this session did not come up, so there is no" >&2
echo "filter for the sandbox to run behind. Refusing to start it." >&2
echo "" >&2
echo "What the filter container said:" >&2
docker logs "$SANDBOX_FILTER_CONTAINER" 2>&1 | sed 's/^/ /' >&2 || true
exit 1
fi
compose_status=0
# Conversation shipping, host-side: it reads transcripts that are on the host
# already, so it never needed to be in the container - and being there put it
+59 -5
View File
@@ -268,6 +268,59 @@ if [ "$session_wanted" -eq 1 ]; then
[ -n "$container" ] || container="${containers%%$'\t'*}"
fi
# The dnsmasq and the ipsets are not in the session's container. They are
# in the filter container that owns its network namespace, which is what
# keeps a session from reaching either. So everything that CHANGES the
# allowlist below runs there, and only the reachability check at the end
# runs in the session's own container - because that is the container whose
# reachability this command claims to have changed.
#
# Both are derived from whichever one the caller named, rather than
# recorded anywhere: Docker already knows the pairing, because joining a
# namespace is how the pair is built.
#
# Naming either half has to work, and not as a convenience. A session that
# is asked for its own container id reads /etc/hostname, and Docker names a
# container that JOINED another's network namespace after the container it
# joined - so the id in a briefing, the one an agent hands its operator, is
# the filter container's. Nothing about the pair is worth making an
# operator hold in their head to type one command.
mode="$(docker inspect -f '{{.HostConfig.NetworkMode}}' "$container" 2>/dev/null)" || mode=""
case "$mode" in
container:*)
# A session container: it names its own filter.
filter="${mode#container:}"
;;
*)
# Then it is a filter container, or not part of a pair at all.
# `docker exec` takes a name or an id; NetworkMode reports the id,
# so the search below compares ids.
filter_id="$(docker inspect -f '{{.Id}}' "$container" 2>/dev/null)" || filter_id=""
session=""
if [ -n "$filter_id" ]; then
while IFS=' ' read -r _id _mode; do
[ "$_mode" = "container:$filter_id" ] || continue
session="$_id"
break
done < <(docker ps -q 2>/dev/null | xargs -r docker inspect \
-f '{{.Id}} {{.HostConfig.NetworkMode}}' 2>/dev/null)
fi
if [ -z "$session" ]; then
echo "$container is neither a running session nor the filter container of" >&2
echo "one, so there is no allowlist to add this domain to." >&2
echo "" >&2
echo "A session's own id is in its startup context, or from 'cat /etc/hostname'" >&2
echo "inside it - either half of the pair is accepted here. If the session has" >&2
echo "already exited, record the domain for the next one instead:" >&2
echo "" >&2
echo " dejima-allow --project $domain" >&2
exit 1
fi
filter="$container"
container="$session"
;;
esac
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
# this has to happen *before* resolving the domain below - otherwise the dig
# right after this would itself get blocked. Append a server= line to
@@ -277,15 +330,16 @@ if [ "$session_wanted" -eq 1 ]; then
# single-quoted heredoc) rather than interpolated into the script text - it's
# validated above, but this keeps it that way even if the validation regex
# above is ever loosened later.
docker exec -u root "$container" bash -c '
docker exec -u root "$filter" bash -c '
domain="$1"
entry="server=/${domain}/127.0.0.11"
grep -qxF "$entry" /etc/dnsmasq-sandbox-servers.conf 2>/dev/null || echo "$entry" >> /etc/dnsmasq-sandbox-servers.conf
kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"
' -- "$domain"
echo "Resolving $domain inside $container..."
ips=$(docker exec "$container" dig +short A "$domain" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || true)
# Named for where the lookup happens, which is where the DNS allowlist is.
echo "Resolving $domain in $filter, this session's egress control..."
ips=$(docker exec "$filter" dig +short A "$domain" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || true)
if [ -z "$ips" ]; then
# An internally-routed name can't be added to a *running* session at
# all: dnsmasq's stop-dns-rebind drops a private answer for any domain
@@ -294,7 +348,7 @@ if [ "$session_wanted" -eq 1 ]; then
# script SIGHUPs carries server= lines and nothing else. Asking the
# upstream resolver directly separates that dead end from a name that
# simply doesn't resolve, so the message can name the fix that works.
upstream=$(docker exec "$container" dig +short A "$domain" @127.0.0.11 2>/dev/null |
upstream=$(docker exec "$filter" dig +short A "$domain" @127.0.0.11 2>/dev/null |
grep -E '^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)' || true)
if [ -n "$upstream" ]; then
echo "$domain resolves to a private address (${upstream//$'\n'/ }), which the" >&2
@@ -320,7 +374,7 @@ if [ "$session_wanted" -eq 1 ]; then
target_set=$(ipset_for_domain "$domain")
for ip in $ips; do
echo "Allowing $ip ($domain -> $target_set)"
docker exec -u root "$container" ipset add "$target_set" "$ip" -exist
docker exec -u root "$filter" ipset add "$target_set" "$ip" -exist
done
echo "$domain allowed for the running session ($container)."
+1
View File
@@ -53,6 +53,7 @@ core.known_hosts path ~/.ssh/known_hosts
github.token_file path ~/.config/gh/sandbox-token
mounts.readonly ?list:path
mounts.read_write ?list:path
mounts.read_write_per_project ?list:path
mounts.nvim_config ?path
credentials.proxy bool on
credentials.transport enum:tls|loopback tls
+10
View File
@@ -2002,6 +2002,16 @@ _briefing_resolve_key() {
if [ -n "${DEJIMA_RW_MOUNTS:-}" ]; then
printf '\n These host directories are bind-mounted read-write at their own paths and\n outlive the container too: %s.\n ' "$DEJIMA_RW_MOUNTS"
fi
# A separate sentence, because the first one is not true of these.
# A per-project mount arrives at the path it names, like the
# others, but what is behind it is this project's own slice of
# that directory rather than the directory - so "these host
# directories ... at their own paths" would tell a session it can
# see something it cannot, and imply the next project's session
# will read what it leaves.
if [ -n "${DEJIMA_RW_PP_MOUNTS:-}" ]; then
printf '\n These are bind-mounted read-write too and also outlive the container, but\n each is THIS project'"'"'s own directory rather than one every project shares -\n the same arrangement as ~/shared: %s.\n ' "$DEJIMA_RW_PP_MOUNTS"
fi
;;
*)
echo "internal error: no resolver for whitelisted briefing key: $key" >&2
+20 -8
View File
@@ -17,16 +17,28 @@
# runs inside the container, the other is a separate library).
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
# Every per-project share directory lives under this one subdirectory of
# `[core] share_dir` rather than directly in it, which is what makes "left
# over from before the split" a decidable question: anything at share_dir's
# top level other than this directory predates the layout, because nothing
# writes there any more. It is also the directory to mount over sshfs - one
# entry per project, with no loose files mixed in.
share_projects_root() { printf '%s/projects' "${1%/}"; }
# The per-project layout, and there is one of it because there is more than one
# caller: `[core] share_dir` slices this way, and so does every
# `[mounts] read_write_per_project` entry. A host directory sliced per project
# holds one `projects` subdirectory, and one directory per slug inside it.
#
# Everything lives under that single subdirectory rather than directly in the
# root, which is what makes "left over from before, or put there by hand" a
# decidable question: anything at the root's top level other than this
# directory was not written by dejima. It is also the directory to mount over
# sshfs - one entry per project, with no loose files mixed in.
projects_root() { printf '%s/projects' "${1%/}"; }
# The directory a launch against $2 gets from a root sliced this way.
project_dir_for() { printf '%s/%s' "$(projects_root "$1")" "$(ws_slug "$2")"; }
# share_dir's own spelling of the two above. `[core] share_dir` is one
# instance of the layout, and these names are what let bin/dejima-share go on
# talking about a share rather than about "a per-project root".
share_projects_root() { projects_root "$1"; }
# The host directory mounted at ~/shared for a launch against $2.
share_dir_for() { printf '%s/%s' "$(share_projects_root "$1")" "$(ws_slug "$2")"; }
share_dir_for() { project_dir_for "$1" "$2"; }
# The slugs belonging to $1: its own, plus one per live git worktree nested
# underneath it. One per line, sorted, no duplicates.
+15 -6
View File
@@ -33,7 +33,7 @@ dejima-update-check - say when this dejima checkout is behind its remote
Usage: dejima-update-check
Compares the checkout bin/dejima runs from against the branch it tracks and
prints one line when the remote has commits this checkout does not. Run at
prints a notice when the remote has commits this checkout does not. Run at
startup by bin/dejima; safe to run by hand.
Silent when the checkout is current or ahead, when [core] update_check is off,
@@ -175,16 +175,25 @@ color_init
# fire on the terminal of the operator this is trying to inform.
_remote_shown="$(sanitize "$remote")"
_branch_shown="$(sanitize "${upstream#refs/heads/}")"
# A blank line either side, the indent the other startup warnings carry, and
# C_WARN: a launch prints this between its startup lines and the image build,
# which starts scrolling immediately after, and two plain lines there are
# indistinguishable from either.
printf '\n' >&2
# The count only when it is known. It is not, whenever the remote's tip is a
# commit this checkout has never fetched, which is the common case - and a
# number invented for those would be the number of commits it can see, not the
# number there are.
if [ -n "$count" ] && [ "$count" != "0" ]; then
if [ "$count" = "1" ]; then noun="commit"; else noun="commits"; fi
printf '%sdejima%s: %s/%s has %s %s this checkout does not.\n' \
"$C_KEY" "$C_RESET" "$_remote_shown" "$_branch_shown" "$C_INFO$count$C_RESET" "$noun" >&2
printf ' %sdejima: %s/%s has %s %s this checkout does not.%s\n' \
"$C_WARN" "$_remote_shown" "$_branch_shown" "$count" "$noun" "$C_RESET" >&2
else
printf '%sdejima%s: %s/%s has commits this checkout does not.\n' \
"$C_KEY" "$C_RESET" "$_remote_shown" "$_branch_shown" >&2
printf ' %sdejima: %s/%s has commits this checkout does not.%s\n' \
"$C_WARN" "$_remote_shown" "$_branch_shown" "$C_RESET" >&2
fi
printf ' Update with %sgit -C %s pull%s\n' "$C_KEY" "$REPO_ROOT" "$C_RESET" >&2
# install.sh is safe to re-run, and a release can change what it links on PATH,
# so pulling is only half the update. Both paths absolute because the operator
# reading this is not necessarily standing in this checkout.
printf ' Update with %sgit -C %s pull && %s/install.sh%s\n\n' \
"$C_KEY" "$REPO_ROOT" "$REPO_ROOT" "$C_RESET" >&2
+10 -1
View File
@@ -28,6 +28,11 @@ ENV TZ="$TZ"
# hardening). Whichever harness has one, its plugin's harness_prepare_config
# turns it off in the override layer, so there is nothing here to satisfy.
# Claude Code's is the "sandbox" block in settings.json.
#
# libcap2-bin is capsh, which entrypoint.sh uses to hand the session a
# container it holds no capabilities in. It was already here as a dependency of
# iproute2; a security control resting on another package's dependency graph is
# one an unrelated package change removes.
RUN apt-get update && apt-get install -y --no-install-recommends \
less \
git \
@@ -41,6 +46,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
iptables \
ipset \
iproute2 \
libcap2-bin \
dnsutils \
aggregate \
jq \
@@ -266,8 +272,11 @@ RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
# are supplied at runtime, not baked into the image - see
# docker-compose.yml/entrypoint.sh.
COPY docker/fixed-domains.sh /usr/local/bin/
COPY docker/caps-lib.sh /usr/local/bin/
COPY docker/dns-proxy.sh /usr/local/bin/
COPY docker/init-firewall.sh /usr/local/bin/
COPY docker/firewall-up.sh /usr/local/bin/
COPY docker/filter-entrypoint.sh /usr/local/bin/
COPY docker/log-shipper.sh /usr/local/bin/
COPY docker/ulogd.conf /etc/ulogd-sandbox.conf
# Into $VIMRUNTIME/plugin, not /etc/xdg/nvim - see the file's own comment.
@@ -323,7 +332,7 @@ RUN bash /tmp/install-forge-bins.sh /tmp/forges /usr/local/lib/dejima/forge /usr
# and describes what a page should look like instead of going and seeing.
RUN ln -s /usr/local/share/npm-global/lib/node_modules /node_modules
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh /usr/local/lib/dejima/hooks/*.sh && \
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/firewall-up.sh /usr/local/bin/filter-entrypoint.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh /usr/local/lib/dejima/hooks/*.sh && \
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
# Entrypoint runs as root (no USER node here) so it can set up the firewall
+45
View File
@@ -0,0 +1,45 @@
# Sourced by every entrypoint in this image. Nothing here runs on its own.
#
# The startup sequence needs real capabilities - it builds the packet filter,
# binds an NFLOG socket, chowns files and drops privilege twice. Nothing after
# it does, and a bounding set survives execve: a process that reaches uid 0
# anywhere downstream recovers its permitted set from whatever bounding set it
# inherited. So an unprivileged session whose effective set is empty still sits
# inside a tree holding every capability the container declares.
#
# `drop_caps` is how anything that outlives a startup script is started, and it
# is what makes a declared floor a description of the root phase rather than of
# the session's whole life.
#
# capsh, not `setpriv --bounding-set`, which is the obvious tool and silently
# the wrong one: without CAP_SETPCAP it exits 0 having changed nothing, so a
# container that lost the capability would run every session with the full set
# and report success at every step. capsh refuses out loud, which is the only
# behaviour worth having here.
#
# `-- -c 'exec "$@"'` runs the command through capsh's shell and immediately
# replaces it, so nothing is left between the caller and the process.
drop_caps() {
exec capsh --drop=all "$@"
}
# Checked rather than assumed, because the failure it prevents is invisible:
# capsh would abort each drop_caps call, and in an entrypoint the last of those
# is the session, so the container would die at the point it looked ready.
# Named here instead, against the file the floor actually lives in.
#
# Called once by each top-level entrypoint rather than from drop_caps: it is a
# precondition of the whole script, and the point is to refuse before doing
# work rather than partway through it.
require_setpcap() {
if capsh --decode="$(awk '/^CapEff:/ {print $2}' /proc/self/status)" \
| grep -q cap_setpcap; then
return 0
fi
echo "${0##*/}: this container does not hold CAP_SETPCAP, so the capabilities" >&2
echo "the startup sequence needs cannot be taken away again before the session" >&2
echo "starts - it would run inside a tree holding all of them." >&2
echo "Refusing to start rather than running with a floor that is not the one" >&2
echo "docker/docker-compose.yml declares. Add SETPCAP to its cap_add." >&2
exit 1
}
+75 -30
View File
@@ -48,43 +48,87 @@ services:
# ten per run - becomes a permanent zombie, and a long session eventually
# exhausts the cgroup pids limit and can no longer fork at all.
init: true
# An explicit floor rather than Docker's default set plus two. The default
# carries about fourteen capabilities and this container uses seven: it
# writes and chowns files, runs the firewall, drops privilege twice (gosu
# for the session, `ulogd -u` for the log daemon), and signals dnsmasq.
# FSETID, MKNOD, SETFCAP, SETPCAP, SYS_CHROOT, AUDIT_WRITE and
# NET_BIND_SERVICE are used by nothing here - the last one because the
# sysctl below is what lets the TLS terminator bind :443 unprivileged.
# The session does not get a network namespace of its own. It joins the one
# the filter container owns, where the egress rules are already installed -
# so the filter bounding this session sits somewhere it declares no
# capability to reach. bin/dejima starts that container and passes its name
# here; see the block above its compose invocation, and docs/security.md.
#
# KILL is the one worth explaining, because it is not obvious from reading
# entrypoint.sh and was missing from the first version of this list. It is
# not for anything the entrypoint does. `dejima-allow --container` adds a
# domain to a RUNNING session by appending a server= line and sending
# dnsmasq a SIGHUP, from outside via docker exec - and dnsmasq drops to its
# own user (`user=dnsmasq`), so root in this container cannot signal it
# without CAP_KILL. Without this line, adding a domain mid-session fails
# with "Operation not permitted", which is a documented feature every
# session briefing tells the agent to ask for.
# The default is for a `docker compose` invocation by hand, which this
# file's first line says is not how to run it. It is `network_mode: none`
# rather than any real network, because the value that makes a session safe
# arrives from outside this file, and the question a default answers is
# what happens when it does not arrive: no network at all is a sandbox that
# visibly cannot work, where a real one is a sandbox that works and has
# unfiltered egress.
#
# It does not affect `docker compose build`, which reads `build.network`.
network_mode: ${SANDBOX_NETNS_CONTAINER:-none}
# An explicit floor rather than Docker's default set. The default carries
# about fourteen capabilities; this container writes and chowns files and
# drops privilege once, to the session user. FSETID, MKNOD, SETFCAP,
# SYS_CHROOT, AUDIT_WRITE and NET_BIND_SERVICE are used by nothing here -
# the last one because a sysctl on this namespace, not a capability, is
# what lets the TLS terminator bind :443 unprivileged.
#
# No network capability at all, which is the point of the arrangement
# rather than an economy. This container is inside a network namespace the
# filter container owns and has already installed the egress rules in, and
# capabilities are per process: without NET_ADMIN, nothing here can alter
# those rules even at uid 0. NET_RAW goes with it - a raw socket is the
# other way to speak past a filter, and nothing here opens one.
#
# KILL is here for tini, and for nothing else in this container.
#
# `init: true` above makes PID 1 the init that reaps for the session, and
# tini forwards every signal it receives to its child. That child is the
# entrypoint, which changes uid in place before exec'ing the session - so
# tini is uid 0 signalling a process whose real AND saved uids are the
# sandbox user's. kill(2) permits that only with CAP_KILL or a uid match,
# and there is no match, so without this every forwarded signal is EPERM.
#
# tini treats a failed forward as fatal, and PID 1 exiting ends the
# container. The signal that arrives in practice is SIGWINCH: the kernel
# sends it to the pty's foreground process group on any terminal resize,
# which includes tini. So without this line, splitting a tmux pane kills
# the session with `[FATAL tini (1)] Unexpected error when forwarding
# signal: 'Operation not permitted'`.
#
# It used to be here for `dejima-allow --container`, which signals dnsmasq;
# that reason moved to the filter container with dnsmasq, and this one was
# not noticed until the capability went away. Nothing in the suite ran an
# init at all - see runtime.bats.
#
# The general lesson, since the next capability will be the same shape:
# what the container needs is not only what its entrypoint does. Things
# reach into it later.
# what a container needs is not only what its entrypoint does. Things reach
# into it later.
#
# This narrows the ROOT phase. The session user's capabilities were already
# empty: they are cleared on the gosu drop, which runtime.bats asserts by
# reading CapEff of the running process. What this adds is that the set was
# never there to be regained.
# empty: they are cleared on the privilege drop, which runtime.bats asserts
# by reading CapEff of the running process. What this adds is that the set
# was never there to be regained.
#
# SETPCAP is the odd one out: nothing in the startup sequence uses it for
# its own work. It is spent, once, to take every capability above away from
# the session before it starts - see entrypoint.sh. A bounding set survives
# execve and a uid-0 process recovers its permitted set from it, so without
# this the whole list is held by everything the session can reach, effective
# set or not.
#
# Adding a capability to remove capabilities is worth stating plainly. It
# can only reduce: since Linux 4.3 SETPCAP grants nothing a process does
# not already hold in its permitted set, so what it buys is the drop and
# nothing else.
cap_drop:
- ALL
cap_add:
- NET_ADMIN
- NET_RAW
- KILL
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETUID
- SETGID
- KILL
- SETPCAP
# No path back up. gosu drops privilege rather than gaining it, so this
# does not interfere with it - what it forbids is a setuid binary or a file
# capability raising privileges on exec, which is the shape any escalation
@@ -92,12 +136,13 @@ services:
# structural instead of only observed.
security_opt:
- no-new-privileges:true
# The credential-proxy TLS terminator binds :443 as the unprivileged
# sandbox user (see entrypoint.sh). Docker already defaults this to 0, but
# pinning it makes the dependency explicit: a future default change then
# fails the launch instead of silently breaking the credential path.
sysctls:
net.ipv4.ip_unprivileged_port_start: 0
# No sysctls here. The one this container depends on -
# net.ipv4.ip_unprivileged_port_start, which lets the credential proxy's
# TLS terminator bind :443 as the unprivileged sandbox user - is a property
# of a network namespace, and this container does not own the one it is in.
# bin/dejima sets it on the filter container that does, so it is declared
# once, by the container it belongs to. runtime.bats reads the value back
# from in here, which is where it has to be true.
environment:
# The forge's own env vars (declared by its manifest's env_export) and
# the DEJIMA_* resolved values (DEJIMA_FORGE_HOSTS, DEJIMA_PUSH_HOSTS,
+30 -50
View File
@@ -8,56 +8,30 @@ set -Eeuo pipefail
# refuse deliberately, and say so (#59).
trap 'status=$?; echo "ERROR: entrypoint.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
# DEJIMA_FORGE_HOSTS is resolved host-side by bin/dejima (forge_hosts, in
# dejima-provider-lib.sh) from whichever forge is configured, so this file
# does not derive or name any one forge's hostnames itself.
# drop_caps and require_setpcap, shared with the filter container's entrypoint.
# Sourced rather than defined here because the callers must not drift: what
# `drop_caps` does is the difference between a session holding nothing and a
# session holding the whole floor, and that is not a thing to have two of.
# shellcheck source=caps-lib.sh
source /usr/local/bin/caps-lib.sh
require_setpcap
# The egress control for this namespace is already up: the filter container
# that owns it installed the rules, and bin/dejima waited for that before
# starting this container. Nothing here brings it up, and nothing here could -
# this container declares none of the capabilities it would take.
#
# LOKI_URL (a full URL) still needs its own derivation here - Loki's host
# has to be reachable for log-shipper.sh to actually deliver anything.
loki_host=""
if [ -n "${LOKI_URL:-}" ]; then
loki_host=$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')
fi
extra_domains="${DEJIMA_FORGE_HOSTS:-} ${loki_host} ${ALLOWED_DOMAINS:-}"
# What this container does need is to be pointed at the resolver enforcing the
# name allowlist. /etc/resolv.conf is per container even when the namespace is
# shared, so dns-proxy.sh repointing the filter container's file says nothing
# about this one - which Docker generated pointing straight at its embedded
# resolver. Left alone, every lookup the session makes would go around dnsmasq
# and the DNS allowlist would apply to nothing.
#
# The same value dns-proxy.sh writes for itself, at the same loopback address,
# because loopback is shared with the container the resolver runs in.
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
# dns-proxy.sh must run first: it repoints /etc/resolv.conf at a local
# dnsmasq that only resolves allowlisted names, so init-firewall.sh's own
# dig calls (and everything afterward) go through the same restriction.
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/dns-proxy.sh
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/init-firewall.sh
# NFLOG groups only exist once init-firewall.sh has run, so ulogd2 (the
# NFLOG consumer) and the Loki shipper start after it. Both are
# best-effort visibility on top of the enforcement above, not required
# for it - log-shipper.sh itself no-ops if LOKI_URL isn't set. Runs as
# its own unprivileged `ulogd` user (opens the NFLOG socket and output
# files first, then drops), not root - it's parsing headers from
# attacker-influenceable packets (whatever a compromised session sends),
# so a bug in it shouldn't be a path back to root in the container.
#
# `ulogd -d` daemonizes immediately and always exits 0 from this shell's
# point of view, regardless of whether the daemon itself goes on to
# start successfully - so `set -e` above won't catch a failure here (nor
# should it: logging is best-effort, not something worth failing the
# whole container over). Check separately so a failure is at least
# visible instead of silently producing no logs.
/usr/sbin/ulogd -d -u ulogd -c /etc/ulogd-sandbox.conf
sleep 0.5
if ! pgrep -x ulogd >/dev/null; then
echo "WARNING: ulogd2 failed to start - connection logging will be unavailable (see /var/log/ulogd.log)" >&2
fi
# Unlike ulogd2 above, this one stays root - deliberately, not an
# oversight. What it ships is an oversight log of the session running
# beside it, and letting the sandbox user own the shipper would let a
# compromised session tamper with or kill its own logging. Root buys
# tamper-resistance that a security log needs and a best-effort netflow
# counter does not.
#
# What it reads is dnsmasq's query log and ulogd2's output, both written
# by root-owned daemons here and neither session-writable.
/usr/local/bin/log-shipper.sh &
echo "nameserver 127.0.0.1" > /etc/resolv.conf
fi
# A normal login/ssh session always chowns the allocated pty to the logging
@@ -538,13 +512,19 @@ fi
# exercising the actual code path instead of a reimplementation of it that
# could drift out of sync.
if [ "${SANDBOX_TEST_MODE:-0}" = "1" ]; then
exec gosu "$SANDBOX_USER" sleep infinity
drop_caps --user="$SANDBOX_USER" -- -c 'exec "$@"' dejima sleep infinity
fi
# Drop root -> the sandbox user for the actual session; that user has no
# sudo/setuid path back to root after this, so it can't re-run
# init-firewall.sh itself to widen the allowlist mid-session.
#
# And drop the bounding set with it, so "no path back to root" and "no
# capability if there were one" are two separate things rather than one
# resting on the other. capsh does both in one process because it has to: the
# bounding set can only be reduced while CAP_SETPCAP is still held, and
# changing uid first gives it away.
#
# SANDBOX_CMD is what actually runs as that user - the selected harness's
# own command unless [core] command overrides it (e.g. to `nvim`) for an
# editor-driven workflow; pair that with SANDBOX_NVIM_CONFIG (see bin/dejima)
@@ -560,4 +540,4 @@ if [ -z "${SANDBOX_CMD:-}" ]; then
echo "command: docker run --entrypoint sh ..." >&2
exit 1
fi
exec gosu "$SANDBOX_USER" $SANDBOX_CMD "$@"
drop_caps --user="$SANDBOX_USER" -- -c 'exec "$@"' dejima $SANDBOX_CMD "$@"
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# PID 1 of the filter container: brings this launch's egress control up, says
# so, and then holds the network namespace open for the sandbox to join.
#
# The sandbox is a separate container that joins this namespace and declares no
# network capability of its own. Capabilities are per process, so it cannot
# alter the filter installed here even at uid 0 - which is the whole reason the
# two are separate. See docs/security.md.
#
# Nothing a session runs ever executes in here. This container holds the
# privilege; the one beside it holds the session.
set -Eeuo pipefail
trap 'status=$?; echo "ERROR: filter-entrypoint.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
# shellcheck source=caps-lib.sh
source /usr/local/bin/caps-lib.sh
require_setpcap
/usr/local/bin/firewall-up.sh
# Written last, and only on the success path above - `set -e` means a firewall
# that failed to come up never reaches this line. bin/dejima waits for this
# file before it starts the sandbox, so the ordering that makes the arrangement
# fail-closed is a fact about the filesystem rather than about how long a
# launcher guessed the firewall would take.
#
# Under /run, which is a tmpfs: a container that is restarted rather than
# relaunched must not inherit a marker saying its filter is up before it is.
: > /run/dejima-firewall-ready
echo "filter: egress control is up; holding the namespace for the session"
# Held open by a process that does nothing else. The namespace lives as long as
# a process is in it, and this container is started with an init that forwards
# SIGTERM, so `docker stop` ends it promptly rather than waiting out a timeout.
exec sleep infinity
+81
View File
@@ -0,0 +1,81 @@
#!/bin/bash
# Brings up this network namespace's egress control: the DNS proxy, the packet
# filter, and the two log producers that watch them.
#
# Its own script rather than a block inside entrypoint.sh because the namespace
# it acts on and the session that runs in it need not be in the same container.
# What this does is a property of a network namespace; everything else an
# entrypoint does is a property of a container.
#
# Called with the caller's environment: DEJIMA_FORGE_HOSTS, LOKI_URL,
# ALLOWED_DOMAINS, ENABLE_FIREWALL and the harness values fixed-domains.sh
# reads. It resolves nothing host-side itself.
set -Eeuo pipefail
# Same reason as entrypoint.sh's: under `set -e` alone a failure here aborts
# with no output, and the only symptom is a container that never appeared (#59).
trap 'status=$?; echo "ERROR: firewall-up.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
# shellcheck source=caps-lib.sh
source /usr/local/bin/caps-lib.sh
# DEJIMA_FORGE_HOSTS is resolved host-side by bin/dejima (forge_hosts, in
# dejima-provider-lib.sh) from whichever forge is configured, so this file
# does not derive or name any one forge's hostnames itself.
#
# LOKI_URL (a full URL) still needs its own derivation here - Loki's host
# has to be reachable for log-shipper.sh to actually deliver anything.
loki_host=""
if [ -n "${LOKI_URL:-}" ]; then
loki_host=$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')
fi
extra_domains="${DEJIMA_FORGE_HOSTS:-} ${loki_host} ${ALLOWED_DOMAINS:-}"
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
# dns-proxy.sh must run first: it repoints /etc/resolv.conf at a local
# dnsmasq that only resolves allowlisted names, so init-firewall.sh's own
# dig calls (and everything afterward) go through the same restriction.
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/dns-proxy.sh
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/init-firewall.sh
# NFLOG groups only exist once init-firewall.sh has run, so ulogd2 (the
# NFLOG consumer) and the Loki shipper start after it. Both are
# best-effort visibility on top of the enforcement above, not required
# for it - log-shipper.sh itself no-ops if LOKI_URL isn't set. Runs as
# its own unprivileged `ulogd` user (opens the NFLOG socket and output
# files first, then drops), not root - it's parsing headers from
# attacker-influenceable packets (whatever a compromised session sends),
# so a bug in it shouldn't be a path back to root in the container.
#
# `ulogd -d` daemonizes immediately and always exits 0 from this shell's
# point of view, regardless of whether the daemon itself goes on to
# start successfully - so `set -e` above won't catch a failure here (nor
# should it: logging is best-effort, not something worth failing the
# whole container over). Check separately so a failure is at least
# visible instead of silently producing no logs.
/usr/sbin/ulogd -d -u ulogd -c /etc/ulogd-sandbox.conf
sleep 0.5
if ! pgrep -x ulogd >/dev/null; then
echo "WARNING: ulogd2 failed to start - connection logging will be unavailable (see /var/log/ulogd.log)" >&2
fi
# Unlike ulogd2 above, this one stays root - deliberately, not an
# oversight. What it ships is an oversight log of the session running
# beside it, and letting the sandbox user own the shipper would let a
# compromised session tamper with or kill its own logging. Root buys
# tamper-resistance that a security log needs and a best-effort netflow
# counter does not.
#
# What it reads is dnsmasq's query log and ulogd2's output, both written
# by root-owned daemons here and neither session-writable.
#
# Root, and nothing else: it parses content the session's own traffic
# decides, and it needs no capability to read two files its gid already
# opens or to POST them. So it keeps the uid that makes it untamperable
# and holds none of the rest.
#
# It outlives this script, and reparents to the container's init when this
# one exits - which is where a daemon watching a session belongs. Started
# from the entrypoint, it was a child of the very session process it exists
# to keep an eye on.
drop_caps -- -c 'exec "$@"' dejima /usr/local/bin/log-shipper.sh &
fi
+13
View File
@@ -48,6 +48,19 @@ ship_stream() {
last_flush=$(date +%s)
}
# tail's stderr is discarded below because -F is meant to wait for a file
# that does not exist yet, and complains once per second while it does.
# That also swallows a permission error, which is not transient and which
# looks identical from the outside: a stream that ships nothing, which is
# also what a quiet session looks like.
#
# This process holds no capabilities (see entrypoint.sh), so it reads these
# two files on their ordinary permissions and nothing else. Say so once
# when that is not enough, rather than shipping an empty stream.
if [ -e "$file" ] && [ ! -r "$file" ]; then
echo "WARNING: $file is not readable by $(id -un) - the $job stream will ship nothing." >&2
fi
last_flush=$(date +%s)
tail -F -n0 "$file" 2>/dev/null | while true; do
if IFS= read -r -t "$BATCH_MAX_SECONDS" line; then
+37 -9
View File
@@ -163,7 +163,8 @@ you selected, the second is push access when GitHub *is* your forge.
| Key | Default | Purpose |
| --- | --- | --- |
| `readonly` | unset | extra host paths (colon-separated) a personal config needs, mounted read-only at their own path |
| `read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
| `read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session and be shared by every project; refused if they overlap anything dejima needs out of a session's reach |
| `read_write_per_project` | unset | the same, mounted at the path named but backed by `<path>/projects/<slug>` - one directory per project rather than one for the host. For anything a session writes to be read later as background, where another project reading it would be a channel rather than a convenience |
| `nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
### `[audit]` - the exit-time audit
@@ -842,15 +843,41 @@ session is meant to write to and whose contents should outlive the container:
```
[mounts]
read_write = ~/specs
read_write = ~/.cache/shared-toolchain
```
Both lists mount at the host path they name, so a convention that says
`[mounts] read_write_per_project` mounts at the same path, but backs it with
one directory per project instead of the whole thing:
```
[mounts]
read_write_per_project = ~/specs
```
The container sees `~/specs` either way. Behind it, `read_write` puts the
directory itself - every session's contents, including other projects' - and
`read_write_per_project` puts `~/specs/projects/<slug>`, keyed the same way
`[core] share_dir` and the audit store are.
**Which to reach for is decided by one question: should another project's
session read what this one leaves there?** For a build cache or a directory of
scripts, yes, and `read_write` is right. For design docs, scratch notes,
anything an agent writes to be read later as background - no, and that is what
`read_write_per_project` is for. A spec is written to be read as context, so a
shared one is a channel from any project into every other.
All three lists mount at the host path they name, so a convention that says
"specs live in `~/specs`" keeps working inside the container without being
rewritten. That is the difference from `[core] share_dir`, which is
mounted at `~/shared` regardless of where it lives on the host because its job
is to be a well-known handoff point.
dejima creates the per-project directory; it never creates the one you named,
so a path that is not there is refused as the typo it is. Naming a
`read_write_per_project` root's `projects` directory in either of the other
two lists is refused as well - handing that over is handing over every
project's.
An entry is **refused, not warned about**, if it overlaps anything dejima
relies on being out of a session's reach: the approval store, either audit
store, the epitaph store, every project's share, `~/.local/bin`, dejima's own
@@ -958,13 +985,14 @@ Treat it as a handoff point, not as general-purpose storage. If your own
project instructions tell agents to write somewhere at a fixed host path -
design docs, scratch notes, anything meant to outlive the session but stay out
of the project checkout - mount that directory instead, with
`[mounts] read_write`. It arrives at its own absolute path, so a convention
that says "put specs in `~/specs`" keeps working inside the container without
being rewritten to point at `~/shared`.
`[mounts] read_write_per_project`. It arrives at its own absolute path, so a
convention that says "put specs in `~/specs`" keeps working inside the
container without being rewritten to point at `~/shared` - and, like `~/shared`
itself, what a session finds there is this project's and not every project's.
`~/shared`, the project mount, and anything in `[mounts] read_write` are the
only writable host paths in the container. Everything else a session writes
goes away with it.
`~/shared`, the project mount, and anything in `[mounts] read_write` or
`[mounts] read_write_per_project` are the only writable host paths in the
container. Everything else a session writes goes away with it.
**Clear it out periodically.** The per-project split bounds which sessions can
read what you left, and bounds nothing about how long. A project's share
+5 -2
View File
@@ -14,10 +14,13 @@ run them.
| `docker/install-forge-bins.sh` | Installs each forge's CLIs into the image from its manifest's `install` list, and fails the build on a file that is shipped but not declared, or declared but not shipped |
| `docker/fixed-domains.sh` | Shared domain list sourced by both `init-firewall.sh` and `dns-proxy.sh` |
| `docker/dns-proxy.sh` | Starts the DNS-allowlisting `dnsmasq`, repoints `resolv.conf` at it |
| `docker/init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at container start |
| `docker/init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at filter-container start |
| `docker/firewall-up.sh` | Brings a network namespace's egress control up: the DNS proxy, the packet filter, and the two log producers. What a namespace needs, rather than what a container does |
| `docker/filter-entrypoint.sh` | PID 1 of the filter container: runs `firewall-up.sh`, signals readiness, then holds the namespace open for the sandbox to join |
| `docker/caps-lib.sh` | `drop_caps` and the `CAP_SETPCAP` precondition, sourced by both entrypoints so the two cannot drift |
| `docker/ulogd.conf` | `ulogd2` config: turns `init-firewall.sh`'s NFLOG-tagged packets into JSON |
| `docker/log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to the configured Loki endpoint (`[logging] loki_url`). Container-side, because those files only exist there |
| `docker/entrypoint.sh` | Runs as root: DNS proxy, firewall, logging, drops to the sandbox user via `gosu`, then execs the session's command |
| `docker/entrypoint.sh` | PID 1 of the session container: runs as root to prepare the session's own state, points `resolv.conf` at the namespace's resolver, then execs the session's command as the sandbox user with an empty capability bounding set. The egress control is the filter container's to bring up, not this one's |
| `docker/harness-wrapper.sh` | Shadows the selected harness's command on `PATH` to add its no-confirm flags and dejima's settings layer. Named by no harness; `entrypoint.sh` links it into place |
| `docker/docker-compose.yml` | Wires up mounts/env; invoked via `bin/dejima`, not directly |
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders, and any `.dejima.md` the project ships |
+7 -4
View File
@@ -46,11 +46,14 @@ at all.
`test/runtime.bats` needs Docker, which a sandbox does not have. It is the only
thing that exercises the mounts, the firewall and the entrypoint end to end, so
a release is exactly when not running it stops being acceptable. Either run it
on the host, or **check that the privileged CI job ran** - it is pinned to one
self-hosted runner and queues silently when that runner is down, so a pull
request can sit with the runtime half neither green nor red.
on the host, or **read the pull request's checks**: green means every job
finished and passed, `runtime.bats` among them. Yellow or grey means the
privileged job has not run - it is pinned to one self-hosted runner and queues
silently when that runner is down.
"CI covers it" is not "it passed."
So the answer is the colour. Green is verified end to end; say so. Anything
else is a run that has not happened, and reporting it as passing is what this
paragraph exists to prevent.
## 2. Security review
+5 -6
View File
@@ -52,12 +52,11 @@ running.
## The bind address is the one thing to get right
`docker/init-firewall.sh` deliberately ACCEPTs container OUTPUT to the
container's default-route address - the Docker bridge gateway, which is your
host. That is the one host address a sandbox can reach at all. Combine it
with the fact above (a tmux client is a host shell) and the consequence is
direct: **a front door the sandbox can reach is a compromised session's own
escape hatch.**
`docker/init-firewall.sh` deliberately ACCEPTs OUTPUT to the session's
default-route address - the Docker bridge gateway, which is your host. That is
the one host address a sandbox can reach at all. Combine it with the fact above
(a tmux client is a host shell) and the consequence is direct: **a front door
the sandbox can reach is a compromised session's own escape hatch.**
So:
+82 -17
View File
@@ -271,7 +271,7 @@ In that configuration the provider directories are in the exit-time audit's
TCB list, alongside `bin/`, `hooks/`, `docker/`, `test/` and `tools/`. They
hold no code and nothing executes them, but what they hold decides what the
next launch permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`,
which `entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so
which `firewall-up.sh` joins into the domains `init-firewall.sh` allowlists, so
a line added to a manifest widens the egress boundary on the next run.
`tools/*/tool.conf` is the same class, and is read by the host rather than the
container: it names a tarball URL, the sha256 checked against it and the
@@ -1017,15 +1017,52 @@ reasoning below is what both are built on.
SSH-agent/token story documented above. Keep credentials out of your
global gitconfig, or expect them to be reachable from inside the
sandbox.
- **The egress filter is not in the container the session runs in.** A launch
starts two: a filter container that owns the network namespace and installs
the packet filter, and the sandbox, which joins that namespace with
`network_mode: container:` rather than getting one of its own. Capabilities
are per process rather than per namespace, so the sandbox cannot alter the
filter bounding it even at uid 0 - measured, not reasoned: `iptables` and
`ipset` both return `Operation not permitted` to a joiner that lacks
`NET_ADMIN`.
Two things follow. The namespace has its rules before anything is created in
it, so there is no window in which a session holds an interface and no
filter; `bin/dejima` waits for the filter container to say it is up and
refuses the launch otherwise. And killing the filter container leaves the
sandbox holding loopback alone - no network at all, rather than an unfiltered
one.
Nothing a session runs ever executes in the filter container. It holds the
privilege; the one beside it holds the session. Each launch gets a network of
its own, so concurrent sessions are separated at the link layer as well.
A launch removes both on the way out, and reconciles at the start against
whatever a `SIGKILL` or a restarted host left behind - a filter container
with no session in its namespace, older than the window in which a pair is
still being built. What that reclaims is address space rather than safety: a
leaked filter container filters a namespace nothing is in, and Docker's
address pool is finite enough that exhausting it fails a later launch with an
error naming neither cause nor remedy.
- The sandbox's own user (matching your host user; see "Config parity" in
the README) has no `sudo`/setuid path back to root once the container is up:
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
that user via `gosu` before execing the configured command (`[core] command`,
or the selected harness's own if that is unset). A compromised session can't
`entrypoint.sh` runs as root, prepares the session's own state, then drops to
that user before execing the configured command (`[core] command`, or the
selected harness's own if that is unset). A compromised session can't
re-run `init-firewall.sh` with a wider allowlist to open its own
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
cleared from the effective/permitted sets on the `gosu` drop, and that
user gets a permission error running `iptables` directly.
exfiltration path: that script runs in the container beside it, so there is
no copy of the allowlist within reach to re-run against. Verified: that user
gets a permission error running `iptables` directly.
- **The session's capability bounding set is empty**, not merely its effective
one. The distinction is the whole of it: an empty effective set is what
changing uid gives you anyway, while the bounding set is what a process that
reached uid 0 in that tree would recover its capabilities from. So the two
claims above - no path back to root, and no capability if there were one -
hold separately rather than one resting on the other. The drop and the uid
change happen in one `capsh` process because they have to: the bounding set
can only be reduced while `CAP_SETPCAP` is still held, and changing uid first
gives it away. The same applies to `log-shipper.sh`, the one root process
that outlives the entrypoint.
- A forge credential reaches the container as `-e NAME`, never
`-e NAME=value`. The value comes from the launcher's own environment, so it
sits in `/proc/<pid>/environ` (mode 0400, owner only) rather than in
@@ -1033,17 +1070,45 @@ reasoning below is what both are built on.
anyone. `docker inspect` still shows the resolved value to whoever can
reach the daemon - that is daemon access rather than any process at all,
and it is not something the launcher can change.
- The container runs with `no-new-privileges` and an explicit capability
floor - `cap_drop: ALL` plus the ones it actually uses: what the
entrypoint's root phase needs, and `KILL`. `KILL` is there for
`dejima-allow --container`, not for the entrypoint - adding a domain to a
running session signals dnsmasq, and dnsmasq runs as its own user. The list
itself is in `docker/docker-compose.yml`, which is where it stays correct. The first means a setuid binary or a file capability cannot raise
- **Each container runs with `no-new-privileges` and a capability floor of its
own**, `cap_drop: ALL` plus what that container actually uses. The two lists
are the arrangement stated as configuration:
The session container declares **no network capability at all** - not
`NET_ADMIN`, not `NET_RAW`. It is inside a namespace the filter container
owns and has already installed the rules in, and capabilities are per
process, so uid 0 in there is refused by the kernel rather than by policy.
What it keeps is what its root phase does before the session starts: write
and chown files, and drop privilege once. `SETPCAP` is there to spend rather
than to use - it is what lets the entrypoint take the rest of the list away
again before execing the session, and since Linux 4.3 it grants nothing a
process does not already hold.
The filter container holds the network capabilities. The session's list is in
`docker/docker-compose.yml` and the filter's is in `bin/dejima`, each beside
the container it describes.
`KILL` is on both lists, for unrelated reasons, and it is the one worth
reading twice. The filter container needs it because adding a domain to a
running session signals dnsmasq, which runs as its own user. The session
container needs it because `init: true` makes PID 1 an init that forwards
signals to the session - and the entrypoint changes uid in place before
exec'ing it, so that is uid 0 signalling a process whose real and saved uids
are the sandbox user's. Without the capability every forwarded signal is
`EPERM`, tini treats a failed forward as fatal, and an ordinary terminal
resize ends the session.
`no-new-privileges` means a setuid binary or a file capability cannot raise
privileges on exec, which is the shape any escalation from the session user
would have to take; the second means the capabilities nothing here uses were
never in the bounding set to be regained. Both are asserted against a running
container rather than against the compose file, and the capability list the
test uses is read out of that file so the two cannot drift.
would have to take. The floor means the capabilities nothing uses were never
in the bounding set to be regained. Both are asserted against running
containers rather than against the files, and both lists the tests use are
read out of those files so neither can drift.
What a floor describes is a container's **root phase**. A launch runs with
`init: true`, so PID 1 is the init that reaps for the session and it holds
that container's floor for as long as the container lives; the session is its
child and holds nothing.
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
see
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
+10 -5
View File
@@ -35,11 +35,16 @@ session_id="${1:-}"
resume_id="${session_id:-<your session id>}"
project_dir="${2:-$PWD}"
# /etc/hostname in a docker container is the container's own ID, which
# docker exec accepts directly - substituting it into the briefing below
# means the agent states its real container instead of guessing (a wrong
# guess that matches *another* running sandbox makes dejima-allow
# "succeed" against the wrong container).
# The id of this launch, read from the only place a session can see one.
#
# Not this container's own id, and worth being exact about: Docker names a
# container that joined another's network namespace after the container it
# joined, so what a session reads here is its FILTER container's id. That is
# still the right thing to put in the briefing - `dejima-allow` takes either
# half of the pair and derives the other - and it is what the agent has, so
# substituting it means stating a real id rather than guessing one. A wrong
# guess that matched another running sandbox would make dejima-allow "succeed"
# against somebody else's session.
container_id=$(cat /etc/hostname 2>/dev/null)
container_id="${container_id:-<container-id>}"
+154 -1
View File
@@ -2221,6 +2221,139 @@ EOF
rm -rf "$workspace" "$RW_HOME"
}
@test "a read_write_per_project entry mounts this project's slice at the path it names" {
_rw_setup
pp="$(mktemp -d)"
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write_per_project = $pp"
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# The container gets the path the operator named; the host gives it one
# directory of that path, keyed the same way share_dir and the audit store
# are. Both halves asserted, because either alone is satisfied by the
# arrangement this replaces.
slug="$(printf '%s' "$workspace" | sed 's/[^a-zA-Z0-9]/-/g')"
grep -qxF "$pp/projects/$slug:$pp" "$docker_log" \
|| { echo "not mounted as a per-project slice:"; grep "$pp" "$docker_log"; return 1; }
run grep -qxF "$pp:$pp" "$docker_log"
[ "$status" -ne 0 ] \
|| { echo "the whole directory was handed over as well"; return 1; }
[ -d "$pp/projects/$slug" ] || { echo "the slice was never created"; return 1; }
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
}
@test "two projects get different slices of one read_write_per_project entry" {
# The whole point, and not deducible from one launch: the same declared
# directory has to give two workspaces two directories, or it is a shared
# pile with extra steps.
_rw_setup
pp="$(mktemp -d)"
ws_a="$(mktemp -d)"; ws_b="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write_per_project = $pp"
_rw_launch "$ws_a"; [ "$status" -eq 0 ] || { echo "$output"; return 1; }
a_log="$docker_log"
_rw_launch "$ws_b"; [ "$status" -eq 0 ] || { echo "$output"; return 1; }
a="$(grep -oE "$pp/projects/[^:]+" "$a_log" | head -1)"
b="$(grep -oE "$pp/projects/[^:]+" "$docker_log" | head -1)"
[ -n "$a" ] && [ -n "$b" ] || { echo "no slice mounted for one of them"; return 1; }
[ "$a" != "$b" ] \
|| { echo "both projects were given the same directory: $a"; return 1; }
rm -rf "$pp" "$ws_a" "$ws_b" "$RW_HOME" "$a_log" "$docker_log"
}
@test "read_write is refused when it names a read_write_per_project root" {
# The share guard's reason, one directory over: handing over the root that
# holds every project's slice puts back the pile the slicing removed.
_rw_setup
pp="$(mktemp -d)"
workspace="$(mktemp -d)"
mkdir -p "$pp/projects"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $pp/projects" "read_write_per_project = $pp"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "the pile was handed back: $output"; return 1; }
[[ "$output" == *"holds one directory per project"* ]] || { echo "$output"; return 1; }
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
}
@test "readonly is refused when it names a read_write_per_project root" {
_rw_setup
pp="$(mktemp -d)"
workspace="$(mktemp -d)"
mkdir -p "$pp/projects"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "readonly = $pp/projects" "read_write_per_project = $pp"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "every project's slice was readable: $output"; return 1; }
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
}
@test "a read_write_per_project entry overlapping a protected path is refused" {
# Slicing does not make a guarded store safe to hand over - it only makes
# less of it reachable, which is not the property the guard is about.
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write_per_project = $CONFIG_DIR"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "the approval store was mounted: $output"; return 1; }
[[ "$output" == *"only how much of it there is"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace" "$RW_HOME" "$docker_log"
}
@test "the same path in read_write and read_write_per_project is refused" {
# Not by a duplicate check: the two mean different things, so naming a path
# in both makes it an ancestor of its own per-project root, and the refusal
# that fires is the one about handing over every project's directory. That
# is the right answer, and it says which list the path belongs in.
_rw_setup
pp="$(mktemp -d)"
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $pp" "read_write_per_project = $pp"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"holds one directory per project"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"one list or the other and not in both"* ]] || { echo "$output"; return 1; }
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
}
@test "a read_write_per_project entry that is not an existing directory is refused" {
# The slice is dejima's to create; the root is not. A path that is not
# there is a typo, and creating it would turn the typo into a directory.
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write_per_project = /nonexistent/specs"
_rw_launch "$workspace"
[ "$status" -ne 0 ]
[[ "$output" == *"not an existing directory"* ]] || { echo "$output"; return 1; }
[ ! -d /nonexistent/specs ]
rm -rf "$workspace" "$RW_HOME" "$docker_log"
}
@test "--print-briefing names a per-project mount as this project's own" {
# A separate sentence from the read_write one, because the read_write
# sentence is not true of these: a session told "these host directories at
# their own paths" would believe the whole directory is behind the mount.
pp="$(mktemp -d)"
rw="$(mktemp -d)"
write_config '[core]' 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $rw" "read_write_per_project = $pp"
run "$REPO_ROOT/bin/dejima" --print-briefing
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"at their own paths"*"$rw"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"THIS project's own directory"*"$pp"* ]] || { echo "$output"; return 1; }
# And composing a briefing leaves nothing behind: the slice belongs to a
# launch, not to a diagnostic that prints one.
[ ! -d "$pp/projects" ] || { echo "--print-briefing created the slice"; return 1; }
rm -rf "$pp" "$rw"
}
@test "a path in both read_write and readonly is refused rather than resolved by mount order" {
_rw_setup
d="$(mktemp -d)"
@@ -3027,10 +3160,30 @@ _upd_cache() { printf '%s/.local/share/dejima/update-check' "$HOME"; }
[ "$status" -eq 0 ]
[[ "$output" == *"origin/trunk"* ]] \
|| { echo "does not name what it is behind: $output"; return 1; }
# The path, because the checkout that is behind is not necessarily the
# The paths, because the checkout that is behind is not necessarily the
# directory the operator is standing in when they read this.
[[ "$output" == *"git -C $UPD_CHECKOUT pull"* ]] \
|| { echo "does not say how to update: $output"; return 1; }
# install.sh too, because pulling does not re-link what a new release
# adds on PATH.
[[ "$output" == *"$UPD_CHECKOUT/install.sh"* ]] \
|| { echo "does not say to re-run install.sh: $output"; return 1; }
}
@test "the notice is set off by a blank line either side" {
_upd_fixture
_upd_commit "$UPD_ORIGIN" second
# Read from the file the tool wrote: bats' run strips the blank lines at
# either end of what it captures, and those are the thing being asserted -
# they are what keeps the launch's startup lines and image build from
# reading as part of the notice.
local out="$UPD_DIR/notice"
"$UPD_CHECKOUT/bin/dejima-update-check" >"$out" 2>&1
grep -q "commits this checkout" "$out" || { cat "$out"; return 1; }
[ -z "$(head -n 1 "$out")" ] \
|| { echo "no blank line before it: $(cat "$out")"; return 1; }
[ -z "$(tail -n 1 "$out")" ] \
|| { echo "no blank line after it: $(cat "$out")"; return 1; }
}
@test "the commit count is given only when the commits are actually here" {
+382
View File
@@ -0,0 +1,382 @@
#!/usr/bin/env bats
# How a launch is arranged: the containers it starts, in what order, on what
# network, and what it takes down again.
#
# The subject is bin/dejima's side of the filter container - the extra
# container per launch that owns the network namespace and the packet filter,
# which the sandbox joins and has no capability to alter. Whether the filter
# actually filters is runtime.bats's question and needs a real Docker; whether
# the launcher builds the arrangement that puts one there is this file's, and
# needs only a stub.
#
# Everything here runs against a `docker` that records its argv and exits 0, so
# a launch completes without a daemon. That makes the ORDER of the calls, and
# the flags on each, the thing being asserted.
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
SANDBOX_DIR="$REPO_ROOT/docker"
LIB="$REPO_ROOT/bin/dejima-project-lib.sh"
CONFIG_DIR="$(mktemp -d)"
RUNTIME_DIR="$(mktemp -d)"
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
export XDG_RUNTIME_DIR="$RUNTIME_DIR"
}
teardown() {
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${LAUNCH_DIR:+"$LAUNCH_DIR"} ${PROJ:+"$PROJ"}
}
# A `docker` that logs one line per invocation and succeeds at everything,
# including the readiness probe. Tests that need a failure override it.
stub_launch() {
LAUNCH_DIR="$(mktemp -d)"
mkdir -p "$LAUNCH_DIR/home/.claude"
echo '{}' > "$LAUNCH_DIR/home/.claude/settings.json"
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
: > "$LAUNCH_DIR/sandbox-key"
chmod 600 "$LAUNCH_DIR/sandbox-key"
PROJ="$(mktemp -d)"
}
launch() {
local conf
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
mkdir -p "$(dirname "$conf")"
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
"$LAUNCH_DIR/sandbox-key" >> "$conf"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
}
log_line() { grep -- "$1" "$LAUNCH_DIR/docker-log" | tail -1; }
# 1-indexed position of the last line matching a pattern, for ordering claims.
log_pos() { grep -n -- "$1" "$LAUNCH_DIR/docker-log" | tail -1 | cut -d: -f1; }
@test "a launch creates a network of its own and a filter container on it" {
stub_launch
launch "$PROJ"
local net; net="$(log_line 'network create')"
[ -n "$net" ] || { echo "no network was created"; return 1; }
[[ "$net" =~ dejima-run-[0-9a-f]{16} ]] \
|| { echo "the network is not named for this run: $net"; return 1; }
[[ "$net" == *"--label dejima.run="* ]] \
|| { echo "the network carries no run label, so nothing can reconcile it: $net"; return 1; }
local run; run="$(log_line 'run -d --init')"
[ -n "$run" ] || { echo "no filter container was started"; return 1; }
[[ "$run" =~ --name\ dejima-filter-[0-9a-f]{16} ]] \
|| { echo "the filter container is not named for this run: $run"; return 1; }
[[ "$run" == *"--entrypoint /usr/local/bin/filter-entrypoint.sh"* ]] \
|| { echo "the filter container does not run the filter entrypoint: $run"; return 1; }
# One network per launch, and the filter container on that one - not on
# whatever bridge Docker would have picked. dns-proxy.sh forwards to the
# embedded resolver at 127.0.0.11, which only exists on a user-defined
# network, so this is load-bearing rather than tidiness.
local id; id="$(sed -E 's/.*dejima-run-([0-9a-f]{16}).*/\1/' <<< "$net")"
[[ "$run" == *"--network dejima-run-$id"* ]] \
|| { echo "the filter container is not on this launch's network: $run"; return 1; }
}
@test "the sandbox joins the filter container's namespace rather than getting one" {
stub_launch
launch "$PROJ"
# The compose file is what carries it, from an environment variable the
# launcher exports - so both halves are asserted. Either alone passes on an
# arrangement where the session still has a network of its own.
grep -qE '^\s*network_mode: \$\{SANDBOX_NETNS_CONTAINER' "$SANDBOX_DIR/docker-compose.yml" \
|| { echo "the compose file does not join a namespace"; return 1; }
# And what it falls back to when nothing supplies one. Only a `docker
# compose` invocation by hand reaches this, but a default naming a real
# network is a sandbox that comes up working with unfiltered egress, and
# nothing about it looks wrong. `none` fails the same case visibly.
grep -qE '^\s*network_mode: \$\{SANDBOX_NETNS_CONTAINER:-none\}\s*$' \
"$SANDBOX_DIR/docker-compose.yml" \
|| { echo "the network_mode default is not fail-closed"; return 1; }
local run; run="$(log_line 'run -d --init')"
local id; id="$(sed -E 's/.*--name dejima-filter-([0-9a-f]{16}).*/\1/' <<< "$run")"
[ -n "$id" ]
run grep -c "SANDBOX_NETNS_CONTAINER" "$REPO_ROOT/bin/dejima"
[ "$status" -eq 0 ]
grep -q 'export SANDBOX_NETNS_CONTAINER="container:\$SANDBOX_FILTER_CONTAINER"' \
"$REPO_ROOT/bin/dejima" \
|| { echo "the launcher does not point the sandbox at the filter container"; return 1; }
}
@test "the filter container comes up before the session, not beside it" {
# The ordering IS the control. A namespace that gets its rules after
# something is already running in it has a window where a session holds an
# interface and no filter, which is what this arrangement exists to remove
# by construction rather than to bound by argument.
stub_launch
launch "$PROJ"
local net_at filter_at ready_at session_at
net_at="$(log_pos 'network create')"
filter_at="$(log_pos 'run -d --init')"
ready_at="$(log_pos 'dejima-firewall-ready')"
session_at="$(log_pos ' run --rm ')"
[ -n "$net_at" ] && [ -n "$filter_at" ] && [ -n "$ready_at" ] && [ -n "$session_at" ] \
|| { echo "one of the four steps never happened"; cat "$LAUNCH_DIR/docker-log"; return 1; }
[ "$net_at" -lt "$filter_at" ] \
|| { echo "the filter container started before its network existed"; return 1; }
[ "$filter_at" -lt "$ready_at" ] \
|| { echo "readiness was probed before the filter container was started"; return 1; }
[ "$ready_at" -lt "$session_at" ] \
|| { echo "the session started before the filter was confirmed up"; return 1; }
}
@test "a filter container that never comes up refuses the launch" {
# The failure this is really about is the quiet one: a launcher that waited,
# gave up, and started the session anyway would produce a working sandbox
# with no egress control, and nothing downstream would notice.
stub_launch
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
case "$*" in
# The marker never appears, and the container is reported gone - which is
# what a firewall that refused to start leaves behind.
*dejima-firewall-ready*) exit 1 ;;
*inspect*State.Running*) echo false; exit 0 ;;
*logs*) echo "init-firewall.sh: DNS lookup for registry.npmjs.org failed"; exit 0 ;;
esac
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
run launch "$PROJ"
[ "$status" -ne 0 ] \
|| { echo "the launch succeeded with no egress control"; return 1; }
[[ "$output" == *"did not come up"* ]] \
|| { echo "it refused without saying why: $output"; return 1; }
# And it hands over what the filter container said, or the operator is left
# with a refusal and nowhere to look.
[[ "$output" == *"registry.npmjs.org"* ]] \
|| { echo "the refusal does not carry the filter container's own output: $output"; return 1; }
run grep -c -- ' run --rm ' "$LAUNCH_DIR/docker-log"
[ "$output" = "0" ] \
|| { echo "the session was started anyway"; return 1; }
}
@test "the launch takes its own container and network down again" {
stub_launch
launch "$PROJ"
local run id
run="$(log_line 'run -d --init')"
id="$(sed -E 's/.*--name dejima-filter-([0-9a-f]{16}).*/\1/' <<< "$run")"
[ -n "$id" ]
grep -q -- "rm -f dejima-filter-$id" "$LAUNCH_DIR/docker-log" \
|| { echo "the filter container was left running"; return 1; }
grep -q -- "network rm dejima-run-$id" "$LAUNCH_DIR/docker-log" \
|| { echo "the network was left behind"; return 1; }
# After the session, not before it. Docker permits removing a namespace
# owner from under a live joiner, so this order is the launcher's to keep.
local session_at rm_at
session_at="$(log_pos ' run --rm ')"
rm_at="$(log_pos "rm -f dejima-filter-$id")"
[ "$session_at" -lt "$rm_at" ] \
|| { echo "the filter container was removed before the session ran"; return 1; }
}
@test "the filter container runs the base image, never a project's derived one" {
# It is the privileged half of the pair. A project's declared dependencies
# are installed into the derived image for the session to use, and there is
# no reason for any of them to be present in the container holding
# NET_ADMIN.
stub_launch
launch "$PROJ"
local run; run="$(log_line 'run -d --init')"
[[ "$run" =~ localhost/dejima:[^[:space:]-]+$ ]] \
|| { echo "the filter container's image is not the plain base tag: $run"; return 1; }
}
@test "the filter container declares the network capabilities and drops the rest" {
stub_launch
launch "$PROJ"
local run; run="$(log_line 'run -d --init')"
[[ "$run" == *"--cap-drop ALL"* ]] \
|| { echo "the filter container keeps Docker's default set: $run"; return 1; }
[[ "$run" == *"--security-opt no-new-privileges:true"* ]] \
|| { echo "no-new-privileges is missing from the filter container: $run"; return 1; }
local cap
for cap in NET_ADMIN NET_RAW KILL SETPCAP; do
[[ "$run" == *"--cap-add $cap"* ]] \
|| { echo "the filter container cannot do its job without $cap: $run"; return 1; }
done
}
@test "every variable the firewall path reads is passed to the filter container" {
# The drift guard this arrangement needs most. The sandbox gets its
# environment from docker-compose.yml; the filter container is started with
# `docker run` and gets nothing that is not named on that command line.
#
# So a firewall script that starts reading a new variable does not fail - it
# reads an empty one, and allowlists less than it was told to, which
# presents as a domain the operator approved being unreachable rather than
# as anything about this. Derived from the scripts rather than listed here,
# so the test cannot drift from them either.
stub_launch
launch "$PROJ"
local run; run="$(log_line 'run -d --init')"
[ -n "$run" ]
local v missing=""
while read -r v; do
[ -n "$v" ] || continue
[[ "$run" == *"-e $v"* ]] || missing="$missing $v"
done < <(cat "$SANDBOX_DIR/firewall-up.sh" "$SANDBOX_DIR/dns-proxy.sh" \
"$SANDBOX_DIR/init-firewall.sh" "$SANDBOX_DIR/fixed-domains.sh" \
"$SANDBOX_DIR/log-shipper.sh" \
| grep -oE '\$\{?(ALLOW[A-Z_]*|DEJIMA_[A-Z_]*|ENABLE_[A-Z_]*|GH_TOKEN|LOKI_URL|SANDBOX_[A-Z_]*)' \
| tr -d '${' | sort -u \
| grep -vE '^(EXTRA_ALLOWED_DOMAINS|SANDBOX_TEST.*)$')
[ -z "$missing" ] \
|| { echo "the firewall path reads these, and the filter container is not given them:$missing"; return 1; }
# And the other half of the same failure, which the flag alone cannot show.
# `docker run -e NAME` takes the value from the launcher's environment, so
# a name that is assigned but never exported passes an empty one - the
# filter container then allowlists less than it was told to, and what an
# operator sees is a domain they approved being unreachable, with nothing
# pointing here.
#
# Not hypothetical: `export DEJIMA_FORGE_HOSTS` is a line that had to be
# added by hand when the filter container was introduced, because until
# then that variable had only ever needed to reach the sandbox as
# `-e NAME=value`, which carries its own value.
#
# Continuations are flattened first, because the exports here run to
# several lines; and `export` is matched anywhere on a line rather than at
# its start, because two of these are exported after a `;` on the line that
# assigns them.
local exported unexported=""
exported="$(sed -z 's/\\\n[[:space:]]*/ /g' "$REPO_ROOT/bin/dejima" \
| grep -oE 'export [A-Za-z_][A-Za-z0-9_ ]*')"
[ -n "$exported" ] || { echo "found no exports in bin/dejima at all"; return 1; }
while read -r v; do
[ -n "$v" ] || continue
grep -qE "(^|[[:space:]])$v([[:space:]]|\$)" <<< "$exported" \
|| unexported="$unexported $v"
done < <(grep -oE '^\s+-e [A-Z_]+' "$REPO_ROOT/bin/dejima" | awk '{print $2}' | sort -u)
[ -z "$unexported" ] \
|| { echo "passed as -e NAME but never exported, so the value is empty:$unexported"; return 1; }
}
# A `docker` that reports one filter container and one network, with whatever
# age, joiner and identity a test needs, and records every removal.
#
# Each of these tests is about a branch that decides NOT to remove something,
# and a reconciler that removed nothing at all would pass every one of them -
# so the first test here is the one that proves the mechanism fires.
stub_orphan() { # <age-seconds> <joined-by|"">
local age="$1" joiner="$2"
stub_launch
# Each query answers in the format Docker really answers it in, and the two
# differ: `Created` is a string field on a container and a time.Time on a
# network, so a template prints the second through String() and appends a
# timezone NAME. One format for both is what let the network half of the
# reconciler pass a test while reaping nothing.
cat > "$LAUNCH_DIR/docker" <<EOF
#!/bin/bash
echo "\$*" >> "\${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
epoch=\$(( \$(date +%s) - $age ))
made=\$(date -u -d "@\$epoch" +%Y-%m-%dT%H:%M:%S.000000000Z)
made_go=\$(date -u -d "@\$epoch" '+%Y-%m-%d %H:%M:%S.000000000 +0000 UTC')
case "\$*" in
# The running containers, and what namespace each is in.
"ps -q") [ -n "$joiner" ] && echo j0 ; exit 0 ;;
*HostConfig.NetworkMode*) [ -n "$joiner" ] && echo "container:$joiner" ; exit 0 ;;
# The candidates.
*"ps -aq --filter label=dejima.filter"*) echo orphan0 ; exit 0 ;;
*"{{.Id}} {{.Name}} {{.Created}}"*) echo "orphan0 /dejima-filter-old \$made" ; exit 0 ;;
*"network ls -q --filter label=dejima.run"*) echo net0 ; exit 0 ;;
*"{{.Name}} {{.Created}}"*) echo "dejima-run-old \$made_go" ; exit 0 ;;
esac
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
}
@test "a filter container nothing is joined to, left over from before, is removed" {
stub_orphan 6000 ""
launch "$PROJ"
grep -q -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log" \
|| { echo "the orphaned filter container was left running"; return 1; }
grep -q -- 'network rm dejima-run-old' "$LAUNCH_DIR/docker-log" \
|| { echo "its network was left behind"; return 1; }
}
@test "the reconcile says what it removed, so a leak is not silently routine" {
stub_orphan 6000 ""
run launch "$PROJ"
[[ "$output" == *"left behind"* ]] \
|| { echo "an orphan was removed with nothing said about it: $output"; return 1; }
}
@test "a filter container with a session still in its namespace is left alone" {
# The one that matters on a host running several sessions: reaping this is
# taking the network out from under somebody's running agent.
stub_orphan 6000 orphan0
launch "$PROJ"
run grep -c -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log"
[ "$output" = "0" ] \
|| { echo "a filter container with a live session was removed"; return 1; }
}
@test "a filter container younger than the grace is left alone" {
# A pair being built right now by a concurrent launch: between `docker run`
# for the filter and `docker compose run` for the session, a healthy filter
# container has no joiner. Age is what tells that apart from abandonment.
stub_orphan 5 ""
launch "$PROJ"
run grep -c -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log"
[ "$output" = "0" ] \
|| { echo "a filter container still being paired was removed"; return 1; }
run grep -c -- 'network rm dejima-run-old' "$LAUNCH_DIR/docker-log"
[ "$output" = "0" ] \
|| { echo "a network still being attached to was removed"; return 1; }
}
@test "a docker that cannot answer the reconcile does not fail the launch" {
# It runs before the launch's own network and container, so an error here
# would refuse a session over tidying up. The launch that follows reports
# its own failures.
stub_launch
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
case "$*" in
ps*|*inspect*|*network\ ls*) echo "Cannot connect to the Docker daemon" >&2; exit 1 ;;
esac
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
run launch "$PROJ"
[ "$status" -eq 0 ] \
|| { echo "a failing reconcile refused the launch: $output"; return 1; }
grep -q -- ' run --rm ' "$LAUNCH_DIR/docker-log" \
|| { echo "the session never started"; return 1; }
}
+75 -1
View File
@@ -31,6 +31,11 @@ if [ "\$1" = ps ]; then
exit 0
fi
case "\$*" in
# A session container shares a filter container's network namespace, and
# that is where the dnsmasq and the ipsets are. dejima-allow derives the
# one from the other rather than being told, so a stub that answers this
# with nothing is a session with no filter to add a domain to.
*inspect*NetworkMode*) echo "container:dejima-test-filter"; exit 0 ;;
*dig*) echo 10.0.0.1; exit 0 ;;
*curl*) exit $2 ;;
*) exit 0 ;;
@@ -651,6 +656,66 @@ EOF
grep -qx 'crates.io' "$(store_of "$PROJ")/allowed-domains"
}
@test "dejima-allow takes the filter container's id, which is what a session reads" {
# A session asked for its own container id runs `cat /etc/hostname`, and
# Docker names a container that joined another's network namespace after
# the container it joined - so the id an agent hands its operator is the
# FILTER container's. That id has to work, or the one command every
# briefing tells an agent to ask for fails on the id the briefing gave it.
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${DOCKER_CALL_LOG:-/dev/null}"
case "$*" in
# Named container is a filter: it owns a network rather than joining one.
*inspect*NetworkMode*c1*) echo "dejima-run-abcd" ;;
*inspect*'{{.Id}}'*c1*) echo "filterid" ;;
# And one running container is in its namespace.
"ps -q") echo sessionid ;;
*'{{.Id}} {{.HostConfig.NetworkMode}}'*) echo "sessionid container:filterid" ;;
*dig*) echo 10.0.0.1 ;;
esac
exit 0
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH" DOCKER_CALL_LOG="$STUB_DIR/calls" \
run "$REPO_ROOT/bin/dejima-allow" --container c1 crates.io
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# Asserted on the calls rather than the messages: which container each
# half runs in is the whole point, and a message could name either while
# the exec went to the wrong one.
grep -q 'exec -u root c1 ipset add' "$STUB_DIR/calls" \
|| { echo "the ipset add did not go to the filter container:"; cat "$STUB_DIR/calls"; return 1; }
grep -q 'exec sessionid curl' "$STUB_DIR/calls" \
|| { echo "the probe did not run in the session container:"; cat "$STUB_DIR/calls"; return 1; }
rm -rf "$STUB_DIR"
}
@test "dejima-allow refuses a container that is neither half of a pair" {
# A launch predating the split has a namespace of its own and no filter
# container in it. The useful answer is what to do instead, not a docker
# exec that fails somewhere inside a heredoc.
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/bash
case "$*" in
*inspect*NetworkMode*) echo "bridge"; exit 0 ;;
*inspect*'{{.Id}}'*) echo "loneid"; exit 0 ;;
"ps -q") exit 0 ;;
esac
exit 0
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH" run "$REPO_ROOT/bin/dejima-allow" --container c1 crates.io
[ "$status" -ne 0 ]
[[ "$output" == *"neither a running session nor the filter container"* ]] \
|| { echo "$output"; return 1; }
[[ "$output" == *"dejima-allow --project crates.io"* ]] \
|| { echo "the refusal does not say what to do instead: $output"; return 1; }
rm -rf "$STUB_DIR"
}
@test "dejima-allow --global persists without needing a container" {
run "$REPO_ROOT/bin/dejima-allow" --global example.com
[ "$status" -eq 0 ]
@@ -704,6 +769,7 @@ stub_docker_rebind() {
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/bash
case "$*" in
*inspect*NetworkMode*) echo "container:dejima-test-filter"; exit 0 ;;
*dig*@127.0.0.11*) echo 10.0.0.1 ;;
esac
exit 0
@@ -737,7 +803,15 @@ EOF
# The rebind branch above must not swallow the ordinary failure: a typo'd
# or dead name gets no answer from the upstream either.
STUB_DIR="$(mktemp -d)"
printf '#!/bin/bash\nexit 0\n' > "$STUB_DIR/docker"
# Answers where the filter container is and nothing else, so every lookup
# comes back empty - which is the case this is about.
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/bash
case "$*" in
*inspect*NetworkMode*) echo "container:dejima-test-filter" ;;
esac
exit 0
EOF
chmod +x "$STUB_DIR/docker"
run env PATH="$STUB_DIR:$PATH" \
"$REPO_ROOT/bin/dejima-allow" --container c1 nowhere.example.com
+39 -6
View File
@@ -10,13 +10,43 @@
set -uo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
base="${1:-main}"
# The remote-tracking ref by default, not the local branch. Everything this
# prints is derived from base..head - the manual checks from which paths the
# diff touches, the rollback list from which config keys it adds - so a base
# behind the real one silently reports a different release. That is not
# hypothetical: two passes ran against a local `main` 260 commits behind
# origin/main and reported keys and a trust-anchor domain that had shipped
# releases earlier. A gate answering confidently about the wrong diff is worse
# than one that does not run.
base="${1:-}"
if [ -z "$base" ]; then
if git rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
base=origin/main
else
base=main
fi
fi
head="${2:-HEAD}"
# Guarded, because everything below is relative to it: a cd that failed
# silently would have this reporting on some other directory's suites and
# diff, which for a release gate is the worst way to be wrong.
cd "$root" || { echo "cannot enter $root" >&2; exit 1; }
# A named base that is behind its own upstream is refused rather than used.
# Passing `main` explicitly is the ordinary thing to do and is exactly how the
# stale-base runs happened, so this has to catch the argument as well as the
# default.
base_upstream="$(git rev-parse --abbrev-ref --symbolic-full-name "$base@{upstream}" 2>/dev/null || true)"
if [ -n "$base_upstream" ]; then
behind="$(git rev-list --count "$base..$base_upstream" 2>/dev/null || echo 0)"
if [ "${behind:-0}" -gt 0 ]; then
echo "release-pass: '$base' is $behind commit(s) behind $base_upstream." >&2
echo "Everything below is derived from $base..$head, so this would report on" >&2
echo "a release that is not the one being made. Fetch, or name $base_upstream." >&2
exit 1
fi
fi
case "${1:-}" in
-h|--help)
echo "Usage: test/release-pass.sh [base] [head]"
@@ -65,9 +95,11 @@ if docker info >/dev/null 2>&1; then
else
fail "no Docker here - test/runtime.bats did NOT run"
echo " It is the only thing that exercises mounts, the firewall and"
echo " the entrypoint end to end. Run it on the host, or check that"
echo " the privileged CI job RAN rather than queued: it is pinned to"
echo " one self-hosted runner and queues silently when that is down."
echo " the entrypoint end to end. Run it on the host, or read the"
echo " pull request's checks: GREEN means every job finished and"
echo " passed, this one among them. Yellow or grey means the"
echo " privileged job is still queued - it is pinned to one"
echo " self-hosted runner and queues silently when that is down."
fi
# --- what a person has to do, chosen by what the release touches -------------
@@ -211,6 +243,7 @@ else
fi
note "Then"
echo " Say in the release PR what was run and what was not. A privileged job"
echo " that queued is not a job that passed."
echo " Say in the release PR what was run and what was not. Green checks mean"
echo " the privileged job ran; a queued one is yellow or grey, and is not a"
echo " job that passed."
exit "$status"
+36 -2
View File
@@ -215,6 +215,30 @@ run_line() { grep -- ' run --rm ' "$LAUNCH_DIR/docker-log" | tail -1; }
rm -rf "$PROJ"
}
@test "an inherited state dir and run id are not what the exit trap deletes" {
# cleanup() removes "$DEJIMA_STATE_DIR/debounce/$DEJIMA_RUN_ID-{waiting,done}".
# With remote access off nothing used to assign either name, so both came
# from the caller's environment if it had them - and the trap then removed
# a path of the environment's choosing. Both are bound by dejima before the
# trap is installed now, so the join can only ever name a path it made.
#
# Two canaries, because either name alone being dejima's is enough to miss
# the path: the removal needs both halves to come from the environment.
stub_launch
local canary="$LAUNCH_DIR/canary"
mkdir -p "$canary/debounce"
: > "$canary/debounce/deadbeefdeadbeef-waiting"
: > "$canary/debounce/deadbeefdeadbeef-done"
DEJIMA_STATE_DIR="$canary" DEJIMA_RUN_ID=deadbeefdeadbeef launch "$PROJ"
[ -f "$canary/debounce/deadbeefdeadbeef-waiting" ] \
|| { echo "the trap removed a file named by the environment"; return 1; }
[ -f "$canary/debounce/deadbeefdeadbeef-done" ] \
|| { echo "the trap removed a file named by the environment"; return 1; }
rm -rf "$PROJ"
}
@test "with remote access configured a registry entry is written and removed on exit" {
stub_launch
write_remote_config
@@ -420,11 +444,21 @@ EOF
write_remote_config
# The EXIT trap removes the entry, so snapshot it from inside the run:
# the stub stands in for the container, and runs while it still exists.
#
# Matched on the session's own compose run rather than on every docker
# call. A launch makes several - a build, this launch's filter container,
# and two removals from the exit trap - and snapshotting on each meant the
# last one won. Those two removals run after the entry is gone, so what
# this read was an empty directory.
cat > "$LAUNCH_DIR/docker" <<EOF
#!/bin/bash
echo "\$*" >> "\${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
rm -rf "$LAUNCH_DIR/sessions-snapshot"
cp -r "$RUNTIME_DIR/dejima/sessions" "$LAUNCH_DIR/sessions-snapshot" 2>/dev/null || true
case "\$*" in
*" run --rm "*)
rm -rf "$LAUNCH_DIR/sessions-snapshot"
cp -r "$RUNTIME_DIR/dejima/sessions" "$LAUNCH_DIR/sessions-snapshot" 2>/dev/null || true
;;
esac
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
+513 -220
View File
File diff suppressed because it is too large Load Diff
+11 -6
View File
@@ -27,12 +27,17 @@ list_scripts() {
ls test/*.sh 2>/dev/null || true
} | sort -u | while read -r f; do
[ -f "$f" ] || continue
# Harness plugins are sourced, not executed, so they carry no shebang
# and the discovery below would drop them - silently, which is the
# worst version of that. They are in the trusted computing base, so
# they are included by name and the -s bash below tells shellcheck
# what they are.
case "$f" in bin/harness/*.sh) printf '%s\n' "$f"; continue ;; esac
# Sourced fragments are not executed, so they carry no shebang and the
# discovery below would drop them - silently, which is the worst
# version of that. Harness plugins are one kind; docker/ has two more,
# and they are not incidental: caps-lib.sh holds the drop that decides
# what a session's capability set is, and fixed-domains.sh is the seam
# keeping the DNS allowlist and the ipset allowlist from drifting.
#
# Matched by directory rather than by name, because a list is a thing
# to forget to add to. Everything under docker/ is in the trusted
# computing base; the -s bash below tells shellcheck what these are.
case "$f" in bin/harness/*.sh|docker/*.sh) printf '%s\n' "$f"; continue ;; esac
head -1 "$f" | grep -qE '^#!.*(bash|sh)' && printf '%s\n' "$f"
done
}
+351 -32
View File
@@ -51,9 +51,40 @@ flat_prose() { tr '\n' ' ' < "$1" | tr -d '*`' | tr -s ' '; }
}
@test "entrypoint.sh drops privilege via SANDBOX_USER, not a literal node" {
run grep -E 'gosu node\b' "$SANDBOX_DIR/entrypoint.sh"
run grep -E 'gosu "?node\b|--user="?node\b' "$SANDBOX_DIR/entrypoint.sh"
[ "$status" -ne 0 ]
# Both spellings, because the session's own drop is not the gosu one: it
# goes through capsh, which reduces the bounding set in the same process
# that changes uid. A check that only knew about gosu would keep passing
# while the line it is really about named a literal user.
grep -q 'gosu "$SANDBOX_USER"' "$SANDBOX_DIR/entrypoint.sh"
grep -q -- '--user="$SANDBOX_USER"' "$SANDBOX_DIR/entrypoint.sh"
}
@test "nothing that outlives the entrypoint is started without dropping capabilities" {
# The session and log-shipper.sh are the two processes that survive the
# root phase, and the point of drop_caps is that neither inherits the
# bounding set that phase needed. A `gosu`/bare exec creeping back in for
# either is the regression, and it is invisible at runtime: the container
# comes up and works, holding capabilities nothing asked for.
grep -q 'drop_caps --user="$SANDBOX_USER" -- -c .exec "\$@". dejima \$SANDBOX_CMD' \
"$SANDBOX_DIR/entrypoint.sh" \
|| { echo "the session is not exec'd through drop_caps"; return 1; }
grep -q 'drop_caps .*log-shipper\.sh &' "$SANDBOX_DIR/firewall-up.sh" \
|| { echo "log-shipper.sh is not started through drop_caps"; return 1; }
run grep -nE '^\s*(exec )?gosu "\$SANDBOX_USER" (\$SANDBOX_CMD|sleep)' \
"$SANDBOX_DIR/entrypoint.sh"
[ "$status" -ne 0 ] \
|| { echo "a session exec still goes through gosu: $output"; return 1; }
}
@test "capsh is a declared package, not a dependency of something else" {
# entrypoint.sh refuses to start without it, so an image built without it
# is a container that never appears. It arrived here as a dependency of
# iproute2, which is exactly the kind of thing an unrelated package change
# takes away.
grep -qE '^\s+libcap2-bin \\$' "$SANDBOX_DIR/Dockerfile" \
|| { echo "libcap2-bin is not in the Dockerfile's package list"; return 1; }
}
@test "Playwright is installed and require()-able globally" {
@@ -830,6 +861,67 @@ _declared_config_dirs() {
done
}
@test "every path cleanup() removes is one dejima bound, never one the environment did" {
# A different invariant from the test above, and the `${NAME:-}` form that
# satisfies that one is no help here. Guarding against UNBOUND stops set -u
# aborting the trap; it does nothing about a name that arrives from the
# caller's environment already carrying a value, which the trap then
# removes. bin/dejima's own comment states this - "every name cleanup()
# removes but does not itself create belongs here" - and until the run-id
# change nothing checked it, so three names had drifted out of it, one of
# them an `rm -rf` argument.
#
# An exit between the trap and a name's first assignment is what makes it
# reachable. SANDBOX_GITCONFIG_DIR is the one name assigned below the trap,
# and its window holds no exit; anything else below the trap is a finding.
local d="$REPO_ROOT/bin/dejima"
local trap_line; trap_line="$(grep -n '^trap cleanup EXIT' "$d" | cut -d: -f1)"
[ -n "$trap_line" ]
# Only the rm lines, and only their arguments - cleanup() reads plenty it
# never deletes, and those are the other test's business.
local removed
removed="$(sed -n '/^cleanup() {/,/^}/p' "$d" \
| grep -E '^[[:space:]]*rm -' \
| grep -oE '\$\{?[A-Za-z_][A-Za-z0-9_]*' | tr -d '${' | sort -u)"
[ -n "$removed" ] || { echo "found no rm arguments in cleanup() at all"; return 1; }
local v asg
for v in $removed; do
[ "$v" != "SANDBOX_GITCONFIG_DIR" ] || continue
asg="$(grep -nE "^[[:space:]]*(export )?$v=" "$d" | head -1 | cut -d: -f1)"
[ -n "$asg" ] || { echo "cleanup() removes \$$v, which is never assigned"; return 1; }
[ "$asg" -lt "$trap_line" ] || {
echo "cleanup() removes \$$v, first assigned at line $asg - below the trap at"
echo "$trap_line, so an exit in between removes whatever the environment held."
return 1
}
done
}
@test "the one name assigned below the trap still has no exit to fire on" {
# SANDBOX_GITCONFIG_DIR is exempted above on a claim about its window, not
# on a claim about the name. The claim is what needs checking: an `exit`
# appearing between the trap and that assignment turns the exemption into
# the bug it is exempted from, and nothing about that edit would look like
# it touched this.
local d="$REPO_ROOT/bin/dejima"
local trap_line asg
trap_line="$(grep -n '^trap cleanup EXIT' "$d" | cut -d: -f1)"
asg="$(grep -nE '^[[:space:]]*SANDBOX_GITCONFIG_DIR=' "$d" | head -1 | cut -d: -f1)"
[ -n "$trap_line" ] && [ -n "$asg" ]
[ "$asg" -gt "$trap_line" ] \
|| skip "SANDBOX_GITCONFIG_DIR is now assigned above the trap; drop the exception"
run awk -v a="$trap_line" -v b="$asg" 'NR>a && NR<b && /^[[:space:]]*exit [0-9]/ {print NR": "$0}' "$d"
[ -z "$output" ] || {
echo "an exit now sits between the trap at $trap_line and the assignment at $asg:"
echo "$output"
echo "so bind SANDBOX_GITCONFIG_DIR above the trap like the rest."
return 1
}
}
@test "docker-compose.yml never hardcodes /home/node - the container home is host-parametrized" {
# ${HOME:-/home/node} (the SANDBOX_HOME build arg's own default value,
# mirroring the Dockerfile's default) is the one legitimate exception -
@@ -1545,6 +1637,41 @@ _reference_has_key() { # <doc> <section> <leaf>
done
}
@test "CI runs every confined suite, not just the ones someone remembered" {
# The matrix in tests.yml is enumerated by hand, and test/run-confined.sh
# discovers suites from the directory. So a suite added to the tree runs
# locally and never runs in CI - a green set of checks covering less than
# it appears to, which is this repo's least acceptable failure and one
# nothing was watching for.
#
# Which suites are confined is run-confined.sh's own declaration, read
# rather than restated: runtime and ci-runner are privileged and have their
# own jobs elsewhere in this workflow, and a list here would be a second
# place for that to be true.
local matrix privileged f n
matrix="$(sed -n '/^ suite:/,/^ name:/p' "$REPO_ROOT/.gitea/workflows/tests.yml" \
| grep -oE '^ - [a-z-]+' | sed 's/^ - //')"
[ -n "$matrix" ] || { echo "found no suite matrix in tests.yml"; return 1; }
privileged="$(grep -oE '^PRIVILEGED=" [a-z -]+"' "$REPO_ROOT/test/run-confined.sh" \
| sed -E 's/^PRIVILEGED=" //; s/ "$//')"
[ -n "$privileged" ] || { echo "could not read the privileged list"; return 1; }
for f in "$REPO_ROOT"/test/*.bats; do
n="$(basename "$f" .bats)"
[[ " $privileged " == *" $n "* ]] && continue
grep -qx "$n" <<< "$matrix" \
|| { echo "test/$n.bats is a confined suite and CI never runs it"; return 1; }
done
# And the other direction, or the matrix names a job that fails on a
# missing file rather than covering anything.
while read -r n; do
[ -n "$n" ] || continue
[ -f "$REPO_ROOT/test/$n.bats" ] \
|| { echo "CI runs a suite that does not exist: $n"; return 1; }
done <<< "$matrix"
}
@test "the changed lane's map names only suites that exist" {
# The map is what --changed selects with, so a suite renamed out from under
# it stops being selected silently: the run still says green, having
@@ -1605,6 +1732,92 @@ _reference_has_key() { # <doc> <section> <leaf>
done < <(lib provider_list harness)
}
@test "shellcheck covers every docker/ script, sourced fragments included" {
# Same failure as the plugins above and a worse place for it: caps-lib.sh
# is sourced, so it has no shebang, and it holds the drop that decides what
# capabilities a session runs with. fixed-domains.sh is the seam holding
# the DNS allowlist and the ipset allowlist together.
#
# Enumerated from the tree rather than from a list here, so a fragment
# added tomorrow is covered without anyone remembering this test.
local f
for f in "$SANDBOX_DIR"/*.sh; do
[ -e "$f" ] || continue
bash "$REPO_ROOT/test/shellcheck.sh" --list | grep -qx "docker/${f##*/}" \
|| { echo "docker/${f##*/} is not shellchecked"; return 1; }
done
}
@test "the filter entrypoint brings the firewall up, and lets a failure stop it" {
# Exactly one caller, and it is the one in the container that holds the
# capabilities. A second caller in the session's container would try to
# install rules in a namespace that already has them, and start a second
# dnsmasq on an address the first one is already bound to.
grep -qE '^/usr/local/bin/firewall-up\.sh$' "$SANDBOX_DIR/filter-entrypoint.sh" \
|| { echo "filter-entrypoint.sh does not call firewall-up.sh, or guards the call"; return 1; }
# Code, not prose: the entrypoint may well go on mentioning the script in a
# comment, and a check that could not tell the two apart would be one
# nobody could satisfy while explaining why the call is absent.
run bash -c "grep -vE '^[[:space:]]*#' '$SANDBOX_DIR/entrypoint.sh' | grep -n 'firewall-up'"
[ "$status" -ne 0 ] \
|| { echo "the session's entrypoint still brings the firewall up: $output"; return 1; }
# Unguarded is the point: under `set -e` a firewall that failed to come up
# takes the container with it, and the readiness marker below it is never
# written, so bin/dejima refuses the launch. `|| true`, an `if`, or a
# background `&` would each turn that into a session running unfiltered.
run grep -nE 'firewall-up\.sh.*(\|\||&|;)|(if|while).*firewall-up\.sh' \
"$SANDBOX_DIR/filter-entrypoint.sh"
[ "$status" -ne 0 ] \
|| { echo "the firewall-up call is guarded or backgrounded: $output"; return 1; }
# And the marker is written after it, never before: it is what the launcher
# waits on, so writing it first would announce a filter that is not there.
local up_at ready_at
up_at="$(grep -n '^/usr/local/bin/firewall-up\.sh$' "$SANDBOX_DIR/filter-entrypoint.sh" | cut -d: -f1)"
ready_at="$(grep -n 'dejima-firewall-ready' "$SANDBOX_DIR/filter-entrypoint.sh" | grep -v '^ *#' | tail -1 | cut -d: -f1)"
[ -n "$up_at" ] && [ -n "$ready_at" ] && [ "$up_at" -lt "$ready_at" ] \
|| { echo "the readiness marker is not written after the firewall comes up"; return 1; }
}
@test "the session container points itself at the namespace's resolver" {
# /etc/resolv.conf is per container even when the network namespace is
# shared - measured, and the reason this line has to exist at all.
# dns-proxy.sh repoints the filter container's copy; Docker generated this
# container's pointing straight at its embedded resolver.
#
# Left alone, every name the session looks up goes around dnsmasq and the
# DNS allowlist applies to nothing - while the packet filter still holds,
# so nothing fails and nothing says so.
grep -q 'echo "nameserver 127.0.0.1" > /etc/resolv.conf' "$SANDBOX_DIR/entrypoint.sh" \
|| { echo "the session container never points itself at the allowlisting resolver"; return 1; }
# The same address dns-proxy.sh binds and writes for itself, or the two
# halves of one arrangement disagree about where the resolver is.
grep -q 'echo "nameserver 127.0.0.1" > /etc/resolv.conf' "$SANDBOX_DIR/dns-proxy.sh" \
|| { echo "dns-proxy.sh no longer writes that address; the two have drifted"; return 1; }
}
@test "dejima-allow changes the allowlist where it lives, and checks where it matters" {
# The dnsmasq and the ipsets are in the filter container. Every mutation
# has to go there; the reachability probe has to stay in the session's
# container, because that is the container whose reachability the command
# claims to have changed. A probe that ran in the filter container would
# pass on an arrangement where the session still could not reach the host.
local a="$REPO_ROOT/bin/dejima-allow"
grep -q 'docker exec -u root "$filter" bash -c' "$a" \
|| { echo "the dnsmasq reload does not target the filter container"; return 1; }
grep -q 'docker exec -u root "$filter" ipset add' "$a" \
|| { echo "the ipset add does not target the filter container"; return 1; }
grep -q 'docker exec "$container" curl' "$a" \
|| { echo "the reachability check no longer runs in the session's container"; return 1; }
# Derived from Docker rather than recorded, so nothing has to be kept in
# step with it.
grep -q "HostConfig.NetworkMode" "$a" \
|| { echo "the filter container is not derived from the session's namespace"; return 1; }
}
@test "an unparseable settings.json stops the launch instead of being ignored" {
# Claude Code ignores a settings.json it cannot parse, wholesale and with
# a startup warning only - so the session would run without the
@@ -2447,6 +2660,27 @@ _install_forge_bins() {
rm -rf "$fake" "$dest"
}
@test "the release pass refuses a base that is behind its own upstream" {
# Everything the pass prints is derived from base..head - which manual
# checks apply, which config keys a rollback would refuse - so a base
# behind the real one reports on a different release, confidently. Two
# passes ran against a local `main` 260 commits behind origin/main and
# named keys and a domain that had shipped releases earlier.
grep -q 'behind its own upstream\|behind \$base_upstream\|commit(s) behind' \
"$REPO_ROOT/test/release-pass.sh" \
|| { echo "the staleness check is gone"; return 1; }
# The default is the tracking ref, not the local branch, so the ordinary
# invocation cannot pick up a stale one in the first place.
grep -q 'base=origin/main' "$REPO_ROOT/test/release-pass.sh" \
|| { echo "the default base is not the tracking ref"; return 1; }
# And it exits rather than warning: a gate that says something is wrong
# and then reports anyway is one whose output still gets pasted into a
# release PR.
sed -n '/is .* behind /,/^fi$/p' "$REPO_ROOT/test/release-pass.sh" \
| grep -q 'exit 1' \
|| { echo "a stale base is warned about rather than refused"; return 1; }
}
@test "the release pass names every area whose check is manual" {
# This list is the only record of what the test suites cannot see, and it
# is the kind of list that rots by omission: an area gets a manual check
@@ -2464,8 +2698,18 @@ _install_forge_bins() {
# And it must say what it could not run, rather than reporting a pass.
grep -q 'did NOT run' "$script" \
|| { echo "a gate that could not run is reported as passing"; return 1; }
grep -q 'queued is not a job that passed' "$script" \
|| { echo "nothing warns that a queued CI job is not a green one"; return 1; }
# Both halves of the rule, because reporting only one of them is how this
# goes wrong in each direction: a queued job read as a pass, or a green one
# hedged as though it might be queued.
#
# Matched against the script flattened to one line, so rewrapping a message
# does not fail a test about what it says.
local flat_script
flat_script="$(tr '\n' ' ' < "$script" | tr -s ' ')"
[[ "$flat_script" == *"queued one is yellow or grey"* ]] \
|| { echo "nothing says a queued CI job is not a passing one"; return 1; }
[[ "$flat_script" == *"GREEN means"* ]] \
|| { echo "nothing says what green means, so a green run gets hedged"; return 1; }
}
@test "docs/release.md exists and is pointed at from where a release starts" {
@@ -4567,43 +4811,103 @@ STUB
grep -qE '^\s*- ALL\s*$' "$c" || { echo "cap_drop does not drop ALL"; return 1; }
grep -qE '^\s*- no-new-privileges:true\s*$' "$c" \
|| { echo "no-new-privileges is gone"; return 1; }
# The two the firewall needs are still declared, or the container cannot
# bring its own egress control up at all.
local cap
for cap in NET_ADMIN NET_RAW; do
grep -qE "^\s*- $cap\s*$" "$c" || { echo "$cap is no longer added"; return 1; }
done
# SETPCAP is declared, and it is the one whose use is not a line of the
# startup sequence: it is spent taking the rest of this list away again
# before the session starts. Declared without that, it is a capability
# nothing uses - so the two are asserted together, in both directions.
grep -qE '^\s*- SETPCAP\s*$' "$c" \
|| { echo "SETPCAP is gone, so the bounding set cannot be emptied"; return 1; }
grep -q 'exec capsh --drop=all' "$SANDBOX_DIR/caps-lib.sh" \
|| { echo "SETPCAP is declared but nothing drops with it"; return 1; }
# And nothing the entrypoint does not use has been added back.
for cap in SYS_ADMIN MKNOD SETPCAP SETFCAP SYS_CHROOT SYS_PTRACE; do
local cap
for cap in SYS_ADMIN MKNOD SETFCAP SYS_CHROOT SYS_PTRACE; do
run grep -nE "^\s*- $cap\s*$" "$c"
[ "$status" -ne 0 ] || { echo "$cap is declared and nothing uses it"; return 1; }
done
}
@test "the runtime suite's session container reads the capability floor rather than copying it" {
# Only the container that stands in for a real session. The suite also
# starts ad-hoc containers to exercise the firewall in isolation, and those
# legitimately pass NET_ADMIN/NET_RAW by hand - they are testing a
# component, not the posture a session runs under.
@test "the session container declares no network capability, and the filter declares them" {
# The arrangement's whole point, asserted where each list is written.
# Capabilities are per process, so a session container that declares
# NET_ADMIN can alter the filter bounding it however carefully everything
# else is arranged - and would do so silently, since the launch keeps
# working either way.
#
# The one in setup_file is different: a hand-written list there would drift
# from the compose file, and the container under test would stop being the
# container a session gets.
# KILL is NOT in this list, and that is the point of the exception: the
# filter container needs it for dnsmasq, and the session container needs it
# for tini, which forwards signals to a child whose uid it does not match.
# Removing it from here once already shipped a session that died on a
# terminal resize.
#
# Both directions for each, or "the session declares none of them" is
# satisfied just as well by a launch with no filter container to hold them.
local c="$SANDBOX_DIR/docker-compose.yml" cap
for cap in NET_ADMIN NET_RAW; do
run grep -nE "^\s*- $cap\s*$" "$c"
[ "$status" -ne 0 ] \
|| { echo "the session container declares $cap, so it can reach the filter"; return 1; }
grep -q -- "--cap-add $cap" "$REPO_ROOT/bin/dejima" \
|| { echo "$cap left the session container without arriving at the filter"; return 1; }
done
# KILL is the exception, declared on both: tini needs it here to forward a
# signal to the session, dnsmasq needs it there to be signalled.
grep -qE '^\s*- KILL\s*$' "$c" \
|| { echo "the session container cannot signal its own session - see tini"; return 1; }
grep -q -- '--cap-add KILL' "$REPO_ROOT/bin/dejima" \
|| { echo "the filter container cannot signal dnsmasq"; return 1; }
# The same split for the one sysctl: a property of a namespace the session
# container does not own, so it is set by the container that does.
run grep -nE '^\s*sysctls:' "$c"
[ "$status" -ne 0 ] \
|| { echo "the session container declares a sysctl for a namespace it does not own"; return 1; }
grep -q -- '--sysctl net.ipv4.ip_unprivileged_port_start=0' "$REPO_ROOT/bin/dejima" \
|| { echo "nothing sets the unprivileged port floor the TLS terminator needs"; return 1; }
}
@test "the runtime suite's containers read both capability floors rather than copying them" {
# start_pair builds the pair a launch actually starts, so it is where both
# floors have to come from the code that declares each: the session's from
# docker-compose.yml, the filter's from bin/dejima's own `docker run`. A
# hand-written list in either place would drift, and the containers under
# test would stop being the containers a launch gets.
#
# The suite still starts two containers by hand - one that must refuse to
# start, one that must abort at a check before the network matters. Both
# say so at the call site; neither stands in for a session.
local rt="$REPO_ROOT/test/runtime.bats"
grep -q '^compose_cap_add()' "$rt" \
|| { echo "runtime.bats no longer derives the capability list"; return 1; }
|| { echo "runtime.bats no longer derives the session capability list"; return 1; }
grep -q '^launcher_filter_cap_add()' "$rt" \
|| { echo "runtime.bats no longer derives the filter capability list"; return 1; }
local pair
pair="$(sed -n '/^start_pair()/,/^}/p' "$rt")"
[ -n "$pair" ] || { echo "no start_pair in runtime.bats"; return 1; }
grep -q 'compose_cap_add' <<< "$pair" \
|| { echo "the session container no longer derives its capabilities"; return 1; }
grep -q 'launcher_filter_cap_add' <<< "$pair" \
|| { echo "the filter container no longer derives its capabilities"; return 1; }
# Both halves, twice each: an empty set to start from, and no way back up.
[ "$(grep -c -- '--cap-drop=ALL' <<< "$pair")" = 2 ] \
|| { echo "a container in the pair does not start from an empty set"; return 1; }
[ "$(grep -c -- '--security-opt=no-new-privileges:true' <<< "$pair")" = 2 ] \
|| { echo "a container in the pair runs without no-new-privileges"; return 1; }
run grep -nE -- '--cap-add=(NET_ADMIN|NET_RAW|CHOWN|SETUID)' <<< "$pair"
[ "$status" -ne 0 ] \
|| { echo "a capability is hardcoded in the pair:"; echo "$output"; return 1; }
# And the fixture that stands in for a session goes through it, or the
# derivation above is true of a helper nothing calls.
local setup_block
setup_block="$(sed -n '/^setup_file()/,/^}/p' "$rt")"
[ -n "$setup_block" ] || { echo "no setup_file in runtime.bats"; return 1; }
grep -q 'compose_cap_add' <<< "$setup_block" \
|| { echo "the session container no longer derives its capabilities"; return 1; }
grep -q -- '--cap-drop=ALL' <<< "$setup_block" \
|| { echo "the session container does not start from an empty set"; return 1; }
grep -q -- '--security-opt=no-new-privileges:true' <<< "$setup_block" \
|| { echo "the session container does not run with no-new-privileges"; return 1; }
run grep -nE -- '--cap-add=(NET_ADMIN|NET_RAW|CHOWN|SETUID)' <<< "$setup_block"
[ "$status" -ne 0 ] \
|| { echo "a capability is hardcoded for the session container:"; echo "$output"; return 1; }
grep -q 'start_pair ""' <<< "$setup_block" \
|| { echo "setup_file no longer starts the pair a launch starts"; return 1; }
}
@test "dejima writes host state under one root, not a new base directory per file" {
@@ -6290,12 +6594,19 @@ q
grep -q 'echo "\$openssl_err" >&2' "$SANDBOX_DIR/entrypoint.sh"
}
@test "compose passes the transport through and lets an unprivileged process bind :443" {
@test "the transport is passed through, and an unprivileged process can bind :443" {
grep -qE '^\s*SANDBOX_CREDENTIAL_TRANSPORT:\s*\$\{SANDBOX_CREDENTIAL_TRANSPORT:-\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
# Docker already defaults this to 0, but the terminator runs as the
# unprivileged sandbox user: pinning it means a future default change
# fails at launch instead of silently breaking the credential path.
grep -qE '^\s*net\.ipv4\.ip_unprivileged_port_start:\s*0\s*$' "$SANDBOX_DIR/docker-compose.yml"
#
# Pinned on the filter container, because it is a property of a network
# namespace and that is the container that owns one. The terminator binds
# in the session container, which joins that namespace - so this is
# asserted here as a declaration and in runtime.bats by reading the value
# back from the container that has to have it.
grep -q -- '--sysctl net.ipv4.ip_unprivileged_port_start=0' "$REPO_ROOT/bin/dejima" \
|| { echo "nothing pins the unprivileged port floor"; return 1; }
}
@test "compose does not pin api.anthropic.com to loopback unconditionally" {
@@ -6515,6 +6826,13 @@ _provider_proxied_domain() {
# of which forge is configured, not a coupling to one. Same
# feature the "GitHub read-only" prose exception below names.
matches="$(grep -vE 'github\.com' <<< "$matches" || true)"
# `network_mode: none` is Docker's own value for "no network",
# which collides with a shipped forge's name and with nothing
# else about it. Scoped to the key AND the value, not to either
# alone: excluding every `network_mode` line would hide a real
# forge name written on one, and excluding the bare word would
# hide it anywhere in the file.
matches="$(grep -vE 'network_mode: .*none' <<< "$matches" || true)"
;;
*/hooks/sandbox-context.sh)
# A citation of Anthropic's own devcontainer, not a forge.
@@ -6993,7 +7311,8 @@ _provider_proxied_domain() {
# shipping went, which is worth keeping.
run bash -c "grep -vE '^[[:space:]]*#' '$SANDBOX_DIR/log-shipper.sh' | grep -nE 'projects|[.]jsonl|transcript'"
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
grep -q 'neither session-writable' "$SANDBOX_DIR/entrypoint.sh"
# And the claim that makes root safe, where the shipper is now started.
grep -q 'neither session-writable' "$SANDBOX_DIR/firewall-up.sh"
}
@test ".dejima.md says to cut what the code replaced, not just how it was found" {