Moves development to main: 84 commits across 12 merges, 54 files, ~5800 insertions. Two clean-room rewrites of the parts of this project that came from Anthropic's reference dev container, and the fixes that seven review rounds turned up.
What is in it
The egress firewall, rewritten (#341).init-firewall.sh and dns-proxy.sh are gone; docker/egress-policy replaces them. One nftables table loaded in a single transaction rather than a sequence of iptables calls, so the namespace is never half-filtered. Two permit classes, forge and open, with the restriction attached to the destination rather than to the path it was reached by. IPv6 is refused as a family, first rule in the chain, checked at startup rather than assumed.
The container image, rewritten (#340). The shell is bash, configured by a snippet in the image rather than a framework fetched at build time. The npm global prefix moved to /opt/node-tools, with a root-level node_modules symlink so ESM resolves as well as CommonJS. Every setuid bit and file capability is stripped as the last instruction of the image and again at the end of a derived per-project layer, and a survivor fails the build.
The provenance note is gone from the README and from hooks/sandbox-context.sh. That removal is a claim about the whole repository, and the measurement behind it is on #340: 23 shared lines with the reference, all shell keywords, Debian package names and single-spelling Docker idioms, and zero shared comments. A static.bats test now fails if the tree reacquires a citation of that kind.
A bare label could be approved as a domain, and cannot now. On current main, valid_domain accepts a single label and allowed-domains is validated with it, so dejima-allow --global com is accepted, becomes a server=/com/ line, and dnsmasq suffix-matches every .com name into the open class, which carries no port restriction. valid_permitted_domain now requires two labels, at the grant path. This is the most consequential single change in the release and it landed late, via #349, #351 and #355 — the route was not straight, and #355's reasoning is the part worth keeping: applying the rule on the read path made an upgrading operator's existing store fail with a "corrupt approval entry" message, which is an upgrade presenting as tampering with no remove verb to recover from. Tightening belongs where granting happens.
The credential proxy's log left the directory the session mounts (#359). The socket directory is bind-mounted read-only into the session so the socket is reachable, and the log lived inside it. The log holds what the proxy learned from the provider - statuses, error-body prefixes from the token endpoint - which is exactly what the container-facing message withholds by design, and the session user's uid is the host user's, so a 0644 file inside a 0700 directory was readable from in there. This is not a regression: main has the same arrangement. It became a stated contradiction only when this release wrote the rule down, and closing it is the point of writing rules down.
Every permitted name is checked into its class's address set at boot (#356). That was previously asserted for one destination, the forge metadata host, which is the one hardest to get wrong. The class an entry carries, the resolver line built from it, and the set the ports are keyed to are three separate statements that can each be right about a different set, and reading the code cannot tell them apart — every defect of that shape in this release reached a green suite. A name that reaches both class lists is also recorded once now, in the narrower one.
Fixes from the review rounds:#346 (root's shell taking its history from a volume the session user owns; the boot path not asking a domain's class), #349 (two boot-stopping regressions introduced by that fix), #351 (reverting dotless acceptance, which could not work and granted a suffix, plus case-folding domain comparisons), #353 (the forge tools telling a sandbox to create a token file it must not create), #354 (a documentation pass that found five drifted claims), #355 and #356 as above, #358 (an expired credential now names the login that fixes it, in the session and in the exit audit).
Rollback
Two things make this not cleanly reversible once adopted.
It adds one config key, core.internal_domains. An operator who sets it and rolls back to the current main gets a launch that refuses to start, because an unknown key fails validation. Remove the key first.
And it makes a previously valid configuration refuse to launch. A permitted destination must now be fully qualified — an approved domain, [core] internal_domains, a forge's ssh_host or api, a backend's base_url, [logging] loki_url. On main a single-label value worked: that resolver forwarded to Docker's embedded resolver, which answers for container and compose service names. An operator upgrading with loki_url = http://loki:3100 gets a refused launch naming the key, rather than a sandbox that will not start — better, but it is a break, and it is the more likely of the two to be met. #352 records the decision and what supporting single-label names again would take.
.dejima/allowed-domains is byte-identical to main, so nothing here widens what a hardened CI runner may reach.
What was run
All ten confined suites and shellcheck, green on this merge commit.
test/runtime.bats has run and passed on this commit. All fourteen checks on 6522ad5 are green, tests / privileged among them.
That claim has now been wrong twice in this description's life, both times in the reader's favour, because the branch moved under it and the sentence did not. If more lands before this merges, check the checks rather than this paragraph.
Exercised by hand, on a live session of this candidate
A sandbox running this exact tip was used to check the things only a real session shows. It came up, which is the strongest single result: the session starts only after the filter container's readiness file, which is written only after egress-policy apply returns, which runs every boot self-check - including the one added in #356 that refuses to start when a permitted name's address is missing from its class's set. A new fatal check did not false-positive on a real operator's configuration.
From inside it:
GitHub over 443 http 200
GitHub over 22 connection refused
a destination nothing permitted refused
a name nothing permitted does not resolve
the credential proxy's mount holds the socket and no log
That last line is #359's fix confirmed end to end. The directory is listable from the session, which is exactly what made the log readable while it lived there.
A Cline session against OpenRouter was also run on this candidate, and worked. That is the other harness and a backend that is not Anthropic, so between the two runs both harnesses have now been launched against a real model on this tip - and the second one exercises a different path through the release: the npm prefix the image rewrite moved, and a backend reached by proxied domain rather than by the OAuth refresh the first one uses.
Not covered by either: the credential proxy's own failure paths, the exit audit (which prints when a session ends), and everything below.
What was run by hand
The maintainer exercised the manual list against this candidate, and it worked: a real session over its full life, the refusals tried by the route an agent would take, Ctrl-Z and fg on a real session rather than the stand-in the tests use, both harnesses against real models, and the web front door attached and detached. That is the second half of this release's gate, and it is the half nothing automated stands in for.
What was not run, and will not be
A pull request through the GitHub forge. It has never been run end to end, here or before, and it is not going to be for this release: the maintainer develops nothing on GitHub, so there is no real repository to try it against. docs/providers.md says the same where someone choosing a forge will meet it, and this release does not change that.
What that leaves unverified is the github forge's own path - gh pr create through the wrapper, the body that arrives, and the push grant opening port 22 in a session that actually pushes. The firewall half of it is covered: runtime.bats asserts port 22 is refused by default, that ALLOW_GITHUB_PUSH=1 opens it, and that a GitHub name declared as an extra domain stays port-restricted. What is untested is the forge CLI on the other side of that port.
This is a stated decision rather than an omission, which is the distinction that matters to whoever reads it later: an unverified path that is named is one somebody can reach for when a bug turns up.
Deferred out of this release
#345 carries three items, knowingly left: a boot self-check whose retry does not cover the failure it was written for, a non-atomic rollback in permit_address, and several tests asserting field names where they mean values. #350 carries three host-side items of the same weight. #357 records that the privileged CI job trips GitHub's secondary rate limit on /meta, which is a re-run rather than a defect. None of them widens anything; they are named here because this description is what a reader of main's history will find.
**Summary generated by LLM agent**
Moves `development` to `main`: 84 commits across 12 merges, 54 files, ~5800 insertions. Two clean-room rewrites of the parts of this project that came from Anthropic's reference dev container, and the fixes that seven review rounds turned up.
## What is in it
**The egress firewall, rewritten (#341).** `init-firewall.sh` and `dns-proxy.sh` are gone; `docker/egress-policy` replaces them. One `nftables` table loaded in a single transaction rather than a sequence of `iptables` calls, so the namespace is never half-filtered. Two permit classes, forge and open, with the restriction attached to the destination rather than to the path it was reached by. IPv6 is refused as a family, first rule in the chain, checked at startup rather than assumed.
**The container image, rewritten (#340).** The shell is bash, configured by a snippet in the image rather than a framework fetched at build time. The npm global prefix moved to `/opt/node-tools`, with a root-level `node_modules` symlink so ESM resolves as well as CommonJS. Every setuid bit and file capability is stripped as the last instruction of the image and again at the end of a derived per-project layer, and a survivor fails the build.
**The provenance note is gone** from the README and from `hooks/sandbox-context.sh`. That removal is a claim about the whole repository, and the measurement behind it is on #340: 23 shared lines with the reference, all shell keywords, Debian package names and single-spelling Docker idioms, and zero shared comments. A `static.bats` test now fails if the tree reacquires a citation of that kind.
**A bare label could be approved as a domain, and cannot now.** On current `main`, `valid_domain` accepts a single label and `allowed-domains` is validated with it, so `dejima-allow --global com` is accepted, becomes a `server=/com/` line, and dnsmasq suffix-matches every `.com` name into the open class, which carries no port restriction. `valid_permitted_domain` now requires two labels, at the grant path. This is the most consequential single change in the release and it landed late, via #349, #351 and #355 — the route was not straight, and #355's reasoning is the part worth keeping: applying the rule on the *read* path made an upgrading operator's existing store fail with a "corrupt approval entry" message, which is an upgrade presenting as tampering with no `remove` verb to recover from. Tightening belongs where granting happens.
**The credential proxy's log left the directory the session mounts (#359).** The socket directory is bind-mounted read-only into the session so the socket is reachable, and the log lived inside it. The log holds what the proxy learned from the provider - statuses, error-body prefixes from the token endpoint - which is exactly what the container-facing message withholds by design, and the session user's uid is the host user's, so a 0644 file inside a 0700 directory was readable from in there. **This is not a regression: `main` has the same arrangement.** It became a stated contradiction only when this release wrote the rule down, and closing it is the point of writing rules down.
**Every permitted name is checked into its class's address set at boot (#356).** That was previously asserted for one destination, the forge metadata host, which is the one hardest to get wrong. The class an entry carries, the resolver line built from it, and the set the ports are keyed to are three separate statements that can each be right about a different set, and reading the code cannot tell them apart — every defect of that shape in this release reached a green suite. A name that reaches both class lists is also recorded once now, in the narrower one.
**Fixes from the review rounds:** #346 (root's shell taking its history from a volume the session user owns; the boot path not asking a domain's class), #349 (two boot-stopping regressions introduced by that fix), #351 (reverting dotless acceptance, which could not work and granted a suffix, plus case-folding domain comparisons), #353 (the forge tools telling a sandbox to create a token file it must not create), #354 (a documentation pass that found five drifted claims), #355 and #356 as above, #358 (an expired credential now names the login that fixes it, in the session and in the exit audit).
## Rollback
**Two things make this not cleanly reversible once adopted.**
It adds one config key, `core.internal_domains`. An operator who sets it and rolls back to the current `main` gets a launch that refuses to start, because an unknown key fails validation. Remove the key first.
And it makes a *previously valid* configuration refuse to launch. A permitted destination must now be fully qualified — an approved domain, `[core] internal_domains`, a forge's `ssh_host` or `api`, a backend's `base_url`, `[logging] loki_url`. On `main` a single-label value worked: that resolver forwarded to Docker's embedded resolver, which answers for container and compose service names. An operator upgrading with `loki_url = http://loki:3100` gets a refused launch naming the key, rather than a sandbox that will not start — better, but it is a break, and it is the more likely of the two to be met. #352 records the decision and what supporting single-label names again would take.
`.dejima/allowed-domains` is byte-identical to `main`, so nothing here widens what a hardened CI runner may reach.
## What was run
All ten confined suites and `shellcheck`, green on this merge commit.
**`test/runtime.bats` has run and passed on this commit.** All fourteen checks on `6522ad5` are green, `tests / privileged` among them.
That claim has now been wrong twice in this description's life, both times in the reader's favour, because the branch moved under it and the sentence did not. If more lands before this merges, check the checks rather than this paragraph.
## Exercised by hand, on a live session of this candidate
A sandbox running this exact tip was used to check the things only a real session shows. It came up, which is the strongest single result: the session starts only after the filter container's readiness file, which is written only after `egress-policy apply` returns, which runs every boot self-check - including the one added in #356 that refuses to start when a permitted name's address is missing from its class's set. A new fatal check did not false-positive on a real operator's configuration.
From inside it:
```
GitHub over 443 http 200
GitHub over 22 connection refused
a destination nothing permitted refused
a name nothing permitted does not resolve
the credential proxy's mount holds the socket and no log
```
That last line is #359's fix confirmed end to end. The directory is listable from the session, which is exactly what made the log readable while it lived there.
A Cline session against OpenRouter was also run on this candidate, and worked. That is the other harness and a backend that is not Anthropic, so between the two runs both harnesses have now been launched against a real model on this tip - and the second one exercises a different path through the release: the npm prefix the image rewrite moved, and a backend reached by proxied domain rather than by the OAuth refresh the first one uses.
Not covered by either: the credential proxy's own failure paths, the exit audit (which prints when a session ends), and everything below.
## What was run by hand
The maintainer exercised the manual list against this candidate, and it worked: a real session over its full life, the refusals tried by the route an agent would take, Ctrl-Z and `fg` on a real session rather than the stand-in the tests use, both harnesses against real models, and the web front door attached and detached. That is the second half of this release's gate, and it is the half nothing automated stands in for.
## What was not run, and will not be
**A pull request through the GitHub forge.** It has never been run end to end, here or before, and it is not going to be for this release: the maintainer develops nothing on GitHub, so there is no real repository to try it against. `docs/providers.md` says the same where someone choosing a forge will meet it, and this release does not change that.
What that leaves unverified is the `github` forge's own path - `gh pr create` through the wrapper, the body that arrives, and the push grant opening port 22 in a session that actually pushes. The firewall half of it is covered: `runtime.bats` asserts port 22 is refused by default, that `ALLOW_GITHUB_PUSH=1` opens it, and that a GitHub name declared as an extra domain stays port-restricted. What is untested is the forge CLI on the other side of that port.
This is a stated decision rather than an omission, which is the distinction that matters to whoever reads it later: an unverified path that is named is one somebody can reach for when a bug turns up.
## Deferred out of this release
#345 carries three items, knowingly left: a boot self-check whose retry does not cover the failure it was written for, a non-atomic rollback in `permit_address`, and several tests asserting field names where they mean values. #350 carries three host-side items of the same weight. #357 records that the privileged CI job trips GitHub's secondary rate limit on `/meta`, which is a re-run rather than a defect. None of them widens anything; they are named here because this description is what a reader of `main`'s history will find.
The list is what a session is expected to have without installing anything.
Sorting it means adding one is a one-line diff rather than a judgement about
where it belongs.
bash-completion arrives here for the session shell, which the next commit
configures in this image rather than through an installer fetched at build
time.
TZ was accepted and exported without being looked at, so a zone the zoneinfo
database does not have left the container on UTC with nothing said. A log line
an hour out of step with the operator's own is harder to distrust than a build
that refused, so refuse. /etc/localtime is set alongside the environment
variable, since most things read the system zone rather than TZ.
The prefix has to be writable by the sandbox user, which has no sudo and never
will, so whatever directory it is gets chowned to that user. Under
/usr/local/share that means handing out ownership inside a directory other
things already live in - Neovim unpacks its runtime into /usr/local/share/nvim
- and the old code was careful to scope the recursion for exactly that reason.
A prefix of its own removes the thing the care was protecting against:
"owned by the sandbox user" and "put here by npm" now describe the same files.
/usr/local/share itself is no longer chowned to the session user either. Nothing
writes there any more, and a session owning a system directory is a privilege it
had no use for.
NODE_PATH is dropped rather than moved. It only ever fed the CommonJS resolver,
so `import` of a global package failed while `require` of the same package
worked - which is why the symlink at /node_modules was added underneath it. The
symlink is what both resolvers actually find, and it covers the case NODE_PATH
covered as well as the one it did not.
The prefix is a contract: harness manifests name an absolute path to the CLI
they launch, so harnesses/*/harness.conf and the example in docs/providers.md
move with it.
/var/opt is where an add-on package's variable data belongs; a directory at the
filesystem root is not. The volume keeps its name, so the mount lands on the
same data and an operator's existing history carries over rather than being
orphaned under a path nothing mounts any more.
It is also chowned to the sandbox user's group and given 0700 rather than left
at the default. Docker seeds a fresh named volume from whatever is at the mount
path in the image, permissions included, so this is what the volume starts life
with.
The shell was zsh, set up by a release of a third-party installer fetched over
the network during the build and then executed. That is a supply-chain surface
in an image whose whole purpose is to be a boundary: whatever it installs runs
in every sandbox on the host, forever, and nothing reviews it. The care around
the download - two steps, so a failed fetch fails the build rather than
silently installing nothing - was real, and it made the fetch reliable without
making it reviewable.
So the shell is now the base image's own bash, which is already what the
sandbox user's account is set to, and what the framework provided is written
out in docker/session-bashrc.sh: history on the persistent volume and appended
per command, completion, a prompt showing git state, and fuzzy search over
history and paths bound to the usual keys. It is a file in this repo, so
shellcheck covers it and a reader can see what their shell does.
Two things it fixes rather than reproduces. The fzf key bindings were sourced
from a path inside the fzf package's documentation directory, which has moved
between releases - a binding that silently stops existing after a distribution
upgrade. They are defined here instead. And PATH is repaired for login shells,
where Debian's /etc/profile rebuilds it from scratch and drops both the global
Node tool directory and the sbin directories the network tools live in.
DEVCONTAINER=true was set for orientation. It misdescribes what this is: a
sandbox with an egress firewall, no sudo and no path back to root is not the
thing tools mean when they check that flag, and several of them change
behaviour on it - relaxing a prompt, enabling a feature, assuming a workspace
layout - on an assumption that does not hold here.
DEJIMA=1 stays, which is the same orientation stated accurately, and is what
the SessionStart hook and the harness already read.
The session user has no sudo, holds no capabilities, and is reached through
gosu, which lowers privilege rather than raising it. A setuid binary or a file
capability is the one shape an escalation out of that could take, and the image
carried fourteen of them - the standard Debian set, none of which an unattended
non-root session that authenticates to nothing has any use for.
runtime.bats already asserted no_new_privs on the running container, which is a
second lock on the same door. This removes what was behind it, and the
verification is the part that keeps paying: a package added later that ships a
setuid binary fails the build rather than quietly widening the image.
It has to run last, which is why it also runs at the end of a derived
per-project layer. That layer installs apt, pip and tarball dependencies after
the base image is finished, so a sweep in the base image alone would not see
them - a project could declare a dependency that shipped a setuid binary and
get it, silently, in every session on that project.
The one visible consequence is utempter, setgid utmp, which multiplexers call
to record a login. tmux carries on without it; `who` and `w` inside the
container stop listing sessions.
This file's header credited Anthropic's public reference dev container as the
shape it started from. That was accurate and is no longer: the parts it
described are gone.
What they were, specifically, because "no longer resembles it" is a claim and
this should be checkable. The reference's history path and its HISTFILE snippet,
its npm prefix and the two ENV lines setting it, its DEVCONTAINER flag with the
comment above it, its zsh-in-docker invocation with the same flags, and its
editor block with the comment above that - each was present here character for
character, and each has now been replaced by something written for what this
image is. Comments included: several were copied verbatim, which is the part
that makes "inspired by the shape" the wrong description.
Compared line by line against the reference, ignoring whitespace and blank
lines, what the two files still share is: fourteen Debian package names, three
ENV assignments whose values are the only sensible ones (nano, nano, $TZ),
`RUN apt-get update && apt-get install -y --no-install-recommends`,
`RUN ARCH=$(dpkg --print-architecture)`, and `USER root`. Four of the package
names go when the firewall toolchain is replaced. The rest is what any two
Debian images built for overlapping purposes would share, and there is no other
way to spell any of it.
The README's provenance note stays for now: init-firewall.sh still carries its
own header, and that file has not been replaced yet.
Four static tests asserted the thing that was replaced: that NODE_PATH was set,
that the npm prefix was a particular path, and that the zsh installer was
invoked in a particular way. Rewritten to assert the property each was
protecting rather than the implementation that used to provide it.
The download test is the one that changed shape rather than paths. It banned
`sh -c "$(wget -O- ...)"` because a failed download in that form substitutes to
an empty string and exits 0, so a build goes green having installed nothing. It
now bans piping a download into a shell at all, in either spelling: nothing in
this image does it any more, and the ban is worth more than the one installer
that prompted it.
The npm prefix test now reads the prefix out of the Dockerfile and checks every
harness manifest names a command_path inside it, because that is the contract a
move breaks - a harness whose CLI is at a path the image no longer creates is a
session that cannot start, and nothing else would have caught it.
Two runtime tests added for what only a real container shows: that the image
holds no setuid binary and no file capability, and that an interactive session
gets bash with HISTFILE on the mounted volume and history appended per command.
The first is the assertion no_new_privs could not make - that flag forbids a
privileged binary from taking effect, not from being there.
A clean-room replacement for the firewall, written from a requirements
document by an agent that had read neither the code being replaced nor
Anthropic's reference dev container, and could reach neither - a dejima session
launched against a scratch directory holding the requirements and nothing else.
I specified and verified it; I did not write it, because anyone who has read
the original produces derived work no matter how carefully they paraphrase.
nftables rather than iptables and ipset, which is its choice and not a
translation of the old one. One tool covers both packet filtering and the named
address sets the policy needs; one inet table covers v4 and v6, so an allowlist
cannot cover one family and silently ignore the other; a whole ruleset loads as
a single transaction, so the namespace is never left half-filtered.
Three things it does that the old firewall did not:
A destination's permitted ports are the same however it was arrived at.
Destinations are declared by class, and the class decides the ports, so a forge
address reached by resolving a name gets the same restriction as one reached
from the published ranges. The old shape kept those in step by hand and it had
already gone wrong once.
An answer that points inside a private network is refused. An answer decides
what the allowlist contains and comes from outside, so one influenced reply for
a permitted public name was egress to a host on the operator's own network, on
every port. The refusal is stated twice - once in the resolver, once
independently in the packet filter - so it does not rest on a single component
being configured right. Destinations the operator configured are exempt,
because they chose the address and there is nothing for an answer to decide.
Every name lookup is recorded with its outcome. A refused lookup never becomes
a connection, so the packet log could not account for it - and it is the query
itself that carries data out, which makes it the record that matters most.
Two things here are mine rather than the clean-room author's, both because they
rest on things only this deployment knows. The ranges fetch takes a credential,
because the endpoint it defaults to is rate-limited per source address across
everything on the host and a host starting sandboxes in a loop exhausts it. And
the connection logger drops to its own account: it parses headers from packets
whose contents the session decides, which is the last thing in that container
that should hold privilege.
A destination's permitted ports are a property of the destination, not of the
path it was named by. fixed-domains.sh now says which class each name belongs
to, and firewall-up.sh turns a launch's configuration into the two lists plus
the exemptions, rather than gluing everything into one string the way
EXTRA_ALLOWED_DOMAINS did.
That string was the problem. Everything in it was permitted on every port and
exempted from the check on private answers - deliberate for a self-hosted forge
on the operator's own network, a silent widening for a public name, and one
list could not distinguish them. The forge and Loki hosts are still exempt,
because dejima resolves their addresses itself. A project's declared domains
are not: they are reviewed with the project and expected to be public, and
exempting a public name is what turns one influenced answer into egress
inwards. An operator whose approved domain really is internal says so, and the
container names the entry at start rather than leaving it to fail hours later.
The class rule is shared with bin/dejima-allow rather than restated, for the
same reason the old file was shared: when the answer lived in two places, a
GitHub name added to a running session landed in the port-unrestricted set and
opened 22 - the read-only guarantee gone, from a command whose whole output
said it had granted one domain.
The shipper tails two records that are already one JSON object per line, so
nothing here parses them - and they are two rather than one because a refused
lookup never becomes a connection, which makes it the record a supervisor most
needs and the one a connection log cannot hold.
The entrypoint's comments were describing a script that no longer exists. What
they were describing is still true and still load-bearing: /etc/resolv.conf is
per container even when the network namespace is shared, so the session has to
point itself at the resolver enforcing the name allowlist. It now says what
happens if it does not, which is not "the allowlist quietly applies to nothing"
- the policy refuses this namespace's traffic to any resolver but its own, so a
session left pointing elsewhere resolves nothing at all.
init-firewall.sh carried a header crediting Anthropic's reference dev container
for its shape, and that credit was accurate: the reset block at the top,
including its grep pattern and two hardcoded chain names, was the reference's
line for line, and the overall arrangement was too. The replacement was written
without sight of either, which is why this is a deletion rather than an edit.
dns-proxy.sh and ulogd.conf go with it: the policy generates both the resolver
and the log configuration per launch, from the permitted set it was given,
rather than shipping a static file that has to be kept in step with one.
dejima-allow used to do the work itself: append a server= line, signal the
resolver, dig, then push addresses into a set. That spread the rules across two
places, and it meant an internally-routed name could not be added to a running
session at all - the exemption for private answers could only be written at
startup, so the answer was "relaunch".
It now runs one command in the filter container. That command records the
entry, regenerates the resolver's configuration and restarts it, resolves the
name, and confirms the address reached the set its class uses - putting the
permitted set back the way it was if any of that fails, so there is no
half-added name that resolves and is then refused. An approved internal name
now works on a running session.
The class is stated here and enforced there, from the file both read. The
exemption for private answers rides on the approval rather than on the request:
--project and --global are reviewed decisions about a destination, and a
session-only grant is not, so it does not get to exempt a name from the check
that stops one influenced answer becoming egress into your own network.
The policy refuses an answer for a permitted name that points at a private
address. The configured forge and Loki host are exempt because dejima resolves
their addresses itself, but an approved domain is not - a project's declared
domains are reviewed with the project and expected to be public.
That expectation is sometimes wrong on purpose: an internal mirror, a service
on the operator's LAN. Without a way to say so, the container would refuse to
start and name an entry with no remedy to offer. [core] internal_domains is
that remedy, and it is deliberately narrow - exempting everything approved
would put back the behaviour this release removed.
The rest is comments naming files that no longer exist, and the capability
floor's own comment, which described what the resolver and the packet filter
needed under their old names.
The briefing's check read /etc/dnsmasq-sandbox-servers.conf to decide which of
a project's declared domains this session could reach. That file has never
existed in the session container: it belongs to the filter container, which
owns the namespace's egress control and which the session deliberately cannot
read. So the grep found nothing, every declared domain was reported as not
allowed, and every session has been opening with a list of things it could
plainly reach - github.com among them, in a briefing whose next line is how to
ask an operator to allow it.
Resolution is the right question anyway, and it can be asked from where the
answer matters. The resolver answers for permitted names and returns NXDOMAIN
for everything else, so "it resolves" and "it is permitted" are the same set.
Bounded, because an agent is waiting on this.
Most of these tests already asserted the right thing about the wrong
implementation, and moved across. The ones worth naming:
The #59 tests - a startup script must never die silently, and a fetch must be
bounded, retried and reported - now cover egress-policy, which gained the -E
and the ERR trap they check for. Porting them found two real defects. jq exits
0 on an empty body having decided nothing, so an empty response was being
accepted as a good fetch and failing later somewhere that could not retry; the
check now reads the filter's output rather than its status. And the body check
had to move inside the retry loop, because a rate limit answers with a message
and no ranges, sometimes over a 200, and it clears on its own - outside the
loop it would have failed a launch over something a second attempt would have
got right.
The resolution tests became tests of the start-time sweep over permitted names,
which has to keep two outcomes distinct: a name that returns nothing is
reported and the session continues, because the world is allowed to be down,
while a name the policy itself refuses stops the container, because that is two
halves of the operator's own configuration disagreeing and it does not fix
itself. Their fixtures write the refusal as the lookup happens rather than
beforehand, which is the real ordering - written beforehand, the test passes
without exercising anything, since the sweep reads only what it produced.
The IPv6 tests are gone rather than translated. They asserted that a family was
blocked, and that a branch which could not tell whether IPv6 was up would
refuse to start rather than log a conclusion it had not checked. Neither
question exists now: one table covers both families, so there is no state in
which one is filtered and the other is not. What replaced them asserts that,
and that a v6 destination is refused rather than merely unrouted - skipping,
not passing, where the namespace has no v6 at all, because a vacuous pass is
the failure those tests were written about.
The env-passing guard derives its exclusions instead of listing them. A name
these scripts originate is theirs; a name they read is the launcher's and has
to be passed. `X="${X:-}"` counts as a read, which is what keeps LOKI_URL in
the required set.
One test was deleted for being vacuous: it checked every `ipset add` in a file
that no longer exists, so it passed by matching nothing.
The README said the egress firewall's original shape came from Anthropic's
reference dev container, and pointed at init-firewall.sh for what it kept. That
was true and is no longer: the file is gone, and what replaced it was written
without sight of either it or the reference.
Compared line by line, ignoring whitespace and comments, what docker/ still
shares with that reference is four shell keywords - `done`, `else`, `fi`,
`exit 1` - ten Debian package names, three ENV assignments whose values have
one sensible spelling, two idioms out of Docker's own documentation, and
`USER root`. There is no other way to write any of it. So the note comes out,
and removing it is a statement of fact rather than a claim.
The prose around it was describing mechanisms that have changed, and two
descriptions were load-bearing enough to be wrong rather than merely stale. The
port-22 claim rested on IPv6 being blocked outright, which is no longer how it
holds: the restriction now belongs to the destination rather than to the path
it was reached by, and both families are covered by one table. And the DNS
section described a resolver with no fallback, which was only half of why it
works - the other half is that nothing in the namespace may reach a resolver
outside it except the forwarder, matched by a uid the image creates for that
and nothing else.
docs/ci.md keeps its own iptables and ipset: dejima-ci-hardener is a separate
project on the host with its own toolchain, and the sentence that claimed the
two were the same now says the arrangement is shared and the toolchain is not.
The privileged CI job died at the policy's own startup check:
[egress-policy] FAILED: the egress chain did not come up with a default-drop policy
The chain had come up with a default-drop policy. The check was
nft list chain inet egress_policy egress | grep -q 'policy drop' || die ...
and `grep -q` leaves the moment it matches, which closes the pipe. nft then
takes SIGPIPE and exits 141, `set -o pipefail` makes that the pipeline's
status, and the `||` fires. The check failed BECAUSE it succeeded.
It is invisible while the producer finishes writing before grep leaves, which
is why it passed every local exercise. It appeared here because the chain
listing carries a forge's published ranges, which is thousands of lines - the
first output big enough to lose the race. nft printed nothing, so there was
nothing in the log to suggest the command rather than the condition.
Replacing the producer with `printf` does not help; the SIGPIPE just moves. So
matches are `[[ ... ]]` where the pattern is a fixed string, and a herestring
where grep is genuinely needed - bash writes the whole string out before grep
runs, so there is no pipe for grep to leave early. `head -1` and `grep -m1`
are the same shape and go the same way: awk stops itself, a glob replaces an
`ls | head`, and the one place that wanted the first matching line reads the
lines in bash.
A static test now refuses the shape across the container's startup path, and
found two more instances while being written - in the ranges fetch and in the
resolver lookup, both of which would have failed the same way once their input
grew.
The same bug was in caps-lib.sh's CAP_SETPCAP check, which decides whether
either container starts at all: capsh's output is one short line and would
almost always win the race, which is the worst place for a race to be.
Also: the resolver configuration check now reports dnsmasq's own words rather
than only a status. The reason that matters is a build without nftset support
- the arrangement rests on the resolver writing answers into the packet
filter's sets, and a dnsmasq compiled without it rejects that line rather than
ignoring it, which is correct and completely opaque from an exit code.
The privileged job got past the ruleset and died at the next check: the
permitted name api.github.com did not resolve through the local resolver.
One likely cause, and the one this fixes. dnsmasq daemonizes, so its parent
returns almost immediately and `pgrep` succeeds while the child is still
binding. A query sent into that window is refused by the kernel rather than
answered, and every check after it reads that as the policy refusing a name it
permits - a container that fails to start, intermittently, over nothing. The
firewall this replaced had the same guard for the same reason; the replacement
waited for the process instead of the service.
The probe is a name that must NOT resolve, so any response at all - including
the NXDOMAIN it is supposed to get - proves the resolver is up and deciding.
Waiting on a name that should resolve would conflate "the resolver is ready"
with "the forward worked", which is the next check's question and a different
failure.
That may not be the whole of it, so the check that failed now reports what it
found rather than only that it found nothing: the resolver's own log lines for
the name, which say whether the query was forwarded or answered locally; the
configuration the name matched; the upstream resolvers discovered at start; and
the owning uid of every UDP socket in the namespace, from /proc/net/udp.
The last of those is the question this cannot otherwise answer. Permission to
reach a resolver outside the namespace is `meta skuid`, which matches the
SOCKET's owner - not the uid the process ended up running as. A socket opened
before dnsmasq dropped privilege keeps uid 0, and the rule would not match it.
The firewall this replaced never exercised that path: it accepted all loopback
traffic first, so the forward was permitted before any uid was considered.
Which is also why the rule is worth keeping rather than relaxing. That blanket
loopback accept is what let a session run `dig @127.0.0.11 anything.example`
and have the runtime forward it off-box - the exact channel the name allowlist
exists to close, open the whole time underneath it.
The diagnostic answered it. dnsmasq forwarded:
dnsmasq[166]: query[A] api.github.com from 127.0.0.1
dnsmasq[166]: forwarded api.github.com to 127.0.0.11
so the configuration was right, the forwarder's own rule matched, and the
query left. Nothing came back. The block was one hop further along: the
container runtime's embedded resolver forwards external names to the host's
real nameservers, and on some versions does that from inside THIS network
namespace, as root, on port 53 to a destination the policy has never heard of.
This chain refused it, so the resolver could go no further and every lookup
timed out having been permitted.
The firewall this replaced documented that exact hop and permitted it, keyed to
uid 0, noting that two hosts with byte-identical resolver configuration behaved
differently and it cannot be assumed either way. The replacement kept the
reasoning and dropped the rule.
What the rule costs is now stated where it is, rather than left to be
discovered: a process already running as root in this namespace can address a
resolver directly. The session user cannot - uid 1000, no sudo, no setuid
binary, no capability to change uid - so the query-name channel stays closed
for the thing this exists to contain. Root in the session container would be a
way around it, which is the posture the old firewall had too; it permitted uid
0 to port 53 anywhere AND accepted all loopback besides, so this is narrower.
Narrower is not closed, and closing it needs a discriminator finer than uid,
which the packet filter cannot express between two containers sharing one
namespace.
Three claims that were true of the version without this rule are corrected
rather than left standing, in docs/security.md, the entrypoint's comment and
the test that pins the property: what holds is that the session USER cannot
address a resolver, not that nothing but the forwarder can.
The permit and refuse chains now carry counters. The diagnostic added last
round tried to read them and found none, because there were none to read - a
refusal was visible in the log, which needs a consumer, and nowhere a startup
self-check could look. A runtime test also now asserts the session user cannot
reach the runtime's embedded resolver directly, which is the half of this that
matters and the half no unit test can reach.
The privileged job got 60 of 62 tests through. Both failures were mine, in the
tests rather than in what they test.
`ps -eo user` pads the column to eight characters and truncates anything
longer, so the account this arrangement rests on came back as "egress-+" and an
assertion naming it could never match. The account is named for what it is
rather than for what fits in a column, so the test compares uids instead, which
cannot be truncated. The policy's own check was never affected: `ps -o user=`
with an empty header sizes the column to its content, which is why it passed
in the same run.
The other asserted a lookup of an unpermitted name comes back REFUSED. That
was true of the resolver this replaced, which had no upstream for such a name
and said so; this one answers NXDOMAIN, because it states the refusal rather
than leaving it as an absence. Which of the two a resolver says is a property
of how the refusal is expressed, not of whether the name is permitted, so the
assertion now accepts either and fails on an address.
One sibling assertion had already been updated when it moved; this one had not,
because it lives in the credential-proxy test and reads as being about the
/etc/hosts pin.
The README was updated with the policy; this file was not, and it is the one
that states the security argument, so its paragraphs were wrong rather than
merely stale.
IPv6. It described blocking the family outright with ip6tables and stripping
AAAA answers, and the fallback that asked the kernel whether IPv6 was live
before deciding. None of that exists: one inet table carries both families, so
there is no state in which one is filtered and the other is not, and AAAA
answers resolve and land in the v6 sets like anything else. Blocking v6 was the
honest answer to a v4-only allowlist rather than the better one, and the check
it needed is gone with it - there is nothing left to infer, because the table
either loads with both families and a closed default or the policy refuses to
start. This one mattered most: the README's GitHub-read-only argument now rests
on the allowlist covering both families, and this file still said the opposite.
Tool names. `iptables` is no longer installed, so "that user gets a permission
error running iptables" now describes a command-not-found. `nft` is the one to
name. The claim beside it was also wrong in a more interesting way: it said a
compromised session has "no copy of the allowlist within reach", which stopped
being true when /usr/local/sbin went on the session user's PATH. Reach is not
the question - the script refuses a non-root caller, and the capability it
would need belongs to the container beside it. Both directions are tested, so
the text now says what the tests check.
The request log. It named a static ulogd.conf that this branch deletes and
NFLOG groups that have changed; the configuration is generated per launch
beside the ruleset, which is what keeps the group numbers from drifting from
the rules that write to them. The DNS half was understated: lookups are now
structured records carrying a reason, and the reason that matters carries a
severity.
Mid-session domains. The "one gap remains" paragraph described a limitation
that is fixed - an internal hostname can be added to a running session now,
because the resolver's configuration is regenerated rather than signalled - and
the mechanism it credited for GitHub's port restriction is no longer the one
that decides it. Longest match decides which set an answer is written into;
rule order decides which ports that set is reachable on.
The two halves disagreed. bin/dejima-allow passed --internal-ok for any domain
recorded as an approval; firewall-up.sh deliberately withheld it, and said so.
So `dejima-allow --global foo.internal` succeeded and the session worked, then
the next launch read the same name with no exemption, saw the resolver refuse
the private answer, and stopped the container. The operator's first hint that
the two commands had different ideas was a failed launch, some time later, on a
name that had just worked.
The strict side is the right one. An approval says a destination may be
reached; it does not say an answer for it may point inside the operator's
network, and a project's declared domains are reviewed as public names -
exempting one on the strength of an approval is the pivot the check exists to
stop.
So neither half decides it: both read [core] internal_domains, and a name is
exempt if and only if it is listed there. That keeps the strict default and
keeps the live add working for a name the operator has actually declared
internal, which is better than either half was doing on its own.
The message that failure prints named EGRESS_INTERNAL_OK, which is the
policy's interface with whatever launched it and not something an operator can
set - it sends them looking for a variable that is not theirs. The launcher now
supplies the name of the key they do set, and the policy uses it, so the
component stays launcher-agnostic and the message is still actionable.
Two things the tests caught while being written. config_get prints its value
with no trailing newline, so a single-entry list is one line `read` never
returns and the loop never ran - the repo's own config parser guards that with
`|| [ -n "$var" ]` and this now does too. And config_get reports a missing
config file on stderr, which is right for a launch and noise here, where a host
with no configuration is ordinary and the answer is simply "not listed"; a
config that exists and is malformed still reports itself.
The loop that read it called dejima_permit_open, which appends to the open
class first and marks the entry internal second - so a name listed only there,
approved nowhere, became a permitted destination reachable on any port. The
configuration table described the key as exempting something already permitted,
which is what it should do and not what it did.
They are separate decisions and only one of them is a grant. Whether a
destination may be reached is an approval, reviewed with the project and
visible in an allowlist; whether an answer for it may point inside the
operator's network is this. A key that did both would make naming a host here
enough to reach it, which is a grant nobody reading an allowlist would think to
look for.
Naming something nothing permits now stops the launch and says which entry and
what to do about it. The alternative was to ignore it, which leaves an
exemption attached to nothing and a destination the operator believes they
configured silently unreachable - the two halves of a configuration disagreeing
with each other, which is the thing this branch already decided to fail on
rather than survive.
The firewall this replaced set INPUT and FORWARD to DROP, in both address
families, with narrow accepts for loopback, the container's own host and
replies. The replacement had one chain and one hook, so nothing filtered
traffic into the namespace at all.
Most of what that exposed is exposed by nothing: the resolver and the
credential forwarder both bind loopback, and a launch puts each pair on a
network of its own. What it did open is the case none of that covers - a
session process binding every interface, by accident or because a dev server
defaults to it, became reachable from the bridge where it had not been. "There
is nothing else on the bridge today" is a fact about the launcher, and the
filter should have its own answer.
Dropped rather than refused, which is the opposite of the outbound decision and
deliberate: a refusal is a courtesy to a caller that is supposed to be there,
and an unsolicited inbound connection is not.
The self-check now verifies every chain's default rather than the outbound
one's, since a chain that failed to come up closed is exactly the case this is
about. The runtime test binds 0.0.0.0 in the session container and dials it
from a third container on the same network, which is the shape of the exposure
- and runs both probes before asserting either, so a leaked listener cannot
follow it into the tests after.
It regex-parses the resolver's text log, whose lines are decided by the names
the session queries - the same argument given a few lines above for dropping
the connection logger, in the same container, and it was still running as root.
It opens its output and its error file and then only reads and writes them, so
the drop costs it nothing: both are chowned to the account first, and the
resolver's log, which it only ever reads, stays root's. gosu because that is
what this image already uses to run something as another user, and because it
execs - so the recorded pid is still the logger's.
The test that covered the connection logger now covers both, and reads each
uid out of /proc rather than matching ps output. ps truncates in two places
that both bite here: `user` to eight characters, which is how an assertion
about egress-resolver came to be unsatisfiable earlier in this branch, and
`comm` to fifteen, which the lookup logger fits inside by one character.
It was new in this branch and nothing referenced it under test/, which is a
poor place to have no coverage: the key decides whether the check that refuses
an answer pointing inside a private network applies to a name.
config.bats takes the schema - that it is a list of hostnames, that a malformed
entry refuses the whole file rather than being skipped, that a colon-separated
list splits, and that unset is not an error. It also pins the thing that bit
this branch twice: config_get returns its value with no trailing newline, so a
`while read` over a single-entry list gets EOF on the only line and never runs
its body.
launch.bats takes the wiring, and needed the docker stub to record the
environment to do it. `docker run -e NAME` carries the value in the
environment, not on the command line, so the existing assertion that the flag
is present cannot tell whether anything was behind it - and an empty value here
applies the check to every name the operator declared internal. GH_TOKEN is
excluded from what the stub records: it is a real credential on a developer's
machine and there is no reason to write one to disk to test an unrelated
variable.
static.bats takes the behaviour: that the key exempts without permitting,
driven through both functions rather than grepped, and that an exemption
reaches the resolver and the packet filter both - one that reached only one of
them would leave the destination configured and unreachable.
docs/development.md lost the first word of a sentence when the `ipset` clause
was removed from it, and a later edit rewrote words inside lines without
reflowing the paragraph, so it wrapped at boundaries that no longer existed.
Reflowed rather than patched, and split in two while there - it was one
paragraph covering two suites. Its list of what runtime.bats asserts was also
stale in the way finding 6 describes: it still said IPv6 was blocked.
The workflow's two comments about the api.github.com/meta rate limit named a
script this branch deletes. The fetch is the same and so is the reasoning; only
the file moved.
static.bats pairs every `docker run` in runtime.bats against a `--network`
flag, because a container on the default bridge has no embedded resolver and
dies mid-startup - which used to present as flaky or vacuous tests rather than
an obvious failure. It counts flags at the start of a line, so the new
inbound-reachability test had its network and did not look like it.
The convention is the check, so the call matches it rather than the check
learning another shape.
`pkill -f "http.server 8099"` matches the command line of the shell running
pkill, because that string is in it. So it killed itself, the docker exec came
back 143, and bats stopped the test there - before either assertion had run.
The test reported a failure and had checked nothing.
It now records the listener's pid when it starts it and kills that, which
cannot match anything else, and the cleanup is guarded so a hiccup in it is
never what decides whether the test passes. A last probe confirms the listener
is actually gone, since a leaked one would sit in the shared container for
every test after this.
Worth the words because the shape is the bug, not the pattern: any `pkill -f`
whose pattern appears in its own invocation does this, and it presents as a
signal rather than as a match.
This branch turned "a sandbox has no IPv6 egress" into "IPv6 is filtered by the
same allowlist as IPv4", and then described that in three documents as an
improvement. It was a regression, and the largest one in the branch: a
permitted name with AAAA records became reachable over IPv6 on the ports its
class allows, which was impossible before.
The requirements document I wrote is where it came from. It said an allowlist
covering one address family and silently ignoring another is not an allowlist,
which is true and is not what dejima does - dejima refuses the family. The
clean-room implementer satisfied what it was given, the sets and rules came
back covering both families, and I integrated them and argued for them.
It also made the README's GitHub read-only claim worth less than it says.
"Port 22 is closed on the published ranges" means nothing if a v6 route to the
same host stays open, which is the reason the old firewall blocked the family
in the first place.
Restored, and said twice rather than once:
the first rule of the egress chain refuses the whole family - above the
established and loopback passes, because a rule below either is one a v6
packet can get past, since loopback carries ::1 and a flow permitted once
stays permitted;
and the resolver hands out no AAAA at all, so a permitted name never
produces an address that could become such a packet.
Every v6 set, rule and entry point goes with it: a v6 literal in the permitted
set is refused at the door rather than added to a set no rule consults, a v6
default-route gateway is no longer taken as the host address, and a v6-only
upstream resolver fails the launch saying so instead of becoming a nameserver
the forwarder is refused on reaching.
The nftables move stays, and is the reason this is now cheaper to hold than it
was. The old firewall had to decide whether the family was even present before
it could block it, and the first version of that read "the tool will not run"
as "the family is not there" - running with IPv6 live and unfiltered, saying
nothing. There is nothing left to infer: the refusal loads in the same
transaction as everything else, or none of it loads.
The startup check asserts the shape of the ruleset rather than a connection
that fails. A v6 connection fails identically on a host with no v6 route, so a
probe alone passes whether the family is refused or merely absent - which is
the same shape of mistake, one level up. It requires the refusal to be first
and requires no other rule to name a v6 destination, because the regression
does not look like the refusal disappearing; it looks like permit rules
appearing beside it.
The tests are inverted rather than extended. The one covering this required
`ip6 daddr` rules in the chain and failed without them, so it asserted the
regression. The one beside it skipped when there was no v6 default route,
which under this posture is nothing to skip for.
The startup check read `egress` and nothing else. The chain a v6 permit rule
last appeared in was `inbound` - `ip6 saddr @host_v6 counter accept`, sitting
there in the previous round while the egress chain was already clean - so it
was watching the wrong door for the one regression that has actually happened.
That matters more than the runtime test covering it, because this check gates
every launch and the test only runs on the privileged runner when it is up.
Both properties are asserted for each chain now: the refusal is present and
first, and nothing else in the chain names a v6 address. The second is the one
that catches it - a regression here does not look like the refusal
disappearing, it looks like a permit rule appearing beside it.
It asserted the resolver returns no AAAA for a probe name, and the comment
beside it already named the hazard: a name with no AAAA anywhere satisfies that
by being unremarkable. Nothing enforced that the name kept its records, so the
day it lost them the check would go quiet and keep reporting a pass - the same
shape as a negative control that passes because the thing it depends on broke,
which this repository has been caught by before.
The upstream is asked first, so "no AAAA came back" is only accepted as
evidence when there was an AAAA to come back. root may reach a resolver
directly, which is what makes that askable from inside the policy container.
Three outcomes, and the third is why this is not simply a stricter assertion:
upstream has them, ours does not pass, and it meant something
upstream has them, ours does too die, as before
upstream has none either say so and carry on
The last is the world changing - the name dropped its records, or the upstream
strips them itself - rather than this configuration being wrong, and refusing
to start a session over it would cost more than it prevents. But it is said,
because a check that proved nothing must not read as one that passed. The
runtime test skips there rather than passing quietly, for the same reason.
It only queries A now, so the v6 half of the address pattern can never match.
Left in, it reads as though a v6 answer is something this function still
expects to see, which is the opposite of what the policy around it says.
It greps the whole of egress-policy for anything naming a v6 address, which
caught the comment on the runtime check - the one that quotes
`ip6 saddr @host_v6 accept` to say which rule regressed last time. That is the
kind of fact .dejima.md asks to be kept, and the test is about the ruleset the
script emits, not about what it says.
Comment lines are excluded. A rule the script emits never begins with a '#', so
nothing real hides behind it - confirmed by adding an `ip6 daddr ... goto
permit` line and watching the test fail.
The AAAA control asked the upstream resolver for the truth as root. Root cannot
reach it. The rule permitting root to reach a resolver matches on port 53, and
the runtime reaches its embedded resolver through a DNAT that has already
rewritten the destination port by the time the filter hook sees the packet - so
that query falls through to the address-matched refuse below it. The forwarder's
own rule is portless for exactly that reason, and the comment on it says so.
Both halves failed safe and failed quietly, which is the worst of both. Every
launch printed a note saying the check proved nothing and naming a remedy that
could not work, and every CI run skipped the test that was the point of the
change - so the control was back to proving nothing, while sounding like it had
diagnosed why.
Asked as the resolver's account now, through gosu, which the portless rule
permits. dnsmasq is not in that path, so filter-AAAA does not apply to it and it
stays an independent source of truth - the property the control needs.
Not fixed by letting root reach the upstream. That would widen the policy to
make a check work, and the rule it would loosen is one of the two holding the
query-name channel shut; the runtime test asserting root does not reach past it
would have failed, which is that test doing its job.
Two comments asserted the thing that is not true and are corrected with the
code. The distinction the runtime one drew - filter container yes, session
container no - is real, but it is not what permits this query.
A launch on this branch stopped with:
check 7: these permitted names are answered with an address inside a private
network ... llm-backend.bradina.fun (open/strict)
That is the answer check doing its job on a name that should never have reached
it as an ordinary approved domain. Where a self-hosted backend lives comes from
[backend.<name>] base_url - the operator's own configuration, the same class of
value as the forge's hosts and [logging] loki_url, both of which are permitted
and exempted automatically for a stated reason: they configured the address, so
there is nothing for an answer to decide.
The third one was missing. `self-hosted` deliberately has no proxied_domain -
its address is a config key, not a manifest constant - and nothing carried that
host into the firewall path. So the only way to reach your own model was to
approve its name as a domain, at which point it is indistinguishable from a
public name nobody reviewed, and a model served on the operator's own network
fails a check meant for names that are not.
bin/dejima now derives the host from base_url, scheme and port stripped, and
hands it over the way DEJIMA_FORGE_HOSTS and LOKI_URL are handed over.
firewall-up.sh permits and exempts it. Backends dejima proxies leave it empty,
and an empty entry is ignored rather than permitted.
This is not the answer check being loosened. An approved domain still gets no
exemption, which is the posture the earlier round settled on; what changed is
that a destination dejima configures itself no longer has to arrive disguised
as one.
Moving the npm prefix also changed how it joins PATH: `ENV PATH=$PATH:<prefix>`
became `ENV PATH=<prefix>:$PATH`, carried over from where the new prefix came
from. /usr/local/bin holds the harness wrapper, which shadows the harness's
real binary to pin --dangerously-skip-permissions, --settings and the briefing
- and that binary is npm-installed, so prepending the prefix let the real one
win and dropped all three flags.
Nothing failed. The session simply came up asking permission for every action,
with no briefing and no settings overlay, which is the sandbox's whole
operating mode quietly off.
Appended again, in all three places PATH is written: the image's ENV, the
profile.d fragment for login shells, and the session's own rc file.
Two tests, because this was invisible. A static one refusing the prepend in any
of those places, checked by reintroducing it and watching it fail. And a
runtime one reading the session user's real PATH, in a login and a non-login
shell - Debian's /etc/profile rebuilds it for the first and the image's ENV
supplies the second, so the order has to hold in both.
The privileged job lost one container of the sixteen it starts:
curl: (28) Connection timed out after 20001 milliseconds
check 5: the permitted destination https://api.github.com/meta is not
reachable through the policy (curl exit 28)
Exit 28 is a timeout, not a refusal. A destination this policy refuses comes
back as a reset or an ICMP admin-prohibited, immediately - that is the whole
reason it rejects rather than drops. Twenty seconds of silence is the network,
and every other container in the same run reached the same URL.
The firewall this replaced retried that check, and its comment describes this
run: "one container failing here while a dozen others in the same CI run came
up fine". A check asserting something CAN be reached is answered wrongly by a
transient failure, and wrongly in the direction that stops the container. The
replacement asked once. That is dejima's own hard-won knowledge and my
requirements never passed it on, so the clean-room author had no way to know.
Retried now: the forge's own lookup and the reachability check. Not retried,
and a test refuses to let them be: verify_refused, verify_port_refused,
verify_ipv6_refused, and the unpermitted-name control. There a single success
is the real answer, and retrying is asking until it says what we want.
resolve_one now returns non-zero when it found no address. It returned 0
either way, so retrying it would have been a no-op - the retry would take the
first answer, empty or not.
retry_positive ended with a literal `return 1`, so check 5 reported "curl exit
1" whatever had happened - a code curl never returns here.
That is the fact the commit adding the retry turned on. It told a timeout from
a refusal: exit 28 meant the network, where exit 7 would have meant the policy
doing its job. The retry kept the container alive through the first and threw
away the evidence for telling them apart.
The status now survives the loop and is what the caller reports. `"$@" || rc=$?`
rather than `if "$@"`, because a bare `if` discards it.
The redundant `>/dev/null` on the check 5 call goes too: curl already has
--output /dev/null, and say writes to stderr.
The self-served backend's host is permitted and exempted from the answer check.
That is safe because the two kinds are disjoint: a backend dejima stands in
front of declares proxied_domain and no base_url, so nothing derives a host for
it. Safe, but only because no manifest happens to declare both.
If one ever did, its host would be permitted and marked internal-ok while the
credential proxy was running. That re-opens the bypass leaving a proxied name
unresolvable exists to close, and hands a public SaaS name the exemption
fixed-domains.sh withholds from it on purpose - two things at once, neither
announced.
Guarded at the point of use, where the proxied domain is already in hand one
line above, and asserted over the manifests so the other end is caught too. A
future backend that genuinely needs both now has to come and change this
deliberately, rather than inheriting an exemption nobody meant to grant.
hooks/sandbox-context.sh opened by pointing at Anthropic's reference dev
container "for the reference this is based on", and at the README "for the
actual setup". Neither holds. The first is the claim the README's own
provenance note made until it was removed for having stopped being true, so the
repository was asserting both at once; the second sent a reader to a note that
is no longer there.
It survived because the audit behind that removal was scoped to docker/, and
this file is in hooks/. My own sweep for provenance references would have found
it and did not: I piped it through `head -20`, which truncated the list, and
then read a truncated list as a complete one.
Says what is true now: the container is dejima's own, and the README and
docs/security.md are where it is described.
Removing that note asserted something about the whole repository, and nothing
in the suite checked it. That is how a citation in hooks/ outlived the audit
behind it: the audit was scoped to docker/, and so was every check written
around it.
Greps for the citation and for the claim, not for the vendor's name. dejima
talks about Anthropic legitimately throughout - a backend, an API, an upstream
bug, and a comparison against someone else's devcontainer in
docs/research-review.md, which this deliberately leaves alone. What must not
reappear is this repository saying its own container came from that one.
Verified by putting a citation back in a docker/ file and watching it fail.
The runtime assertion walked the filesystem with its own find and getcap.
`getcap` has no `-xdev`, so `getcap -r /` descended into /proc and /sys as
well: 30s against 4s for the same answer, measured on a session container,
and more on an image carrying a browser.
strip-privilege-bits already names the roots that can hold a binary, and
says why. Giving it a --check mode that runs its verification alone lets the
test ask the same question of the same roots. A check that walks fewer roots
than the sweeper would pass for the wrong reason, and now it cannot walk a
different set at all.
Refs #343
The slow job is the one nobody can run locally, so what a test costs there
has been inferred rather than read. -T prints each test's duration, and this
suite runs serially, so the numbers add up to the job.
The expensive tests here are the ones that build an image, not the ones that
assert the most, and the two are not the same tests. The remaining items on
#343 need that attribution before they are worth doing.
Refs #343
/etc/bash.bashrc sources the session snippet for every interactive bash in
the container, so it reached root as well - the shell an operator gets when
attaching to debug a session. The snippet points HISTFILE at a volume the
sandbox user owns, and `history -a` follows a symlink, so the session could
aim root's history at a file of its choosing and have uid 0 append to it. In
the other direction anything the operator typed, a token included, landed
where the agent could read it.
The guard bails out of the whole file rather than the one assignment:
nothing in it is written for root, and a guard on a single setting is one
the next addition has to remember.
The static test runs the snippet with `id` stubbed and reads HISTFILE back,
rather than grepping for the guard, and asserts the sandbox user still gets
its configuration - the failure mode of a guard like this is covering too
much.
Two paths turn a name into a permitted entry, and both have to ask which
class it belongs to. The live add in dejima-allow does. The approved-domains
loop at container start did not, so a project declaring github.com or
api.github.com in its own allowlist got an open-class entry for it.
The effect is a resolver line more specific than the forge suffix entry,
feeding the open-class set instead of the forge one. The port restriction
then rests entirely on the forge's published ranges covering that address,
which is one layer where the design has two: the day a name is served from
an address outside those ranges it is reachable on every port, with the push
grant off.
A forge-class name is skipped rather than reclassified. It is already
reachable under its suffix entry, which is what resolves api.github.com
today without anything enumerating it.
The test drives the loop extracted from the file rather than a copy written
beside it, because a copy keeps passing after a revert. Both halves were
checked against the reverted code, and against a change that satisfies the
grep while restoring the behaviour.
Skipping it left the name reachable - the forge suffix entry covers it - but
absent from every list. [core] internal_domains is checked against those
lists by exact string, so declaring split-horizon DNS for a forge subdomain
read as a name nothing permits, and the launch stopped on a configuration
that was correct. The remedy the error printed put the name back in the
approved list, where it was skipped again.
Recording it as forge class keeps what the skip was protecting: the resolver
line more specific than the suffix entry now feeds the forge set rather than
the open one, so the port restriction does not come to rest on the published
ranges alone.
The new test drives the loop and the internal_domains check as they are in
the file, so it fails if either is changed to disagree with the other. It was
run against the previous behaviour, where it reports the boot stopping.
The launcher's own validator accepts a name with no dots, so a LAN host, a
container-runtime service name or an /etc/hosts entry passes every check an
operator can see. The policy then refused it and stopped the container, which
is the worst place for the two to disagree: the operator has already been
told the value is good, and the failure surfaces as a sandbox that will not
start rather than as a message about the key they set.
Reachable from [logging] loki_url, a self-hosted forge host, and a
self-served backend's base_url, each of which is seded down to a bare host.
Nothing covered classify_entry, which is how the two drifted. The test drives
the function out of the file against both sides of the boundary, and asserts
the refusals it exists for still refuse - an IPv6 entry in particular, since
this policy refuses the family rather than filtering it.
The document claimed the push opt-in was verified by the startup self-check.
It is not: with the opt-in on the check is skipped, and only the refusal is
asserted. A reader relying on that would expect a container that refuses to
start, and get a failed push instead.
Says where the property is covered rather than leaving the absence implied.
Accepting one did not make it work and did open a grant. Two things were
missed: the resolver config sets domain-needed, so dnsmasq never forwards a
dotless query and the name resolves to nothing however it is permitted; and
an entry becomes a `server=/<entry>/` line, which matches the name and
everything under it, so `org` permits every .org name into the open class,
which carries no port restriction. Both were measured against dnsmasq 2.90:
with domain-needed `loki` is NXDOMAIN and `sub.loki` answers, and with
`server=/org/` both `example.org` and `anything.org` answer.
So the refusal was right and only its message was wrong - it read as a typo
for the one shape an operator writes on purpose. It now says what would have
to be true for the name to work, which is also what anyone proposing to
support these has to change.
The test asserts the reason as well as the refusal, and checks domain-needed
is still what makes it true, so the two cannot drift apart silently.
DNS names are case-insensitive and the resolver matches them so, but the
class decision compared them verbatim: GITHUB.COM came back open while
github.com came back forge. An allowlist entry in that spelling, which an
operator reviews as an ordinary GitHub name, would be permitted on every port
by its own class.
Not reachable today - the forge clamp on the published ranges sits above the
open-class permit and ends in a refusal, so the address is caught whatever
class its name landed in. That is one layer doing the work of two, which is
the arrangement this function exists to prevent.
dejima_class_for was folded; dejima_mark_internal beside it and the
internal_domains lookup in dejima-allow were not. The principle was stated
and applied once.
Here the cost is not a lost restriction but a refused launch.
dejima_mark_internal returns non-zero for an entry nothing permits and the
caller stops the container naming it, so [core] internal_domains and the key
that permitted the same host, differing only in case, is a correct
configuration that will not start - and the message says the name is
permitted by nothing when it is permitted. No forge need be involved: two
ordinary keys naming one host is enough.
dejima-allow loses the exemption instead, silently, and the next launch then
refuses on the same mismatch.
The test guard added with the previous commit checked the wrong thing: it
grepped the whole file for `domain-needed`, which that commit had just put
into a comment and a refusal message, so it was satisfied by the text whose
accuracy it existed to check. It now matches the directive on its own line,
and was run against a copy with the directive deleted and the prose left.
Both folds are covered on both sides of the comparison. Removing only the
suffix half previously left every test green.
The policy refuses a dotless entry at container start, and correctly: a
permitted name covers everything under it, because the resolver is configured
per name and matches the name and its subdomains with no exact-name form to
use instead. A bare label therefore grants its whole suffix, and `org` would
ask for every .org name. The requirement is a floor on how much one entry can
grant.
Refusing it at container start is the wrong place. The operator meets it as a
sandbox that will not boot, on a value every check on the way in accepted.
This moves the refusal to the two places they can act on it: the project
declaration, so `dejima-project` refuses the entry while they are looking at
the file, and the launch, before anything starts, naming the key it came
from.
valid_domain is left alone. It also gates host-side consumers, where a single
label is an ordinary thing to write, so the rule belongs with the entries
that reach the firewall rather than with host names in general.
The derived hosts are checked, not the values they came from. A URL yields
its host through a sed, and a slip there - a base_url carrying userinfo
reduces to the user name - produces a bare label nobody typed. That is the
case this most wants to catch, because it is the one nobody would look for.
The token reaches a sandbox as GITEA_TOKEN, read host-side from whatever the
configured forge's token_file names. The file these tools fall back to is
deliberately absent in the container - the credential does not enter it as a
file at all.
Both messages named that file as the requirement and the variable as the
exception, "for this invocation only". Inside a sandbox that is backwards,
and an agent reading it concluded it had no way to push and stopped, on a
session whose token was present and working the whole time. Following the
advice instead would mean writing a credential into a disposable filesystem,
or asking the operator to paste one in.
The sandbox branch says where the token comes from and that there is nothing
to create in here, and points at the two config keys that decide it. The
host-side message is unchanged, because there the file is the remedy.
Not a regression from any release: these lines predate the egress and image
rework and were not touched by it.
Five things this release changed and their documentation did not.
The port-22 self-check claim was corrected in security.md and left standing
in providers.md twice - the same one-of-three that the case fold got wrong a
round earlier. Both now say what the check covers: the refusal when the
opt-in is off, skipped when it is on.
The correction in security.md then over-claimed in the other direction, that
with the opt-in on the self-check "checks nothing". Only that one check is
skipped.
The documented LogQL query for refused lookups matches `REFUSED`, which was
dnsmasq's raw log. That stream is structured now and the token does not
appear in it; the outcome is a JSON field. The same document describes the
new shape correctly four paragraphs earlier.
log-shipper.sh's row in the development table still described tailing the
dnsmasq and ulogd2 logs. That file's own header was rewritten in this
release and the row was missed.
The two-label rule for a permitted destination was documented nowhere, so an
operator met it as a rejected declaration or a refused launch. It is stated
once, with the reason, next to the file it most often applies to, and lists
the other keys it covers. valid_domain's comment claimed the shape it no
longer gates.
Two holes at either end of the two-label rule.
The rule was applied where approvals are READ. approved_entries validates
every entry on every read and bin/dejima assigns from it under set -e, so a
single-label domain already in the store failed the read - in every project,
on every launch, with the store's "corrupt approval entry" message, which
means tampering. The last release accepted those domains, so that store is
the one an upgrading operator has, and their only mistake was upgrading.
`dejima-allow` has no remove verb, so the recovery was hand-editing the file
the message had just called corrupt. The launch-time message written for this
case was unreachable behind it.
Granting is where a tightened rule belongs. Reading keeps the shape check it
had, which still refuses real corruption.
The launch check counted dots, which only catches a derivation that failed in
a way that happens to leave a bare label. `https://user@host.example/` gives
`user@host.example` and `http://loki./` gives `loki.` - both have a dot,
neither is a host name, and both were the sed slips the check was written
for. It now asks the same question of the value that the policy will ask.
Its message changed shape with it, so the assertion naming the old wording
moved to the rule rather than the phrasing.
Check 4 asserted this for one destination: the host of the GitHub metadata
URL, which is the one hardest to get wrong. The operator's own forge, Loki, a
self-served backend and every approved domain were never subjected to it.
That is the gap four review rounds kept falling into. The class an entry was
recorded with, the resolver line built from it, and the set the ports are
keyed to are three separate statements, and each can be right about a
different set. Reading the code cannot tell them apart, and every defect of
that shape here reached a green suite: a forge name recorded in the open
class, a name skipped out of every class, a name that could not resolve at
all. All three present at boot as a name that answers and an address that is
not where the ports expect it.
The sweep that already resolves every permitted name now reads each answer
back out of its class's set. Fatal, unlike a name that did not answer: that
one is the world being unavailable and deliberately costs nothing, while this
one is the policy disagreeing with itself.
Which set a class feeds now has one definition. The config that fills a set
and the check that reads it back asking separately is the same shape as the
bug, and would have let the check pass while the class meant nothing.
The harness for these tests stubs nft as well as dig, because a sandbox's own
nft answers for a table that is not there - every name would look stranded
and the failure would be about the test environment.
A name can reach both class lists. release-assets.githubusercontent.com is an
open-class entry of this component's own and also sits under a forge suffix,
and any harness or backend domain under one would do the same - so this
repository's own allowlist produces it.
Two lines meant two resolver lines naming different sets for one name, of
which the resolver honours one. The entries file and `status` could not say
which class was in force, and the check added alongside this - which reads an
answer back out of its class's set - would look in the set that lost and stop
the launch. That is how this was found: the check refused the project's own
configuration.
Forge is recorded first and the deduplication keeps the first occurrence, so
a name that is forge class by any route keeps the port restriction rather
than inheriting the open class from a second mention. That also makes
fixed-domains.sh's claim that the forge class wins wherever the two overlap
true of the entries, which it was not.
resolve_one returns non-zero for a name that answered with no address, which
is an ordinary outcome. The sweep assigned from it bare, and under
`set -Eeuo pipefail` that aborts - so a permitted name whose host is
unreachable took the container down, which is the opposite of what the note
two lines below it exists to do. The previous form tested the substitution
inside `[ -n ... ]`, where a failure is part of a condition and exempt.
The harness could not see it. bats runs a test with errexit off, and `set -e`
inside a subshell of that shell does not restore it - measured rather than
assumed, because the first attempt at this fix was exactly that subshell and
it reported the regression as passing. The extracted functions now run in a
real child bash with the options egress-policy sets, which reproduces the
failure and then the fix.
It also wanted a table name the harness had never supplied, which the
stricter shell surfaced immediately as an unbound variable. Read out of the
script, like the exemption hint beside it.
Covered by the case that reaches it: a lookup that succeeds and carries no A
record. The existing empty-answer test stubs a dig that fails, and resolve_one
returns zero on that path, so it never reached the abort.
A session left open overnight meets this in the morning: the access token has
expired, the refresh token with it, and the provider answers the refresh with
invalid_grant. Nothing about that is transient, and no amount of waiting or
retrying improves it - the operator has to run `claude` on the host and log
in. What they got instead was `502 credential proxy: could not load or refresh
the credential - see host terminal` in the session, and on the host a line
quoting the provider's error code with no remedy in it.
The remedy already existed, in the startup check, for the case where the
credential file records its own refresh-token expiry. A session that was
running before the token died has already passed that check, so the path it
actually takes is the refresh, which had no idea what invalid_grant means.
One definition now, reached by both.
It is sent to the session as well as logged. The session is where the failure
is met and the host terminal is behind a window the agent cannot read, so
"see host terminal" costs a trip to find one sentence. The rule about what
crosses that boundary is intact and is the reason the exception carries two
texts: the remedy is written in this file, and the provider's bytes stay in
the log beside it.
Also raised on the 401-retry path, which reported it as an upstream request
failure - sending whoever read it to look at the provider's availability
rather than at their own login.
The host line now leads with the remedy, because the exit audit prints the
last one of them when a session ends, and that is where an operator who was
away while it failed will read it.
The message said the same thing twice before reaching the verb, and carried
the provider's answer in the same line. In a terminal already full of harness
output that is a paragraph to scan before finding out that the fix is one
command.
Now one short sentence, action first: what went wrong is the subordinate
half, because by the time this prints it is the only part that changes
anything.
The host log is two lines rather than one, detail first. bin/dejima's exit
audit prints the LAST line when a session ends, so the line an operator reads
without going looking is the one that says what to do - and the provider's
own words sit directly above it for anyone who does. That ordering is now
asserted, since nothing else would notice it reversing.
The documentation pass that ran mid-release checked what existed then. Four
pull requests landed after it. These are what they left behind.
The exit audit announced every proxy log as "The model API answered with an
error this session", counted statuses out of it, and closed by saying a
retried one may still have succeeded. A credential that could not be loaded
produces no status line at all, because no request was made - so that case
printed the header, no counts, and an invitation to hope. All three are false
for exactly the failure the credential work routes there deliberately. It now
says what happened and prints the one line that says what to do.
permit_name still spelled out its own set name, which is the fourth place
deciding which set a class feeds and the one set_for_class's comment claimed
did not exist. A live add looking in a different set from the one being
filled reports a permitted name as unreachable, or the reverse. The static
test now refuses any spelling of a set name outside that helper, rather than
naming the three sites it knew about.
Documentation: the rule the credential proxy's comment cites - what may cross
into a session and what may not - was not written anywhere, and the change
that made a repo-authored remedy cross needed it to be. The boot check that
now refuses a name whose address is missing from its class's set was
undocumented, as was the widening of the launch check from counting dots to
asking for a host name. And an approval granted before the two-label rule now
stops a launch with no remove verb to clear it, so the file to edit is named.
The audit's two paths are driven against logs this proxy really wrote, rather
than lines written in the test, because the classification only works if the
two files agree about what a line looks like.
fixed-domains.sh said the forge class wins wherever the two overlap. Only the
approved-domains loop asked. The built-in open list carries
release-assets.githubusercontent.com, which sits under a built-in forge
suffix, so with the shipped defaults it kept the open class and took a
resolver line more specific than the suffix entry - feeding the set with no
port restriction.
The 443 clamp then rests on the forge's published ranges covering that
address, which is the one-layer-where-there-are-two that firewall-up.sh's own
comment warns about. The ranges do cover it today. The last release did not
depend on that: with no line of its own, the name fell to the suffix entry
and was restricted.
Every route into the open class is normalised now, so a harness or backend
domain under a forge suffix cannot arrive the same way. The comment states
the mechanism rather than asserting the outcome.
The test drives the composition with nothing else set, because the shipped
defaults are the configuration every operator has before declaring anything,
and that was the case nothing covered.
dejima_class_for and dejima_mark_internal fold; build_entries compared
verbatim. So a pair the launcher had already accepted was refused one layer
down, with a message saying the name was permitted by nothing - a correct
configuration that will not start, over a spelling difference DNS does not
recognise.
Both halves were substring greps, and the sweeper's own path appears in the
line that chmods it. So a Dockerfile whose last instruction only copies or
chmods the sweeper satisfied the test, and so did a derived layer whose
invocation had been commented out - the sweep could be disabled with the test
green, leaving Debian's setuid set in the image with no_new_privs as the only
lock, which the sweeper's header declines to rely on.
It now asks whether the sweeper is invoked as a command, and reads the
derived Dockerfile the generator produces rather than grepping for a string
in the generator, since the order relative to the layer's own installs is the
whole property.
My first attempt at this was the same defect again - a tighter regex that
still matched `chmod +x <path>`. Three mutations, all of which had passed:
the base invocation removed, the derived one commented out, and the derived
one moved above its installs.
The runtime check also gained the directories the sweeper deliberately does
not walk. --check covers the sweeper's own roots, which is right for proving
the sweep worked and leaves nothing asserting that the image ships no file
capability outside them; the whole-filesystem getcap this replaced did cover
them.
docs/security.md's new rule says what the proxy learned from the provider
stays on the host - and the log holds exactly that, statuses and error-body
prefixes, while living in the socket directory that is bind-mounted read-only
into the session. The session user's uid is the host user's, so the 0644 log
inside the 0700 mount was readable from inside, including token-endpoint
error bodies the container-facing message deliberately withholds.
The log is its own mktemp file now, removed by the exit trap, and the trap
never inherits its name from the environment - same rule as the other names
cleanup() removes but does not create.
The classifier asked whether 'answered N' appeared anywhere and called every
log without one a credential failure. Both halves were wrong. The log carries
text chosen elsewhere - an upstream error-body prefix, an exception echoing
the session's request path - so a token endpoint whose 400 body said
'answered 503' minted a status count and the retry caveat for a session that
made no request. And a log whose only lines were connection failures was
reported as the session being unable to authenticate, sending an operator to
log in again over a network problem.
Three sections now, each on its own evidence: statuses counted from the
proxy's anchored 'upstream answered N for' lines, the credential section from
its 'cannot load|refresh credentials:' lines plus the remedy the proxy writes
after them, and anything else shown under a header that claims neither. A
session that earned both gets both.
The two new cases are driven like the existing pair, against logs the proxy
really wrote - the fixture's oauth-reject gained ?say= so a test can choose
the upstream's text.
df7a744 fixed the comparison in build_entries; the exemption decision one
function up still compared INTERNAL_OK to the entry verbatim, on case and on
a trailing dot. A pair spelled differently passed the launcher's folded check
and build_entries' folded check, was recorded strict here, and the boot then
refused the name's internal answer - telling the operator to configure the
exemption they had already configured.
The existing test now spells the two sides apart in case and dot both, and
asserts the exemption that was recorded rather than only that the boot
proceeded.
The verdict rides on which NFLOG group a rule named, and nothing asserted
either value - the two groups swapped would have logged every refusal as
"allowed" with the suite green, and a supervisor reading the log would
believe it. The refused probe now has to be recorded as blocked, and a
permitted attempt - the bridge gateway, which is in @host_v4 whether or not
the host answers on the port - has to be recorded as allowed, closing the
half the comment promised and the test never drove.
Not run here: this is test/runtime.bats, which needs Docker. CI's privileged
job is what exercises it.
Setuid gets a whole-filesystem find in the derived-image test; capabilities
were covered only inside the sweeper's own roots plus the four directories it
skips, so one at a top-level path nobody enumerated was asserted by nothing.
-xdev keeps the walk to the image itself - /proc, /sys and the bind mounts
are their own filesystems - and a missing getcap prints a marker rather than
an empty result that reads as clean.
Not run here: test/runtime.bats needs Docker. CI's privileged job is what
exercises it.
test/fixtures/fake-anthropic.py is executed as proxy.bats' upstream, but
test/* mapped every non-bats test file to static alone, so a fixture-only
change ran a suite that never touches it and skipped the one that does.
bin/dejima-allow likewise: static.bats asserts on its class wiring, and the
project-only rule skipped the suite holding that guard.
runtime.bats consumes the fixture too, but no confined selection can carry
it - that half stays CI's.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary generated by LLM agent
Moves
developmenttomain: 84 commits across 12 merges, 54 files, ~5800 insertions. Two clean-room rewrites of the parts of this project that came from Anthropic's reference dev container, and the fixes that seven review rounds turned up.What is in it
The egress firewall, rewritten (#341).
init-firewall.shanddns-proxy.share gone;docker/egress-policyreplaces them. Onenftablestable loaded in a single transaction rather than a sequence ofiptablescalls, so the namespace is never half-filtered. Two permit classes, forge and open, with the restriction attached to the destination rather than to the path it was reached by. IPv6 is refused as a family, first rule in the chain, checked at startup rather than assumed.The container image, rewritten (#340). The shell is bash, configured by a snippet in the image rather than a framework fetched at build time. The npm global prefix moved to
/opt/node-tools, with a root-levelnode_modulessymlink so ESM resolves as well as CommonJS. Every setuid bit and file capability is stripped as the last instruction of the image and again at the end of a derived per-project layer, and a survivor fails the build.The provenance note is gone from the README and from
hooks/sandbox-context.sh. That removal is a claim about the whole repository, and the measurement behind it is on #340: 23 shared lines with the reference, all shell keywords, Debian package names and single-spelling Docker idioms, and zero shared comments. Astatic.batstest now fails if the tree reacquires a citation of that kind.A bare label could be approved as a domain, and cannot now. On current
main,valid_domainaccepts a single label andallowed-domainsis validated with it, sodejima-allow --global comis accepted, becomes aserver=/com/line, and dnsmasq suffix-matches every.comname into the open class, which carries no port restriction.valid_permitted_domainnow requires two labels, at the grant path. This is the most consequential single change in the release and it landed late, via #349, #351 and #355 — the route was not straight, and #355's reasoning is the part worth keeping: applying the rule on the read path made an upgrading operator's existing store fail with a "corrupt approval entry" message, which is an upgrade presenting as tampering with noremoveverb to recover from. Tightening belongs where granting happens.The credential proxy's log left the directory the session mounts (#359). The socket directory is bind-mounted read-only into the session so the socket is reachable, and the log lived inside it. The log holds what the proxy learned from the provider - statuses, error-body prefixes from the token endpoint - which is exactly what the container-facing message withholds by design, and the session user's uid is the host user's, so a 0644 file inside a 0700 directory was readable from in there. This is not a regression:
mainhas the same arrangement. It became a stated contradiction only when this release wrote the rule down, and closing it is the point of writing rules down.Every permitted name is checked into its class's address set at boot (#356). That was previously asserted for one destination, the forge metadata host, which is the one hardest to get wrong. The class an entry carries, the resolver line built from it, and the set the ports are keyed to are three separate statements that can each be right about a different set, and reading the code cannot tell them apart — every defect of that shape in this release reached a green suite. A name that reaches both class lists is also recorded once now, in the narrower one.
Fixes from the review rounds: #346 (root's shell taking its history from a volume the session user owns; the boot path not asking a domain's class), #349 (two boot-stopping regressions introduced by that fix), #351 (reverting dotless acceptance, which could not work and granted a suffix, plus case-folding domain comparisons), #353 (the forge tools telling a sandbox to create a token file it must not create), #354 (a documentation pass that found five drifted claims), #355 and #356 as above, #358 (an expired credential now names the login that fixes it, in the session and in the exit audit).
Rollback
Two things make this not cleanly reversible once adopted.
It adds one config key,
core.internal_domains. An operator who sets it and rolls back to the currentmaingets a launch that refuses to start, because an unknown key fails validation. Remove the key first.And it makes a previously valid configuration refuse to launch. A permitted destination must now be fully qualified — an approved domain,
[core] internal_domains, a forge'sssh_hostorapi, a backend'sbase_url,[logging] loki_url. Onmaina single-label value worked: that resolver forwarded to Docker's embedded resolver, which answers for container and compose service names. An operator upgrading withloki_url = http://loki:3100gets a refused launch naming the key, rather than a sandbox that will not start — better, but it is a break, and it is the more likely of the two to be met. #352 records the decision and what supporting single-label names again would take..dejima/allowed-domainsis byte-identical tomain, so nothing here widens what a hardened CI runner may reach.What was run
All ten confined suites and
shellcheck, green on this merge commit.test/runtime.batshas run and passed on this commit. All fourteen checks on6522ad5are green,tests / privilegedamong them.That claim has now been wrong twice in this description's life, both times in the reader's favour, because the branch moved under it and the sentence did not. If more lands before this merges, check the checks rather than this paragraph.
Exercised by hand, on a live session of this candidate
A sandbox running this exact tip was used to check the things only a real session shows. It came up, which is the strongest single result: the session starts only after the filter container's readiness file, which is written only after
egress-policy applyreturns, which runs every boot self-check - including the one added in #356 that refuses to start when a permitted name's address is missing from its class's set. A new fatal check did not false-positive on a real operator's configuration.From inside it:
That last line is #359's fix confirmed end to end. The directory is listable from the session, which is exactly what made the log readable while it lived there.
A Cline session against OpenRouter was also run on this candidate, and worked. That is the other harness and a backend that is not Anthropic, so between the two runs both harnesses have now been launched against a real model on this tip - and the second one exercises a different path through the release: the npm prefix the image rewrite moved, and a backend reached by proxied domain rather than by the OAuth refresh the first one uses.
Not covered by either: the credential proxy's own failure paths, the exit audit (which prints when a session ends), and everything below.
What was run by hand
The maintainer exercised the manual list against this candidate, and it worked: a real session over its full life, the refusals tried by the route an agent would take, Ctrl-Z and
fgon a real session rather than the stand-in the tests use, both harnesses against real models, and the web front door attached and detached. That is the second half of this release's gate, and it is the half nothing automated stands in for.What was not run, and will not be
A pull request through the GitHub forge. It has never been run end to end, here or before, and it is not going to be for this release: the maintainer develops nothing on GitHub, so there is no real repository to try it against.
docs/providers.mdsays the same where someone choosing a forge will meet it, and this release does not change that.What that leaves unverified is the
githubforge's own path -gh pr createthrough the wrapper, the body that arrives, and the push grant opening port 22 in a session that actually pushes. The firewall half of it is covered:runtime.batsasserts port 22 is refused by default, thatALLOW_GITHUB_PUSH=1opens it, and that a GitHub name declared as an extra domain stays port-restricted. What is untested is the forge CLI on the other side of that port.This is a stated decision rather than an omission, which is the distinction that matters to whoever reads it later: an unverified path that is named is one somebody can reach for when a bug turns up.
Deferred out of this release
#345 carries three items, knowingly left: a boot self-check whose retry does not cover the failure it was written for, a non-atomic rollback in
permit_address, and several tests asserting field names where they mean values. #350 carries three host-side items of the same weight. #357 records that the privileged CI job trips GitHub's secondary rate limit on/meta, which is a re-run rather than a defect. None of them widens anything; they are named here because this description is what a reader ofmain's history will find.