Compare commits

...
Author SHA1 Message Date
branderson 90222214e4 Ask which class an approved domain is before permitting it at boot
tests / confined (conversation) (pull_request_target) Successful in 1m5s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (providers) (pull_request_target) Successful in 1m55s
tests / confined (launch) (pull_request_target) Successful in 2m4s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (share) (pull_request_target) Successful in 1m53s
tests / confined (remote) (pull_request_target) Successful in 2m45s
tests / confined (shellcheck) (pull_request_target) Successful in 1m17s
tests / confined (project) (pull_request_target) Successful in 3m9s
tests / confined (static) (pull_request_target) Successful in 2m54s
tests / privileged (pull_request_target) Successful in 6m25s
tests / confined (audit) (pull_request_target) Successful in 3m29s
tests / confined (config) (pull_request_target) Successful in 4m42s
tests / runner-env (privileged) (pull_request_target) Successful in 49s
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.
2026-09-13 17:06:57 -07:00
branderson 6bee272d5c Keep root's shell out of the session user's history file
/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.
2026-09-13 17:04:26 -07:00
branderson 0516676fce Merge pull request 'Replace the image environment inherited from the reference dev container, clean-room' (#340) from cleanroom-image into development
Reviewed-on: #340
Reviewed-by: branderson <brad@branderson.io>
2026-09-13 14:42:34 -07:00
branderson 9f4b9a9011 Check the claim the removed provenance note rests on
tests / confined (conversation) (pull_request_target) Successful in 1m40s
tests / confined (proxy) (pull_request_target) Successful in 1m42s
tests / confined (providers) (pull_request_target) Successful in 2m26s
tests / confined (launch) (pull_request_target) Successful in 2m35s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (share) (pull_request_target) Successful in 1m52s
tests / confined (remote) (pull_request_target) Successful in 3m15s
tests / confined (shellcheck) (pull_request_target) Successful in 1m17s
tests / confined (project) (pull_request_target) Successful in 3m43s
tests / confined (audit) (pull_request_target) Successful in 4m5s
tests / confined (static) (pull_request_target) Successful in 2m57s
tests / confined (config) (pull_request_target) Successful in 5m23s
tests / privileged (pull_request_target) Successful in 12m16s
tests / runner-env (privileged) (pull_request_target) Successful in 56s
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.
2026-09-13 11:29:01 -07:00
branderson 1009810c52 Stop the briefing hook citing a reference the container is not based on
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.
2026-09-13 11:27:49 -07:00
branderson f20075941b Make "a proxied backend has no host to permit" true by construction
tests / confined (conversation) (pull_request_target) Successful in 1m2s
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (providers) (pull_request_target) Successful in 1m52s
tests / confined (launch) (pull_request_target) Successful in 2m0s
tests / runner-env (confined) (pull_request_target) Successful in 49s
tests / confined (share) (pull_request_target) Successful in 1m50s
tests / confined (remote) (pull_request_target) Successful in 2m41s
tests / confined (shellcheck) (pull_request_target) Successful in 1m13s
tests / confined (project) (pull_request_target) Successful in 3m6s
tests / confined (audit) (pull_request_target) Successful in 3m25s
tests / confined (static) (pull_request_target) Successful in 2m50s
tests / confined (config) (pull_request_target) Successful in 4m45s
tests / privileged (pull_request_target) Successful in 6m35s
tests / runner-env (privileged) (pull_request_target) Successful in 46s
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.
2026-09-13 01:30:50 -07:00
branderson 5e7d1d2fbb Report the status a retried check actually returned
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.
2026-09-13 01:30:50 -07:00
branderson 666f2427e1 Retry the self-checks that assert reachability, not the ones that deny it
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.
2026-09-13 01:30:50 -07:00
branderson ce6e739201 Keep the harness wrapper ahead of the binary it wraps on PATH
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.
2026-09-13 01:30:50 -07:00
branderson 1a7c22764a Reach a self-served model without approving its name as a domain
tests / confined (conversation) (pull_request_target) Successful in 57s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (providers) (pull_request_target) Successful in 1m56s
tests / confined (launch) (pull_request_target) Successful in 2m3s
tests / confined (share) (pull_request_target) Successful in 1m57s
tests / runner-env (confined) (pull_request_target) Successful in 50s
tests / confined (remote) (pull_request_target) Successful in 2m47s
tests / confined (shellcheck) (pull_request_target) Successful in 1m16s
tests / confined (project) (pull_request_target) Successful in 3m14s
tests / confined (audit) (pull_request_target) Successful in 3m35s
tests / confined (static) (pull_request_target) Successful in 3m0s
tests / confined (config) (pull_request_target) Successful in 4m55s
tests / privileged (pull_request_target) Successful in 6m26s
tests / runner-env (privileged) (pull_request_target) Successful in 46s
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.
2026-09-12 22:00:40 -07:00
branderson e95eb49cc9 Ask the upstream as the account that may reach it, not as root
tests / confined (conversation) (pull_request_target) Successful in 1m5s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (providers) (pull_request_target) Successful in 1m57s
tests / confined (launch) (pull_request_target) Successful in 2m2s
tests / runner-env (confined) (pull_request_target) Successful in 53s
tests / confined (share) (pull_request_target) Successful in 1m57s
tests / confined (remote) (pull_request_target) Successful in 2m53s
tests / confined (shellcheck) (pull_request_target) Successful in 1m22s
tests / confined (project) (pull_request_target) Successful in 3m19s
tests / confined (audit) (pull_request_target) Successful in 3m40s
tests / confined (static) (pull_request_target) Successful in 3m6s
tests / confined (config) (pull_request_target) Successful in 4m55s
tests / privileged (pull_request_target) Successful in 6m14s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
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.
2026-09-12 20:56:15 -07:00
branderson a75bf8a5a5 Let the v6 test read rules, not the prose explaining them
tests / confined (conversation) (pull_request_target) Failing after 29s
tests / confined (project) (pull_request_target) Failing after 29s
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (providers) (pull_request_target) Successful in 1m57s
tests / confined (share) (pull_request_target) Successful in 1m54s
tests / confined (launch) (pull_request_target) Successful in 2m7s
tests / confined (shellcheck) (pull_request_target) Successful in 1m17s
tests / runner-env (confined) (pull_request_target) Successful in 47s
tests / confined (remote) (pull_request_target) Successful in 2m35s
tests / confined (audit) (pull_request_target) Successful in 3m16s
tests / confined (static) (pull_request_target) Successful in 3m6s
tests / confined (config) (pull_request_target) Successful in 4m31s
tests / privileged (pull_request_target) Successful in 6m29s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
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.
2026-09-12 19:32:02 -07:00
branderson 9467159c8f Drop resolve_one's unreachable IPv6 alternation
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.
2026-09-12 19:13:04 -07:00
branderson fdd1bc68ae Make the AAAA check prove it meant something
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.
2026-09-12 19:12:16 -07:00
branderson 72d7fe8fea Check the inbound chain's IPv6 posture too, not just the outbound one
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.
2026-09-12 19:10:45 -07:00
branderson 0b6012a78c Refuse IPv6 outright again, rather than allow-listing it beside IPv4
tests / confined (conversation) (pull_request_target) Successful in 59s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (providers) (pull_request_target) Successful in 1m55s
tests / confined (launch) (pull_request_target) Successful in 2m4s
tests / confined (share) (pull_request_target) Successful in 1m54s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (remote) (pull_request_target) Successful in 2m46s
tests / confined (shellcheck) (pull_request_target) Successful in 1m18s
tests / confined (project) (pull_request_target) Successful in 3m13s
tests / confined (audit) (pull_request_target) Successful in 3m35s
tests / confined (static) (pull_request_target) Successful in 3m1s
tests / confined (config) (pull_request_target) Successful in 4m51s
tests / runner-env (privileged) (pull_request_target) Successful in 45s
tests / privileged (pull_request_target) Successful in 6m16s
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.
2026-09-12 18:39:16 -07:00
branderson 9370e88ebe Stop the probe listener by pid, not by matching its command line
tests / confined (conversation) (pull_request_target) Successful in 57s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (providers) (pull_request_target) Successful in 1m52s
tests / confined (launch) (pull_request_target) Successful in 1m59s
tests / confined (share) (pull_request_target) Successful in 1m55s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / confined (remote) (pull_request_target) Successful in 2m42s
tests / confined (shellcheck) (pull_request_target) Successful in 1m13s
tests / confined (project) (pull_request_target) Successful in 3m7s
tests / confined (audit) (pull_request_target) Successful in 3m26s
tests / confined (static) (pull_request_target) Successful in 2m52s
tests / confined (config) (pull_request_target) Successful in 4m46s
tests / privileged (pull_request_target) Successful in 5m49s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
`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.
2026-09-12 17:19:39 -07:00
branderson 1cc6744e2e Put --network on its own line, which is what counts it
tests / confined (conversation) (pull_request_target) Successful in 1m4s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (providers) (pull_request_target) Successful in 1m54s
tests / confined (launch) (pull_request_target) Successful in 1m57s
tests / runner-env (confined) (pull_request_target) Successful in 50s
tests / confined (share) (pull_request_target) Successful in 1m49s
tests / confined (remote) (pull_request_target) Successful in 2m43s
tests / confined (shellcheck) (pull_request_target) Successful in 1m13s
tests / confined (project) (pull_request_target) Successful in 3m7s
tests / confined (audit) (pull_request_target) Successful in 3m27s
tests / confined (static) (pull_request_target) Successful in 2m50s
tests / confined (config) (pull_request_target) Successful in 4m43s
tests / privileged (pull_request_target) Failing after 6m27s
tests / runner-env (privileged) (pull_request_target) Successful in 46s
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.
2026-09-12 15:34:16 -07:00
branderson 077f37580f Repair prose two in-place edits left behind
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.
2026-09-12 15:05:17 -07:00
branderson b651709cae Cover [core] internal_domains, which gates whether a check applies
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.
2026-09-12 15:04:44 -07:00
branderson 9b61708ace Drop privilege on the lookup logger too
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.
2026-09-12 14:54:50 -07:00
branderson 8fcc16eaf3 Give inbound and forwarded traffic the closed default they had
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.
2026-09-12 14:52:03 -07:00
branderson ca9c03b88d Make [core] internal_domains exempt a destination, not grant one
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.
2026-09-12 14:48:54 -07:00
branderson 7c7b2ec533 Decide one rule for which names are exempt, and use it on both sides
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.
2026-09-12 14:47:46 -07:00
branderson 290a50b878 Correct docs/security.md, which still describes the firewall that was replaced
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.
2026-09-12 14:26:42 -07:00
branderson 36eb1f1644 Merge pull request 'Replace the egress firewall, clean-room, and remove the provenance note' (#341) from cleanroom-egress into cleanroom-image
tests / confined (conversation) (pull_request_target) Successful in 59s
tests / confined (proxy) (pull_request_target) Successful in 1m4s
tests / confined (providers) (pull_request_target) Successful in 1m50s
tests / confined (launch) (pull_request_target) Successful in 1m54s
tests / runner-env (confined) (pull_request_target) Successful in 50s
tests / confined (shellcheck) (pull_request_target) Successful in 1m13s
tests / privileged (pull_request_target) Successful in 5m42s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
tests / confined (share) (pull_request_target) Successful in 1m51s
tests / confined (remote) (pull_request_target) Successful in 2m40s
tests / confined (project) (pull_request_target) Successful in 3m4s
tests / confined (audit) (pull_request_target) Successful in 3m25s
tests / confined (static) (pull_request_target) Successful in 2m54s
tests / confined (config) (pull_request_target) Successful in 4m41s
Reviewed-on: #341
Reviewed-by: branderson <brad@branderson.io>
2026-09-12 13:55:01 -07:00
branderson edbeab1cc8 Fix two runtime assertions that were wrong about their own output
tests / confined (conversation) (pull_request_target) Successful in 1m2s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (providers) (pull_request_target) Successful in 1m54s
tests / confined (launch) (pull_request_target) Successful in 1m58s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (share) (pull_request_target) Successful in 1m52s
tests / confined (remote) (pull_request_target) Successful in 2m45s
tests / confined (shellcheck) (pull_request_target) Successful in 1m15s
tests / confined (project) (pull_request_target) Successful in 3m8s
tests / confined (audit) (pull_request_target) Successful in 3m28s
tests / confined (static) (pull_request_target) Successful in 2m54s
tests / confined (config) (pull_request_target) Successful in 4m46s
tests / privileged (pull_request_target) Successful in 6m6s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
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.
2026-09-12 13:27:41 -07:00
branderson 25b5dd48ee Let the runtime's own resolver forward onwards, which every lookup depends on
tests / confined (conversation) (pull_request_target) Successful in 1m5s
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (providers) (pull_request_target) Successful in 1m57s
tests / confined (launch) (pull_request_target) Successful in 2m3s
tests / runner-env (confined) (pull_request_target) Successful in 50s
tests / confined (share) (pull_request_target) Successful in 1m56s
tests / confined (remote) (pull_request_target) Successful in 2m50s
tests / confined (shellcheck) (pull_request_target) Successful in 1m16s
tests / confined (project) (pull_request_target) Successful in 3m15s
tests / confined (audit) (pull_request_target) Successful in 3m37s
tests / confined (static) (pull_request_target) Failing after 3m0s
tests / confined (config) (pull_request_target) Successful in 4m58s
tests / privileged (pull_request_target) Failing after 7m42s
tests / runner-env (privileged) (pull_request_target) Successful in 48s
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.
2026-09-12 12:42:07 -07:00
branderson a7658e790b Wait for the resolver to answer, and say why when a permitted name does not
tests / confined (conversation) (pull_request_target) Successful in 1m5s
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / privileged (pull_request_target) Failing after 1m29s
tests / runner-env (privileged) (pull_request_target) Successful in 46s
tests / confined (providers) (pull_request_target) Successful in 2m4s
tests / confined (launch) (pull_request_target) Successful in 2m10s
tests / confined (share) (pull_request_target) Successful in 2m1s
tests / confined (remote) (pull_request_target) Successful in 2m57s
tests / confined (audit) (pull_request_target) Successful in 3m45s
tests / confined (static) (pull_request_target) Successful in 3m5s
tests / confined (config) (pull_request_target) Successful in 5m10s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / confined (shellcheck) (pull_request_target) Successful in 1m16s
tests / confined (project) (pull_request_target) Successful in 3m23s
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.
2026-09-12 12:10:26 -07:00
branderson 0d0b3d8cde Stop piping into readers that exit early, which fails when they match
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / confined (shellcheck) (pull_request_target) Successful in 1m14s
tests / confined (static) (pull_request_target) Successful in 2m45s
tests / confined (config) (pull_request_target) Successful in 4m56s
tests / confined (share) (pull_request_target) Successful in 1m53s
tests / confined (remote) (pull_request_target) Successful in 2m50s
tests / confined (project) (pull_request_target) Successful in 3m16s
tests / confined (audit) (pull_request_target) Successful in 3m36s
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / privileged (pull_request_target) Failing after 1m25s
tests / confined (conversation) (pull_request_target) Successful in 1m40s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (providers) (pull_request_target) Successful in 2m1s
tests / confined (launch) (pull_request_target) Successful in 2m6s
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.
2026-09-12 04:59:54 -07:00
branderson 8812232e00 Remove the provenance note, and describe what is actually there
tests / confined (conversation) (pull_request_target) Successful in 1m4s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (providers) (pull_request_target) Successful in 1m55s
tests / confined (share) (pull_request_target) Successful in 1m54s
tests / confined (remote) (pull_request_target) Successful in 2m45s
tests / confined (shellcheck) (pull_request_target) Successful in 1m15s
tests / confined (launch) (pull_request_target) Successful in 1m58s
tests / runner-env (confined) (pull_request_target) Successful in 49s
tests / confined (project) (pull_request_target) Successful in 3m9s
tests / confined (audit) (pull_request_target) Successful in 3m32s
tests / privileged (pull_request_target) Failing after 3m47s
tests / confined (static) (pull_request_target) Successful in 3m13s
tests / runner-env (privileged) (pull_request_target) Successful in 45s
tests / confined (config) (pull_request_target) Successful in 4m49s
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.
2026-09-12 04:07:15 -07:00
branderson 842cfd7a4d Pin the policy's properties, and stop pinning the firewall's mechanism
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.
2026-09-12 04:07:03 -07:00
branderson 4b24065438 Ask the resolver whether a declared domain is permitted, not a file
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.
2026-09-12 04:07:03 -07:00
branderson 886b652133 Give an operator a way to declare a domain that really is internal
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.
2026-09-12 04:06:43 -07:00
branderson ec875dcbe2 Add a domain to a running session through the policy, not around it
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.
2026-09-12 04:06:43 -07:00
branderson 18905fd8d0 Remove the firewall the egress policy replaces
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.
2026-09-12 04:06:29 -07:00
branderson 612a641893 Point the session and the log shipper at what the policy produces
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.
2026-09-12 04:06:29 -07:00
branderson bddd13ee04 Compose the permitted set by class, so ports follow the destination
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.
2026-09-12 04:06:29 -07:00
branderson bdde256123 Add the egress policy, and the toolchain it is written against
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.
2026-09-12 04:06:10 -07:00
branderson 9f5dd0148e Pin the new image shape, and stop pinning the old one
tests / confined (conversation) (pull_request_target) Successful in 1m2s
tests / confined (proxy) (pull_request_target) Successful in 1m17s
tests / confined (launch) (pull_request_target) Successful in 2m10s
tests / confined (providers) (pull_request_target) Successful in 2m8s
tests / runner-env (confined) (pull_request_target) Successful in 53s
tests / confined (share) (pull_request_target) Successful in 2m8s
tests / confined (remote) (pull_request_target) Successful in 3m2s
tests / confined (shellcheck) (pull_request_target) Successful in 1m15s
tests / confined (project) (pull_request_target) Successful in 3m26s
tests / confined (audit) (pull_request_target) Successful in 3m48s
tests / confined (static) (pull_request_target) Successful in 3m3s
tests / confined (config) (pull_request_target) Successful in 5m10s
tests / privileged (pull_request_target) Successful in 9m14s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
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.
2026-09-12 02:02:50 -07:00
branderson 59e0d5e8fc Remove the Dockerfile's attribution header
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.
2026-09-12 01:50:11 -07:00
branderson 311310f4bd Strip every setuid bit and file capability, and fail the build on a survivor
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.
2026-09-12 01:49:29 -07:00
branderson eb9970d471 Stop telling the session it is a generic development container
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.
2026-09-12 01:47:31 -07:00
branderson bbc23b0881 Configure the session shell in this image, not from a download
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.
2026-09-12 01:47:20 -07:00
branderson fb203206db Move shell history under /var/opt, and give the directory a mode
/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.
2026-09-12 01:45:39 -07:00
branderson b20a0bc9b8 Give globally installed Node tools a directory of their own
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.
2026-09-12 01:44:58 -07:00
branderson ed15a30d1d Sort the tool list, and make an unknown timezone fail the build
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.
2026-09-12 01:43:17 -07:00
branderson 9ef51488ad Merge pull request 'Name the script that joins the forge's hosts into the allowlist' (#336) from release-pass-check into development
tests / confined (conversation) (pull_request_target) Successful in 1m1s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (share) (pull_request_target) Successful in 1m51s
tests / confined (providers) (pull_request_target) Successful in 1m53s
tests / confined (remote) (pull_request_target) Successful in 2m39s
tests / confined (project) (pull_request_target) Successful in 3m4s
tests / confined (audit) (pull_request_target) Successful in 3m25s
tests / privileged (pull_request_target) Successful in 4m36s
tests / confined (shellcheck) (pull_request_target) Successful in 1m16s
tests / runner-env (confined) (pull_request_target) Successful in 49s
tests / confined (static) (pull_request_target) Successful in 2m59s
tests / confined (config) (pull_request_target) Successful in 4m43s
tests / runner-env (privileged) (pull_request_target) Successful in 46s
Reviewed-on: #336
Reviewed-by: branderson <brad@branderson.io>
2026-09-11 14:51:40 -07:00
branderson c637d1dee6 Name the script that joins the forge's hosts into the allowlist
tests / confined (conversation) (pull_request_target) Successful in 1m2s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (providers) (pull_request_target) Successful in 2m0s
tests / confined (launch) (pull_request_target) Successful in 2m4s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / confined (share) (pull_request_target) Successful in 1m59s
tests / confined (remote) (pull_request_target) Successful in 2m55s
tests / confined (shellcheck) (pull_request_target) Successful in 1m17s
tests / confined (project) (pull_request_target) Successful in 3m28s
tests / confined (audit) (pull_request_target) Successful in 4m16s
tests / confined (static) (pull_request_target) Successful in 4m6s
tests / privileged (pull_request_target) Successful in 4m35s
tests / runner-env (privileged) (pull_request_target) Successful in 43s
tests / confined (config) (pull_request_target) Successful in 7m43s
`entrypoint.sh` did that until the egress control moved to a container of its
own. It still exists and still runs; what it no longer does is this.

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

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

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

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

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

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

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

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

Three things the wiring could not skip:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

KILL was in this list for `dejima-allow --container`, which signals dnsmasq.
That reason moved to the filter container with dnsmasq and the capability went
with it - and this second reason, which had been quietly carried by the same
line for as long as `init: true` has been set, went unnoticed.
2026-09-10 20:49:50 -07:00
branderson 8fb4a4d39b Merge pull request 'Take either half of the pair, because a briefing hands over the filter's id' (#332) from allow-pair into filter-container
tests / confined (conversation) (pull_request_target) Successful in 1m2s
tests / confined (proxy) (pull_request_target) Successful in 1m6s
tests / confined (share) (pull_request_target) Successful in 1m49s
tests / confined (shellcheck) (pull_request_target) Successful in 1m12s
tests / confined (remote) (pull_request_target) Successful in 2m32s
tests / confined (config) (pull_request_target) Successful in 4m27s
tests / confined (providers) (pull_request_target) Successful in 1m49s
tests / runner-env (confined) (pull_request_target) Successful in 49s
tests / confined (project) (pull_request_target) Successful in 2m57s
tests / confined (audit) (pull_request_target) Successful in 3m15s
tests / confined (static) (pull_request_target) Successful in 2m45s
tests / privileged (pull_request_target) Successful in 4m26s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
Reviewed-on: #332
Reviewed-by: branderson <brad@branderson.io>
2026-09-10 19:47:33 -07:00
48 changed files with 4948 additions and 1720 deletions
+3 -2
View File
@@ -402,7 +402,7 @@ jobs:
# hour to 5000, and nothing about a run looks different when it stops
# doing that. A misspelled secret name, a revoked token and an expiry a
# year from now all present identically: green for a while, then
# containers failing to boot in init-firewall.sh - which reads as a
# containers failing to boot in docker/egress-policy - which reads as a
# regression in the firewall rather than as a lapsed credential. So ask
# the API what the ceiling actually is, rather than checking that a
# variable is non-empty and assuming the rest.
@@ -472,7 +472,8 @@ jobs:
env:
SKIP_BUILD: 1
# Every container this suite starts fetches GitHub's IP ranges from
# api.github.com/meta in init-firewall.sh, and unauthenticated that
# api.github.com/meta (fetch_forge_ranges in docker/egress-policy),
# and unauthenticated that
# endpoint allows 60 requests an hour per source IP - shared with
# everything else on this runner host. Thirteen starts per run means
# two runs an hour exhausts it, after which containers fail to boot
+19 -17
View File
@@ -398,11 +398,13 @@ behind each of these, and the fork-and-review workflow in full.
## Network egress firewall
Outbound access is default-deny: the container builds an allowlist-only
`iptables`/`ipset` firewall at start, and anything not explicitly allowed is
rejected outright rather than left to time out, so a session is told when
something doesn't connect. IPv6 is blocked outright, with no allowlist
equivalent for it.
Outbound access is default-deny: the filter container establishes an
allowlist-only `nftables` policy at start, and anything not explicitly allowed
is rejected outright rather than left to time out, so a session is told when
something doesn't connect rather than waiting out a timeout and reporting a
broken tool. IPv6 is refused outright rather than allowlisted: a sandbox has no
IPv6 egress at all, and one table states that in the same transaction as
everything else, so there is no moment where the family is partly filtered.
The allowlist covers what the sandbox needs to function - DNS, the Docker host,
GitHub on port 443 for read access whichever forge is selected, whatever the
@@ -428,8 +430,8 @@ the one that patches a live session. `dejima --no-firewall` disables the
firewall for one launch (debugging only).
**→ [docs/security.md](docs/security.md#network-egress-firewall)** - how the
ipsets are built, how `dnsmasq` is configured, and the NFLOG groups behind the
request log below.
address sets are built, how the resolver is restricted, and the two records
behind the request log below.
## Global and per-project domains and dependencies
@@ -531,13 +533,16 @@ name, on a queue its maintainers are already drowning in.
The sandbox can always read from GitHub. By default it can never push there:
GitHub's IP ranges are only allowlisted on port 443, so port 22 is physically
unreachable rather than merely discouraged by a hook. That rests on **two**
properties, both worth naming because the claim stays readable if either
quietly stops being true. The allowlist decides what is reachable over IPv4;
what makes port 22 unreachable *at all* is that the container also blocks IPv6
outright, since an allowlist naming one address family says nothing about the
other. `init-firewall.sh` refuses to start rather than run with IPv6 live and
unfilterable.
unreachable rather than merely discouraged by a hook. Two properties carry
that, both worth naming because the claim stays readable if either quietly
stops being true. The restriction belongs to the *destination* rather than to
the path it was reached by, so resolving a GitHub name cannot open a port its
published ranges do not have. And IPv6 is refused as a whole family, because an
allowlist naming one address family says nothing about the other - so "port 22
is closed on those ranges" would mean nothing if a v6 route to the same host
stayed open. The refusal is the first rule of the chain and there is no v6
permit path anywhere in it, which the policy checks at startup rather than
assuming.
`--allow-github-push` opens port 22 for one launch, paired with a repo-scoped
deploy key in `[core] ssh_key` rather than a forwarded personal agent. The one
@@ -710,6 +715,3 @@ The dejima wordmark and logo are not code and are not covered by that licence.
They are trademarks; who holds them and what may be done with them is stated
once for the organisation, in [ATTRIBUTION.md](ATTRIBUTION.md).
The egress firewall's original shape came from
[Anthropic's reference dev container](https://github.com/anthropics/claude-code/tree/main/.devcontainer).
`docker/init-firewall.sh` records what it kept and what it did not.
+204 -15
View File
@@ -645,6 +645,10 @@ unset _tools_wanted _tool
AUDIT_DIR="$(config_get audit dir)"
BASELINE_DIR="$(config_get audit baselines_dir)"
# Read before either guard runs, because what this list declares becomes
# something the other two lists are refused for naming - see PP_PROJECT_ROOTS.
SANDBOX_RW_PP_MOUNTS="$(config_get mounts read_write_per_project)"
SANDBOX_EXTRA_MOUNTS="$(config_get mounts readonly)"
SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
${EXTRA_RO_FLAGS[@]+"${EXTRA_RO_FLAGS[@]}"})"
@@ -745,6 +749,30 @@ _harness_host_config
# arrives at ~/shared without going through this list; naming the root that
# holds every project's would put the pile back, which is the thing the split
# removed.
# Refuse $2 if it reaches a per-project root. Its own check rather than a row
# in GUARDED_STORES, because the generic refusal there is about approval and
# audit stores and says so - an operator who wrote the same path into two
# mount lists would be told their specs directory lets a session approve its
# own domains, which is both untrue and no help.
_refuse_pp_root() {
local label="$1" path="$2" real root root_real
_pp_project_roots
[ ${#PP_PROJECT_ROOTS[@]} -gt 0 ] || return 0
real="$(readlink -m "$path")"
for root in "${PP_PROJECT_ROOTS[@]}"; do
root_real="$(readlink -m "$root")"
_paths_overlap "$real" "$root_real" || continue
echo "Refusing $label: $path" >&2
echo "It reaches $root_real, which holds one directory per project for a" >&2
echo "[mounts] read_write_per_project entry." >&2
echo "" >&2
echo "Handing that over is handing over every project's, which is the pile" >&2
echo "slicing it removed. If this path is meant to be shared by every" >&2
echo "project, it belongs in one list or the other and not in both." >&2
exit 1
done
}
GUARDED_STORES=()
_guarded_stores() {
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
@@ -753,6 +781,28 @@ _guarded_stores() {
"$(share_projects_root "$(config_get core share_dir)")")
}
# The `projects` subdirectory of every read_write_per_project root: the
# directory that holds one slice per project, exactly as share_dir's does.
#
# Guarded separately from the stores above because the two lists need
# different answers. For `readonly` and `read_write`, naming one of these is
# the mistake the share guard already names: handing over the root that holds
# every project's directory puts back the pile the split removed. For the
# per-project list itself it is not a mistake at all - it is where that list
# puts things - so that loop consults GUARDED_STORES alone.
PP_PROJECT_ROOTS=()
_pp_project_roots() {
[ ${#PP_PROJECT_ROOTS[@]} -eq 0 ] || return 0
local path
[ -n "$SANDBOX_RW_PP_MOUNTS" ] || return 0
IFS=':' read -ra _pp_declared <<< "$SANDBOX_RW_PP_MOUNTS"
for path in "${_pp_declared[@]}"; do
[ -n "$path" ] || continue
PP_PROJECT_ROOTS+=("$(projects_root "$path")")
done
unset _pp_declared
}
# Refuse $2 if it overlaps any of the paths in $3.., in either direction: an
# ancestor of a store exposes it just as surely as the store itself.
_refuse_guarded_mount() {
@@ -802,6 +852,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
# epitaph store be mountable from here after it was added to one of
# them. A store added to _guarded_stores is now guarded on both
# paths without anyone remembering to do it twice.
_refuse_pp_root "$(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path")" "$path"
_guarded_stores
for guarded in ${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"} \
${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"} \
@@ -837,6 +888,78 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
done
fi
# [mounts] read_write_per_project: the same idea as read_write, sliced the way
# [core] share_dir is. An operator declares ~/specs; the container gets ~/specs,
# backed by ~/specs/projects/<slug> on the host rather than by the whole thing.
#
# Which keeps the property that separates these lists from share_dir - a mount
# arrives at the host path it names, so a project instruction saying "put specs
# in ~/specs" goes on working - while removing the one share_dir was split to
# remove: every session seeing every other project's, across projects. A spec
# is written to be read as background, which makes that threat-model item 4
# with nobody attacking anything.
#
# Its own key rather than a marker on read_write, and that is what makes it
# small: a key that did not previously parse has nothing in it on the day it
# lands. Nothing moves, nothing is stranded, and there is no migration. Some
# writable mounts are meant to be shared - a cache, a bin directory - and
# read_write stays right for those.
RW_PP_MOUNT_SOURCES=()
RW_PP_MOUNT_TARGETS=()
DEJIMA_RW_PP_MOUNTS=""
# Every host path this launch makes writable, which for a plain read_write
# entry is the path itself and for a per-project one is the slice. The audit
# reads this; the briefing reads the two target lists.
DEJIMA_RW_SOURCES="$DEJIMA_RW_MOUNTS"
if [ -n "$SANDBOX_RW_PP_MOUNTS" ]; then
IFS=':' read -ra rw_pp_paths <<< "$SANDBOX_RW_PP_MOUNTS"
for path in "${rw_pp_paths[@]}"; do
[ -n "$path" ] || continue
# The declared root must exist, like a read_write entry: dejima creates
# the slice below it, never the root itself, so a typo stays a typo
# rather than becoming a new empty directory nobody meant.
if [ ! -d "$path" ]; then
echo "[mounts] read_write_per_project entry is not an existing directory: $path" >&2
echo "dejima creates the per-project directory inside it, but not the" >&2
echo "directory itself - so a path that is not there is a typo, not a" >&2
echo "layout to be built." >&2
exit 1
fi
pp_real="$(readlink -m "$path")"
# GUARDED_STORES alone, deliberately: PP_PROJECT_ROOTS holds this
# list's own `projects` directories, which the other two lists are
# refused for naming and this one exists to write into.
_guarded_stores
for guarded in ${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"} \
${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"} \
"$HOME/.local/bin" "$REPO_ROOT" "$WORKSPACE_DIR"; do
guarded_real="$(readlink -m "$guarded")"
_paths_overlap "$pp_real" "$guarded_real" || continue
echo "Refusing [mounts] read_write_per_project entry: $path" >&2
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
echo "Slicing it per project does not change what the session could reach" >&2
echo "through it - only how much of it there is." >&2
exit 1
done
# Resolved here and created later, on the launch path. This block runs
# before --print-briefing so the briefing it composes names the same
# mounts a launch would, and a diagnostic that prints something should
# not leave a directory behind - which is why share_dir's own mkdir
# sits after that exit too.
pp_source="$(project_dir_for "$path" "$WORKSPACE_DIR")"
RW_PP_MOUNT_TARGETS+=("$path")
RW_PP_MOUNT_SOURCES+=("$pp_source")
# Two lists, because the two readers want different answers and for a
# per-project mount they differ. The briefing tells the SESSION what it
# can write, which is the path inside the container. The exit audit
# tells the OPERATOR what on their host this session could reach, and
# naming the root there would claim a blast radius this feature exists
# to have narrowed.
DEJIMA_RW_PP_MOUNTS="${DEJIMA_RW_PP_MOUNTS:+$DEJIMA_RW_PP_MOUNTS }$path"
DEJIMA_RW_SOURCES="${DEJIMA_RW_SOURCES:+$DEJIMA_RW_SOURCES }$pp_source"
done
fi
# --print-briefing composes and exits, the same way --print-config does -
# before anything is built or mounted, and before a workspace argument is
# required. It exists so the composed briefing can be inspected (and, per
@@ -859,12 +982,12 @@ DEJIMA_WORKFLOW_PUSH="$(manifest_get workflow "$DEJIMA_WORKFLOW_NAME" push)"
# A forge declaring hosts_allowlisted_by_core says its hosts are ones core
# already allowlists in its own right - the GitHub ranges every session can
# read from over 443. Those must NOT reach DEJIMA_FORGE_HOSTS, which
# entrypoint.sh folds into EXTRA_ALLOWED_DOMAINS: that path resolves a name
# into the port-UNRESTRICTED allowed-domains ipset and exempts it from
# dnsmasq's stop-dns-rebind, both of which are deliberate for an internal,
# self-hosted forge host and both of which are a widening for a public one.
# It would also make init-firewall.sh's startup self-check ("port 22 was
# reachable without the push grant") fail, killing the container outright.
# firewall-up.sh puts in the open class: that path permits a name on every
# port and exempts it from the check that refuses an answer pointing inside a
# private network, both of which are deliberate for an internal, self-hosted
# forge host and both of which are a widening for a public one. It would also
# make the policy's startup self-check ("port 22 was reachable without the
# push grant") fail, killing the container outright.
#
# They still reach DEJIMA_PUSH_HOSTS: git-push-guard.sh's allowlist is a
# separate question from which names the firewall resolves, and a push to a
@@ -898,7 +1021,7 @@ fi
# --allow-github-push's actual effect: git-push-guard.sh only ever checks
# DEJIMA_PUSH_HOSTS, so granting it a github.com push means adding these
# hosts to that list, not just exporting ALLOW_GITHUB_PUSH - that variable
# reaches only init-firewall.sh's port-22 grant and its startup self-check,
# reaches only the egress policy's port-22 grant and its startup self-check,
# never the guard. Keyed to the
# flag alone, not to ALLOW_CORE_PUSH_GRANT: a forge whose own hosts these
# are has already contributed them above, and adding them twice would leave
@@ -1457,7 +1580,11 @@ cleanup() {
# read it, so this function's own cleanup can't race the audit that
# needs it - and its removal is guarded too, so it stays truly last.
exit_step "checking what changed on the host while it ran"
DEJIMA_RW_MOUNTS="$DEJIMA_RW_MOUNTS" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
# DEJIMA_RW_SOURCES, not DEJIMA_RW_MOUNTS: this report names what on the
# HOST the session could have written, and for a per-project mount that is
# the slice rather than the root it sits in. Naming the root would claim a
# blast radius the per-project split exists to have narrowed.
DEJIMA_RW_MOUNTS="$DEJIMA_RW_SOURCES" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
rm -f "$SANDBOX_AUDIT_STATE" || true
}
trap cleanup EXIT
@@ -1885,6 +2012,9 @@ _suggest_backends() { # <harness-section-name>
# declare still stops the launch.
DEJIMA_BACKEND_NAME=""
SANDBOX_BACKEND_PROXIED_DOMAIN=""
# The host of a backend the operator serves themselves, when the selected one
# has a base_url. Empty for every backend dejima proxies.
DEJIMA_BACKEND_HOST=""
DEJIMA_BACKEND_CREDENTIAL_KIND=""
DEJIMA_BACKEND_CREDENTIAL_FILE=""
DEJIMA_BACKEND_CREDENTIAL_FIELD=""
@@ -2057,6 +2187,29 @@ if [ -n "$DEJIMA_BACKEND_NAME" ]; then
if config_type "backend.$DEJIMA_BACKEND_NAME.credential_field" >/dev/null 2>&1; then
DEJIMA_BACKEND_CREDENTIAL_FIELD="$(config_get "backend.$DEJIMA_BACKEND_NAME" credential_field)"
fi
# A backend the operator serves rather than one dejima stands in front of:
# where it is comes from their own config, and the sandbox has to be able
# to reach it or the session has no model at all.
#
# Handed to the firewall path the way the forge's hosts and Loki's are.
# Without this the endpoint is reachable only if the operator ALSO approves
# its name as a domain - which works, and then looks like any other
# approved public name, so a self-hosted endpoint on their own network gets
# the answer check meant for names nobody reviewed. dejima configured this
# address; there is nothing for an answer to decide about it.
#
# Only for a backend dejima does NOT stand in front of, which today is the
# same set as "has a base_url" and is guarded rather than assumed. A
# backend with both would be proxied AND have its host permitted and
# exempted: that re-opens the DNS 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. No manifest declares both,
# and this is what keeps that from being a coincidence.
if [ -z "$SANDBOX_BACKEND_PROXIED_DOMAIN" ] \
&& config_type "backend.$DEJIMA_BACKEND_NAME.base_url" >/dev/null 2>&1; then
DEJIMA_BACKEND_HOST="$(config_get "backend.$DEJIMA_BACKEND_NAME" base_url \
| sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')"
fi
fi
# Everything staged for the container, checked before anything is put back
@@ -2114,7 +2267,7 @@ SANDBOX_HARNESS_BRIEFING_PATH="$(manifest_get harness "$DEJIMA_HARNESS_NAME" bri
export SANDBOX_HARNESS_COMMAND SANDBOX_HARNESS_REAL SANDBOX_HARNESS_ARGS \
SANDBOX_HARNESS_SETTINGS SANDBOX_HARNESS_DOMAINS \
SANDBOX_BACKEND_PROXIED_DOMAIN SANDBOX_HARNESS_BRIEFING_ARGS \
SANDBOX_HARNESS_BRIEFING_PATH
SANDBOX_HARNESS_BRIEFING_PATH DEJIMA_BACKEND_HOST
# [core] command defaults to the selected harness's own command rather than
# to a literal in core's schema.
@@ -2127,6 +2280,26 @@ export SANDBOX_CMD
ENABLE_FIREWALL="$(config_get_bool core firewall)"; export ENABLE_FIREWALL
LOKI_URL="$(config_get logging loki_url)"; export LOKI_URL
# Approved names that genuinely resolve inside the operator's own network.
#
# The egress policy refuses an answer for a permitted name that points at a
# private address, because an answer decides what the allowlist contains and an
# answer comes from outside - so one influenced reply for a public name would
# otherwise be egress to an internal host. The configured forge and Loki are
# exempt without being listed here: dejima resolves their addresses itself, so
# there is nothing for an answer to decide. An approved domain is not, because
# a project's declared domains are reviewed with the project and expected to be
# public names.
#
# This is for the case where that expectation is wrong on purpose - an internal
# mirror, a service on the operator's LAN. It exempts and does not grant: a
# name here is reachable only if it is also approved, and firewall-up.sh stops
# the launch when one is not, rather than leaving an exemption attached to
# nothing. Colon-separated in the config file like every other list;
# space-separated from here on, which is what the firewall scripts read.
DEJIMA_INTERNAL_DOMAINS="$(config_get core internal_domains | tr ':' ' ')"
export DEJIMA_INTERNAL_DOMAINS
# ~/.claude/hooks may contain symlinks whose real targets live outside
# ~/.claude (this repo's own hooks/, and potentially others - e.g. dotfiles'
# config/claude/hooks/ for deny-dangerous-bash.sh, if that's what a given
@@ -2284,6 +2457,7 @@ if [ -n "$SANDBOX_EXTRA_MOUNTS" ]; then
# [mounts] read_write has been guarded since it was added; this one
# never was, so `readonly = ~/.config/dejima` mounted the approval
# store into the container and nothing said a word (#15).
_refuse_pp_root "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" "$path"
_guarded_stores
_refuse_guarded_mount "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" \
"$path" "${GUARDED_STORES[@]}"
@@ -2306,6 +2480,17 @@ fi
for path in ${RW_MOUNT_PATHS[@]+"${RW_MOUNT_PATHS[@]}"}; do
extra_mounts+=(-v "$path:$path")
done
# And the per-project ones, which are the only mounts in dejima whose source
# and target differ on purpose: the slice on the host, the declared path in
# the container. Indices are paired, filled together in one loop above.
for _i in ${RW_PP_MOUNT_TARGETS[@]+"${!RW_PP_MOUNT_TARGETS[@]}"}; do
# dejima's own layout below a directory the operator made, so the slice is
# created and the root never is. Here rather than where it was resolved,
# because this is the launch path and that was also --print-briefing's.
mkdir -p "${RW_PP_MOUNT_SOURCES[$_i]}"
extra_mounts+=(-v "${RW_PP_MOUNT_SOURCES[$_i]}:${RW_PP_MOUNT_TARGETS[$_i]}")
done
unset _i
# The session briefing, composed host-side from the core text and the
# selected workflow/harness fragments (see briefing_compose) and mounted
@@ -3244,7 +3429,8 @@ reconcile_orphans || true
# One network per launch, for two reasons, and the first is not optional:
# Docker's embedded resolver at 127.0.0.11 exists only on a user-defined
# network, and dns-proxy.sh forwards to it. The second is that concurrent
# network, and the restricted resolver forwards to it. The second is that
# concurrent
# sessions are then separated at the link layer as well, rather than only by
# each namespace's own egress default-deny.
docker network create --label "dejima.run=$DEJIMA_RUN_ID" "$SANDBOX_RUN_NETWORK" >/dev/null
@@ -3265,6 +3451,8 @@ filter_env=(
-e ALLOW_GITHUB_PUSH # whether port 22 to GitHub opens
-e GH_TOKEN # authenticates the GitHub ranges fetch
-e LOKI_URL # its host is allowlisted, and the shipper needs it
-e DEJIMA_INTERNAL_DOMAINS # approved names that really do resolve inside
-e DEJIMA_BACKEND_HOST # a self-served model's host, from its base_url
-e SANDBOX_HARNESS_DOMAINS
-e SANDBOX_BACKEND_PROXIED_DOMAIN
-e SANDBOX_CREDENTIAL_PROXY_SOCKET
@@ -3274,12 +3462,13 @@ filter_env=(
# owns needs. Every one of these used to be in docker-compose.yml, and none of
# them belongs to a container running a session:
#
# NET_ADMIN, NET_RAW the packet filter and the ipsets
# KILL dnsmasq drops to its own user, and dejima-allow
# signals it to add a domain mid-session
# NET_ADMIN, NET_RAW the packet filter and its named address sets, which
# the resolver also writes into as it answers
# KILL the resolver drops to its own user, and dejima-allow
# restarts it to add a domain mid-session
# CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID
# dns-proxy.sh's log file, and the two daemons that
# drop privilege after opening what they need
# the log files, and the daemons that drop privilege
# after opening what they need
# SETPCAP spent taking the rest away from log-shipper.sh
#
# The sysctl lets the credential proxy's TLS terminator bind :443 as the
+73 -60
View File
@@ -60,10 +60,10 @@ esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=dejima-project-lib.sh
source "$SCRIPT_DIR/dejima-project-lib.sh"
# For ipset_for_domain: the live add below writes into a running container's
# ipsets, and which set a name belongs in is the container's rule, not this
# script's. Sourced rather than restated so the two cannot drift - the same
# reason init-firewall.sh and dns-proxy.sh share this file.
# For dejima_class_for: the live add below changes a running container's
# permitted set, and which class a name belongs to is the container's rule, not
# this script's. Sourced rather than restated so the two cannot drift - the
# same reason firewall-up.sh reads this file.
# shellcheck source=../docker/fixed-domains.sh
source "$SCRIPT_DIR/../docker/fixed-domains.sh"
# The palette, heading and prompt dejima-attach uses. Picking one session out
@@ -268,9 +268,9 @@ if [ "$session_wanted" -eq 1 ]; then
[ -n "$container" ] || container="${containers%%$'\t'*}"
fi
# The dnsmasq and the ipsets are not in the session's container. They are
# in the filter container that owns its network namespace, which is what
# keeps a session from reaching either. So everything that CHANGES the
# The resolver and the packet filter are not in the session's container.
# They are in the filter container that owns its network namespace, which
# is what keeps a session from reaching either. So everything that CHANGES the
# allowlist below runs there, and only the reachability check at the end
# runs in the session's own container - because that is the container whose
# reachability this command claims to have changed.
@@ -321,61 +321,74 @@ if [ "$session_wanted" -eq 1 ]; then
;;
esac
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
# this has to happen *before* resolving the domain below - otherwise the dig
# right after this would itself get blocked. Append a server= line to
# dnsmasq's servers-file (idempotent - skip if already present from an
# earlier call) and SIGHUP it to reload just that file, no restart needed.
# $domain is passed as a positional arg to the inner script (`$1`, inside a
# single-quoted heredoc) rather than interpolated into the script text - it's
# validated above, but this keeps it that way even if the validation regex
# above is ever loosened later.
docker exec -u root "$filter" bash -c '
domain="$1"
entry="server=/${domain}/127.0.0.11"
grep -qxF "$entry" /etc/dnsmasq-sandbox-servers.conf 2>/dev/null || echo "$entry" >> /etc/dnsmasq-sandbox-servers.conf
kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"
' -- "$domain"
# Named for where the lookup happens, which is where the DNS allowlist is.
echo "Resolving $domain in $filter, this session's egress control..."
ips=$(docker exec "$filter" dig +short A "$domain" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || true)
if [ -z "$ips" ]; then
# An internally-routed name can't be added to a *running* session at
# all: dnsmasq's stop-dns-rebind drops a private answer for any domain
# not exempted when the container started, and dns-proxy.sh writes
# those rebind-domain-ok= lines only at startup - the servers-file this
# script SIGHUPs carries server= lines and nothing else. Asking the
# upstream resolver directly separates that dead end from a name that
# simply doesn't resolve, so the message can name the fix that works.
upstream=$(docker exec "$filter" dig +short A "$domain" @127.0.0.11 2>/dev/null |
grep -E '^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)' || true)
if [ -n "$upstream" ]; then
echo "$domain resolves to a private address (${upstream//$'\n'/ }), which the" >&2
echo "container's DNS rebind protection drops for any domain not allowlisted" >&2
echo "when it started." >&2
echo "" >&2
if [ -n "$project" ] || [ "$global" -eq 1 ]; then
echo " (the approval recorded above already does this)" >&2
else
echo " dejima-allow --global $domain" >&2
fi
echo "" >&2
echo "Relaunch the session to pick it up - a running one cannot be patched." >&2
exit 1
fi
echo "Failed to resolve $domain inside the container." >&2
exit 1
# One command in the filter container does the whole change: it records the
# entry, regenerates and restarts the restricted resolver so the name
# resolves, waits for the answer, and confirms the address reached the set
# its class uses. If any of that fails it puts the permitted set back the
# way it was and exits non-zero, so there is no half-added name that
# resolves and is then refused.
#
# The class is this script's to state and the container's to enforce - a
# forge name added to the open class would be reachable on every port,
# which is the read-only guarantee gone from a command whose whole output
# says it granted one domain.
#
# --internal-ok for a name in [core] internal_domains, and for no other.
#
# The policy refuses an answer for a permitted name that points inside a
# private network, because an answer decides what the allowlist contains
# and comes from outside. An approval does not lift that: a project's
# declared domains are reviewed with the project and expected to be public,
# and exempting a public name is the pivot the check exists to stop. What
# lifts it is the operator saying a specific name really is internal.
#
# Read from the same key a launch reads, so the two cannot disagree. They
# did: this passed the exemption for any approval while firewall-up.sh
# withheld it, so a live add succeeded and the next launch refused to start
# on the same name - with the failed launch as the operator's first hint
# that the two commands had different ideas.
#
# $domain reaches the container as an argument rather than inside the
# command string: it is validated above, and this keeps it that way if that
# validation is ever loosened.
#
# Guarded on the file existing: config_get says so on stderr when it does
# not, which is right for a launch and noise for this, where a host with no
# configuration at all is an ordinary case and the answer is simply "not
# listed". A config that exists and is malformed still reports itself.
internal=0
if [ -f "$(config_path)" ]; then
# `|| [ -n "$_d" ]`, because config_get prints its value with no
# trailing newline - so a single-entry list is one line read never
# returns, and the loop body would not run at all.
while IFS= read -r _d || [ -n "$_d" ]; do
[ "$_d" = "$domain" ] && internal=1
done < <(config_get core internal_domains | tr ':' '\n')
fi
# allowed-domains carries no port restriction, so a GitHub address added
# there is reachable on 22 as well - which is the read-only guarantee gone,
# from a command whose whole output says it granted one domain.
target_set=$(ipset_for_domain "$domain")
for ip in $ips; do
echo "Allowing $ip ($domain -> $target_set)"
docker exec -u root "$filter" ipset add "$target_set" "$ip" -exist
done
permit_args=(permit "$domain")
[ "$(dejima_class_for "$domain")" = forge ] && permit_args+=(--forge)
[ "$internal" -eq 1 ] && permit_args+=(--internal-ok)
echo "Permitting $domain in $filter, this session's egress control..."
if ! docker exec -u root "$filter" /usr/local/sbin/egress-policy "${permit_args[@]}"; then
echo "" >&2
echo "The permitted set was left as it was - the reason is above." >&2
if [ "$internal" -eq 0 ]; then
echo "" >&2
echo "If $domain resolves inside your own network, say so in the config and" >&2
echo "relaunch - an answer pointing inside is refused for every name that is" >&2
echo "not listed there, whether or not it is approved:" >&2
echo "" >&2
echo " [core]" >&2
echo " internal_domains = $domain" >&2
echo "" >&2
echo "It still needs an approval to be reachable at all:" >&2
echo "" >&2
echo " dejima-allow --global $domain" >&2
fi
exit 1
fi
echo "$domain allowed for the running session ($container)."
+2 -2
View File
@@ -953,8 +953,8 @@ tcb_snapshot() {
#
# A forge's `fixed_hosts` and a harness's paths are resolved on the
# host and handed to the container: fixed_hosts reaches
# DEJIMA_FORGE_HOSTS, which entrypoint.sh joins into extra_domains and
# init-firewall.sh allowlists. So a line added to a manifest widens the
# DEJIMA_FORGE_HOSTS, which firewall-up.sh puts into the permitted set
# the egress policy is given. So a line added to a manifest widens the
# egress allowlist on the next launch - the boundary that bounds where
# a compromised session can send anything.
#
+5 -2
View File
@@ -45,6 +45,7 @@ core.command ?identifier
core.identity ?identifier
core.tools ?list:identifier
core.firewall bool on
core.internal_domains ?list:hostname
ui.banner bool on
core.update_check bool on
core.share_dir path ~/.local/share/dejima/shared
@@ -53,6 +54,7 @@ core.known_hosts path ~/.ssh/known_hosts
github.token_file path ~/.config/gh/sandbox-token
mounts.readonly ?list:path
mounts.read_write ?list:path
mounts.read_write_per_project ?list:path
mounts.nvim_config ?path
credentials.proxy bool on
credentials.transport enum:tls|loopback tls
@@ -289,8 +291,9 @@ valid_tarball_spec() {
# server=/domain/ line. Here rather than in dejima-project-lib.sh as well, so
# there is one definition and no pair of them to drift apart.
# A hostname, label by label. The matcher is a trust boundary: what it accepts
# becomes a dnsmasq `server=` line and an ipset entry, so it should accept only
# what it means to rather than only what it can see a use for.
# becomes a dnsmasq `server=` line and an entry in the packet filter's address
# sets, so it should accept only what it means to rather than only what it can
# see a use for.
#
# The previous form allowed an EMPTY label - `a..b.com` matched - because its
# repeated group could consume a dot having matched no characters before it.
+7 -1
View File
@@ -481,7 +481,7 @@ _emit_tarball_layers() {
# Installs run at BUILD time, where there's no egress firewall - which is
# why this is a derived image rather than an install step in entrypoint.sh:
# the latter would need apt/npm/PyPI/crates.io reachable as root before
# init-firewall.sh locks egress down.
# the egress policy locks egress down.
generate_derived_dockerfile() {
local base="$1" store="$2" entries
entries="$(approved_entries "$store" dependencies)" \
@@ -574,6 +574,12 @@ generate_spec_dockerfile() {
# above left behind - entrypoint.sh must still start as root and drop
# via gosu, with no escalation path for the session user.
printf 'USER root\n'
# The base image swept its own setuid bits and file capabilities, and then
# the apt/pip/tarball layers above installed more software on top. Sweep
# again here, so a project declaring a dependency that happens to ship a
# setuid binary fails its build rather than handing every session on that
# project an escalation path the base image was careful not to have.
printf 'RUN /usr/local/sbin/strip-privilege-bits\n'
printf 'ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]\n'
return 0
}
+10
View File
@@ -2002,6 +2002,16 @@ _briefing_resolve_key() {
if [ -n "${DEJIMA_RW_MOUNTS:-}" ]; then
printf '\n These host directories are bind-mounted read-write at their own paths and\n outlive the container too: %s.\n ' "$DEJIMA_RW_MOUNTS"
fi
# A separate sentence, because the first one is not true of these.
# A per-project mount arrives at the path it names, like the
# others, but what is behind it is this project's own slice of
# that directory rather than the directory - so "these host
# directories ... at their own paths" would tell a session it can
# see something it cannot, and imply the next project's session
# will read what it leaves.
if [ -n "${DEJIMA_RW_PP_MOUNTS:-}" ]; then
printf '\n These are bind-mounted read-write too and also outlive the container, but\n each is THIS project'"'"'s own directory rather than one every project shares -\n the same arrangement as ~/shared: %s.\n ' "$DEJIMA_RW_PP_MOUNTS"
fi
;;
*)
echo "internal error: no resolver for whitelisted briefing key: $key" >&2
+20 -8
View File
@@ -17,16 +17,28 @@
# runs inside the container, the other is a separate library).
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
# Every per-project share directory lives under this one subdirectory of
# `[core] share_dir` rather than directly in it, which is what makes "left
# over from before the split" a decidable question: anything at share_dir's
# top level other than this directory predates the layout, because nothing
# writes there any more. It is also the directory to mount over sshfs - one
# entry per project, with no loose files mixed in.
share_projects_root() { printf '%s/projects' "${1%/}"; }
# The per-project layout, and there is one of it because there is more than one
# caller: `[core] share_dir` slices this way, and so does every
# `[mounts] read_write_per_project` entry. A host directory sliced per project
# holds one `projects` subdirectory, and one directory per slug inside it.
#
# Everything lives under that single subdirectory rather than directly in the
# root, which is what makes "left over from before, or put there by hand" a
# decidable question: anything at the root's top level other than this
# directory was not written by dejima. It is also the directory to mount over
# sshfs - one entry per project, with no loose files mixed in.
projects_root() { printf '%s/projects' "${1%/}"; }
# The directory a launch against $2 gets from a root sliced this way.
project_dir_for() { printf '%s/%s' "$(projects_root "$1")" "$(ws_slug "$2")"; }
# share_dir's own spelling of the two above. `[core] share_dir` is one
# instance of the layout, and these names are what let bin/dejima-share go on
# talking about a share rather than about "a per-project root".
share_projects_root() { projects_root "$1"; }
# The host directory mounted at ~/shared for a launch against $2.
share_dir_for() { printf '%s/%s' "$(share_projects_root "$1")" "$(ws_slug "$2")"; }
share_dir_for() { project_dir_for "$1" "$2"; }
# The slugs belonging to $1: its own, plus one per live git worktree nested
# underneath it. One per line, sorted, no duplicates.
+2 -2
View File
@@ -5,8 +5,8 @@
# systemd/dejima-ttyd.service.
# See docs/remote-access.md.
#
# --interface 127.0.0.1 is a requirement, not a preference. init-firewall.sh
# accepts container OUTPUT to the bridge gateway (the host), and attaching
# --interface 127.0.0.1 is a requirement, not a preference. The egress policy
# permits traffic to the container's own host, and attaching
# tmux gives a HOST shell - the prefix key opens a window outside the
# container. A listener on any address the container can route to is a
# compromised session's escape hatch; loopback is not one of them. Put nginx
+158 -102
View File
@@ -1,9 +1,3 @@
# Loosely inspired by the general shape of Anthropic's public reference dev
# container (https://github.com/anthropics/claude-code/tree/main/.devcontainer),
# substantially extended and reworked for this project's own sandboxing model
# (parametrized SANDBOX_USER, DNS proxy + ulogd2 logging, gosu-based privilege
# drop, nvim/Playwright/tree-sitter toolchain, gitea-pr helper, etc).
#
# What belongs in here is what the sandbox itself needs, plus the editor and
# browser tooling that has no route through the dependency mechanism. Language
# toolchains and per-project utilities do have one - `dejima-dep`, including
@@ -12,13 +6,41 @@
# below is added for every project, forever, with no review anywhere.
FROM node:22
ARG TZ
# Timestamps inside the container match the ones outside it. Validated rather
# than trusted: an unknown zone leaves the container silently on UTC, and a log
# line an hour out of step with the operator's own is harder to distrust than a
# build that refused. /etc/localtime covers everything that reads the system
# zone; TZ in the environment covers what consults it directly, Node among them.
ARG TZ=Etc/UTC
RUN [ -f "/usr/share/zoneinfo/$TZ" ] || { echo "unknown timezone: $TZ" >&2; exit 1; }; \
ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime && \
echo "$TZ" > /etc/timezone
ENV TZ="$TZ"
# Install basic development tools, iptables/ipset (for the egress firewall),
# openssh-client (for git-over-ssh and agent forwarding), and the toolchain a
# typical nvim plugin set (telescope/treesitter/mason) expects, since
# SANDBOX_NVIM_CONFIG can point a session's editor at one.
# What a session is expected to have without installing anything: git and a
# forge client, tools to read and search a tree, an editor, the network tools
# the egress policy is written against, and enough of a toolchain to build a
# small thing. Sorted, so adding one is a one-line diff rather than a judgement
# about where it goes.
#
# nftables rather than iptables and ipset. 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; sets take CIDR intervals directly; and a whole ruleset loads as a
# single transaction, so the namespace is never left half-filtered. See
# docker/egress-policy.
#
# dnsmasq-base rather than dnsmasq: the binary without the init script, service
# user and default configuration, none of which apply - there is no init system
# here to run them, and egress-policy generates the configuration per launch.
#
# No CIDR aggregation package. Collapsing a list of ranges is a few lines of the
# Python standard library, which is here anyway, and the packaged option
# predates IPv6 - see docker/cidr-collapse.
#
# bash-completion rather than a shell framework: the session shell is the base
# image's bash, configured by this image in /etc/bash.bashrc.d rather than by an
# installer fetched at build time. See the shell section below.
#
# Deliberately NOT installing bubblewrap/socat: they exist to let a harness's
# own internal per-command sandboxing run, and that inner sandbox is redundant
@@ -34,49 +56,63 @@ ENV TZ="$TZ"
# iproute2; a security control resting on another package's dependency graph is
# one an unrelated package change removes.
RUN apt-get update && apt-get install -y --no-install-recommends \
less \
git \
openssh-client \
procps \
fzf \
zsh \
unzip \
gnupg2 \
gh \
iptables \
ipset \
iproute2 \
libcap2-bin \
dnsutils \
aggregate \
jq \
openssl \
dnsmasq \
ulogd2 \
ulogd2-json \
nano \
ripgrep \
fd-find \
bash-completion \
bind9-dnsutils \
build-essential \
dnsmasq-base \
fd-find \
fzf \
gh \
git \
gnupg2 \
iproute2 \
jq \
less \
libcap2-bin \
nano \
nftables \
openssh-client \
openssl \
procps \
python3 \
python3-pip \
ripgrep \
ulogd2 \
ulogd2-json \
unzip \
xz-utils \
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
&& ln -s "$(command -v fdfind)" /usr/local/bin/fd
# Set `DEVCONTAINER` environment variable to help with orientation
ENV DEVCONTAINER=true
# Deliberately NOT set: DEVCONTAINER, or any other variable announcing this as
# a generic development container. It is not one - it is a sandbox with an
# egress firewall and no path back to root - and tools change behaviour when
# they see such a flag, on an assumption about their environment that does not
# hold here. What a harness actually needs to know is below, and says the thing
# that is true.
# Lets the harness, and the SessionStart hook it runs, know it is inside this
# container rather than directly on the host.
ENV DEJIMA=1
# Dedicated unprivileged user for ulogd2 (see entrypoint.sh) - unlike
# dnsmasq, the ulogd2 Debian package doesn't create one of its own. ulogd2
# opens its log/output files and the NFLOG netlink socket before dropping to
# this user, so it doesn't need to own anything itself.
# Dedicated unprivileged user for ulogd2 (see firewall-up.sh) - the ulogd2
# Debian package doesn't create one of its own. ulogd2 opens its log/output
# files and the NFLOG netlink socket before dropping to this user, so it
# doesn't need to own anything itself.
RUN useradd -r -s /usr/sbin/nologin ulogd
# The account the DNS forwarder drops to, and nothing else ever runs as.
# Permission to speak to a resolver outside this network namespace is a uid
# match in the packet filter (docker/egress-policy), so this account IS that
# permission: anything else running as it would inherit the one route out that
# the name allowlist exists to close.
#
# `nobody` would be the obvious choice and is the wrong one. It is where every
# unprivileged program lands when it drops privilege without having an account
# of its own, so a permission keyed to it is a permission granted to all of
# them. Created here rather than by a package, so nothing else can be given it.
RUN useradd -r -M -s /usr/sbin/nologin egress-resolver
# Every fetch from here down retries. What matters is not the odds of one
# download failing but of ANY of them failing, and without retries a single
# blip discards the whole build.
@@ -154,20 +190,29 @@ RUN userdel -r node 2>/dev/null; \
ENV SANDBOX_USER=$SANDBOX_USER
ENV SANDBOX_HOME=$SANDBOX_HOME
# Ensure the sandbox user has access to /usr/local/share. The recursion is
# scoped to npm-global: Neovim unpacks into /usr/local/share/nvim above this
# line, and recursing from the parent would hand the sandbox user Neovim's
# runtime files. /usr/local/share itself is still
# chowned, so anything that expected to create entries in it still can.
RUN mkdir -p /usr/local/share/npm-global && \
chown $SANDBOX_USER:$SANDBOX_USER /usr/local/share && \
chown -R $SANDBOX_USER:$SANDBOX_USER /usr/local/share/npm-global
# Where globally installed Node CLIs go: this image's own later stages, and an
# operator's declared npm dependencies at launch. Both have to work as the
# sandbox user, which has no sudo and never will, so the prefix has to be a
# directory that user owns.
#
# A directory of its own rather than a subdirectory of a shared one, because
# "owned by the sandbox user" and "put here by npm" then describe the same set
# of files. Under /usr/local/share they do not: Neovim unpacks into
# /usr/local/share/nvim, and one careless -R would hand the session write access
# to the editor's own runtime.
RUN mkdir -p /opt/node-tools/bin /opt/node-tools/lib/node_modules && \
chown -R $SANDBOX_USER:$SANDBOX_USER /opt/node-tools
# Persist bash history. The env vars that actually wire history up to this
# file are set below via zsh-in-docker's -a flag.
RUN mkdir /commandhistory && \
touch /commandhistory/.bash_history && \
chown -R $SANDBOX_USER /commandhistory
# Shell history, on a named volume (docker-compose.yml) so a session started
# tomorrow sees what was typed today. Created here so the directory exists with
# the right owner and mode even on a run with nothing mounted over it - Docker
# seeds a fresh named volume from what is already at the mount path, ownership
# included, so this is also what keeps the volume writable by that user rather
# than root. The shell that reads it is configured below.
RUN mkdir -p /var/opt/shell-history && \
touch /var/opt/shell-history/.bash_history && \
chown -R $SANDBOX_USER:$SANDBOX_USER /var/opt/shell-history && \
chmod 0700 /var/opt/shell-history
# Create the ssh directory and set permissions. The project directory itself
# is bind-mounted at runtime at its real host path (see docker-compose.yml),
@@ -193,47 +238,35 @@ RUN mkdir -p $SANDBOX_HOME/.ssh && \
RUN mkdir -p $SANDBOX_HOME/.local/share/nvim $SANDBOX_HOME/.local/state/nvim $SANDBOX_HOME/.cache/nvim && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.local $SANDBOX_HOME/.cache
# Set up non-root user
# Everything from here to the `USER root` below runs as the sandbox user, so
# that global npm installs land in the prefix that user owns.
USER $SANDBOX_USER
# Install global packages
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# A global npm install only puts a package's CLI on PATH, not its module on
# Node's require() resolution path - NODE_PATH is what makes `require
# ("playwright")` work from any script anywhere, not just the project's own
# node_modules (which it likely doesn't have without its own npm install).
ENV NODE_PATH=/usr/local/share/npm-global/lib/node_modules
ENV NPM_CONFIG_PREFIX=/opt/node-tools
# Appended, not prepended. /usr/local/bin holds the harness wrapper, which
# shadows the harness's real binary to pin the no-confirm, settings and
# briefing flags - and the real binary is npm-installed, so putting this
# directory first would let it win and drop every one of those flags silently.
ENV PATH=$PATH:/opt/node-tools/bin
# Set the default shell to zsh rather than sh
ENV SHELL=/bin/zsh
# The session shell is the base image's own bash. It is what the sandbox user's
# account is already set to, and configuring it costs this image one file
# instead of a package and an installer downloaded at build time.
#
# That download is the reason, not the package. A build-time fetch of a script
# that is then executed is a supply-chain surface, and this image is a security
# boundary: whatever it installs runs in every sandbox on the host, forever,
# with no review anywhere. What a prompt framework would have set up is written
# out in session-bashrc.sh instead, where it can be read.
ENV SHELL=/bin/bash
# Set the default editor and visual
# Anything that spawns an editor - `git commit` with no message, an interactive
# rebase - reads these. Unset, git falls back to whatever /usr/bin/editor points
# at, and an unattended session that lands in a modal editor it cannot name the
# exit key for will sit there until something kills it. nano prints its own.
ENV EDITOR=nano
ENV VISUAL=nano
# Default powerline10k theme, git/fzf oh-my-zsh plugins, fzf's own key
# bindings, and wiring HISTFILE to the persisted /commandhistory volume -
# see https://github.com/deluan/zsh-in-docker for the installer's own flags.
#
# Downloaded to a file and then run, rather than upstream's documented
# `sh -c "$(wget -O- ...)"`. In that form a failed download substitutes to an
# empty string, `sh -c ""` exits 0, and the build goes green having installed
# no zsh config at all - the failure only surfaces much later, as a shell that
# is mysteriously unconfigured. Two steps make the download's exit status the
# build's exit status.
ARG ZSH_IN_DOCKER_VERSION=1.2.0
RUN wget --tries=3 --timeout=30 --waitretry=5 --retry-connrefused \
-O /tmp/zsh-in-docker.sh \
"https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh" && \
sh /tmp/zsh-in-docker.sh \
-p fzf \
-p git \
-a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
-a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
-a "source /usr/share/doc/fzf/examples/completion.zsh" \
-x && \
rm /tmp/zsh-in-docker.sh
ENV PAGER=less
# No harness here. The image is shared by every project on the host, and
# baking one harness into it means every sandbox carries it whether or not it
@@ -271,14 +304,15 @@ RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
# The launched application itself (SANDBOX_CMD) and any editor config for it
# are supplied at runtime, not baked into the image - see
# docker-compose.yml/entrypoint.sh.
COPY docker/session-bashrc.sh /etc/bash.bashrc.d/10-session.bash
COPY docker/fixed-domains.sh /usr/local/bin/
COPY docker/caps-lib.sh /usr/local/bin/
COPY docker/dns-proxy.sh /usr/local/bin/
COPY docker/init-firewall.sh /usr/local/bin/
COPY docker/egress-policy /usr/local/sbin/egress-policy
COPY docker/dns-lookup-log /usr/local/sbin/dns-lookup-log
COPY docker/cidr-collapse /usr/local/bin/cidr-collapse
COPY docker/firewall-up.sh /usr/local/bin/
COPY docker/filter-entrypoint.sh /usr/local/bin/
COPY docker/log-shipper.sh /usr/local/bin/
COPY docker/ulogd.conf /etc/ulogd-sandbox.conf
# Into $VIMRUNTIME/plugin, not /etc/xdg/nvim - see the file's own comment.
COPY docker/sandbox-clipboard.lua /usr/local/share/nvim/runtime/plugin/sandbox-clipboard.lua
COPY docker/entrypoint.sh /usr/local/bin/
@@ -321,19 +355,41 @@ COPY docker/install-forge-bins.sh /tmp/install-forge-bins.sh
RUN bash /tmp/install-forge-bins.sh /tmp/forges /usr/local/lib/dejima/forge /usr/local/bin \
&& rm -rf /tmp/forges /tmp/install-forge-bins.sh
# The other half of NODE_PATH, which only ever applied to require(): ESM
# resolution ignores it entirely and walks up from the importing file looking
# for node_modules, so `import { chromium } from "playwright"` fails from
# every directory a script is likely to be written in while require() of the
# same package succeeds. At the root, that walk always ends here.
# What makes a globally installed package resolvable from a script in any
# directory. Both of Node's resolvers find a bare specifier by walking
# node_modules directories upwards from the importing file, and every path
# bottoms out at /, so one here is visible everywhere - including from a
# directory with no dependency tree of its own.
#
# It is worth a line because of how the failure reads. An agent writing the
# modern form gets ERR_MODULE_NOT_FOUND, concludes the image has no browser,
# and describes what a page should look like instead of going and seeing.
RUN ln -s /usr/local/share/npm-global/lib/node_modules /node_modules
# It is worth a line because of how the failure reads without it. An agent
# writing `import { chromium } from "playwright"` gets ERR_MODULE_NOT_FOUND,
# concludes the image has no browser, and describes what a page should look
# like instead of going and seeing.
RUN ln -s /opt/node-tools/lib/node_modules /node_modules
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/firewall-up.sh /usr/local/bin/filter-entrypoint.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh /usr/local/lib/dejima/hooks/*.sh && \
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
# Source the snippet directory from the system rc file, so every interactive
# bash gets it, and from the sandbox user's own ~/.bashrc, so it is applied
# after the Debian skeleton rc file rather than being undone by it. The guard on
# the append keeps a rebuild on a changed base from adding the loop twice.
RUN grep -q 'bash.bashrc.d' /etc/bash.bashrc || \
printf '\nfor __rc in /etc/bash.bashrc.d/*.bash; do\n [ -r "$__rc" ] && . "$__rc"\ndone\nunset __rc\n' >> /etc/bash.bashrc; \
printf '%s\n' \
'# The sandbox shell configuration this image installs. Sourced here as well' \
'# as from /etc/bash.bashrc so it is applied last, after the Debian defaults.' \
'[ -r /etc/bash.bashrc.d/10-session.bash ] && . /etc/bash.bashrc.d/10-session.bash' \
> "$SANDBOX_HOME/.bashrc" && \
chown "$SANDBOX_USER:$SANDBOX_USER" "$SANDBOX_HOME/.bashrc"
RUN chmod +x /usr/local/sbin/egress-policy /usr/local/sbin/dns-lookup-log /usr/local/bin/cidr-collapse /usr/local/bin/firewall-up.sh /usr/local/bin/filter-entrypoint.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh /usr/local/lib/dejima/hooks/*.sh && \
echo 'export PATH="$PATH:/opt/node-tools/bin"' > /etc/profile.d/node-tools-path.sh
# Last instruction that changes the filesystem, and it has to stay last: it
# removes every setuid bit, setgid bit and file capability in the image and
# fails the build if one survives, which only holds for what already exists
# when it runs. A derived per-project layer runs it again at its own end, for
# the packages it installs after this point - see bin/dejima-project-lib.sh.
COPY docker/strip-privilege-bits.sh /usr/local/sbin/strip-privilege-bits
RUN chmod +x /usr/local/sbin/strip-privilege-bits && /usr/local/sbin/strip-privilege-bits
# Entrypoint runs as root (no USER node here) so it can set up the firewall
# directly, with no sudo/setuid escalation path available to node at any
+9 -4
View File
@@ -32,10 +32,15 @@ drop_caps() {
# precondition of the whole script, and the point is to refuse before doing
# work rather than partway through it.
require_setpcap() {
if capsh --decode="$(awk '/^CapEff:/ {print $2}' /proc/self/status)" \
| grep -q cap_setpcap; then
return 0
fi
# Decoded first and matched with `[[ ]]`, rather than piped into `grep -q`.
# Both callers set `pipefail`, and grep -q leaves as soon as it matches,
# which SIGPIPEs the writer and makes the pipeline's status 141 - so the
# check reports a missing capability BECAUSE it found one. capsh's output is
# one short line and would almost always win that race, which is the worst
# place for it: this is the guard that decides whether the container starts.
local decoded
decoded=$(capsh --decode="$(awk '/^CapEff:/ {print $2}' /proc/self/status)") || decoded=""
[[ "$decoded" == *cap_setpcap* ]] && return 0
echo "${0##*/}: this container does not hold CAP_SETPCAP, so the capabilities" >&2
echo "the startup sequence needs cannot be taken away again before the session" >&2
echo "starts - it would run inside a tree holding all of them." >&2
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/python3
"""Collapse CIDRs into the smallest equivalent list.
Reads networks from the arguments, or from stdin if there are none, separated
by whitespace or commas. v4 and v6 may be mixed; each family is collapsed
independently and printed v4 first.
Every entry is validated on the way through, so a malformed range from an
upstream document stops the caller rather than being loaded into a live
allowlist. egress-policy relies on both halves of that: the collapse keeps the
interval sets small, and the validation is the only thing checking what the
forge's published document actually contained.
"""
import ipaddress
import re
import sys
def main():
words = sys.argv[1:] or re.split(r"[\s,]+", sys.stdin.read())
families = {4: [], 6: []}
for word in words:
if not word:
continue
try:
net = ipaddress.ip_network(word, strict=False)
except ValueError as exc:
sys.exit(f"cidr-collapse: {exc}")
families[net.version].append(net)
for version in (4, 6):
for net in ipaddress.collapse_addresses(families[version]):
print(net)
if __name__ == "__main__":
main()
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/python3
"""Turn the resolver's query log into one JSON record per name lookup.
A lookup that is refused never becomes a connection, so the packet log cannot
account for it - and a refused lookup is the interesting event, because the
query itself is what carries data out. This follows dnsmasq's own log and emits
one record per lookup, with its outcome, next to the connection log.
dnsmasq has no structured output of its own, so its text log is parsed here
rather than in whatever reads the result: the parsing then lives in one place
and is tested once, instead of in every supervisor.
Outcomes:
answered the name was permitted and the resolver returned addresses
refused not-permitted the name is not in the permitted set
non-global-answer permitted, but answered with a private,
loopback or link-local address, which the
resolver would not hand out
no-such-name permitted, upstream says it does not exist
"""
import argparse
import json
import os
import re
import sys
import time
from datetime import datetime, timezone
# "Sep 12 00:45:07 dnsmasq[4553]: " - dropped; the record is stamped when the
# line is read, which is accurate to well within the resolution that matters and
# does not depend on a log format that carries no year.
PREFIX = re.compile(r"^\w{3}\s+\d+\s+[\d:]+\s+\S*dnsmasq\[\d+\]:\s*")
PATTERNS = {
"query": re.compile(r"^query\[(?P<type>[^\]]+)\]\s+(?P<name>\S+)\s+from\s+(?P<client>\S+)"),
"not-permitted": re.compile(r"^config\s+(?P<name>\S+)\s+is\s+NXDOMAIN"),
"non-global-answer": re.compile(r"^possible DNS-rebind attack detected:\s*(?P<name>\S+)"),
"forwarded": re.compile(r"^forwarded\s+(?P<name>\S+)\s+to\s+(?P<server>\S+)"),
"reply": re.compile(r"^(?:reply|cached)\s+(?P<name>\S+)\s+is\s+(?P<value>.+?)\s*$"),
}
IDLE_FLUSH = 1.0 # seconds a lookup waits for further lines before it is written
def now():
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
# Outcomes, worst last. A lookup is written with the highest-ranked outcome its
# lines reported: a name answered on one query type and refused on another is a
# refusal, and the reason a supervisor would want to be woken for outranks the
# rest.
RANK = {
None: 0,
# Below "answered" on purpose: a name with an A record and no AAAA is
# answered and reports NODATA for the other query, and that is the ordinary
# case, not a failure.
("refused", "no-such-name"): 1,
("answered", None): 2,
("refused", "not-permitted"): 3,
("refused", "non-global-answer"): 4,
}
class Lookups:
"""Open lookups, keyed by name, flushed when they stop changing.
One lookup produces several log lines - query, forwarded, one reply per
address - and a resolver library asking for A and AAAA emits two queries for
the same name before either is answered, which is what a C library's stub
does by default. So a second query for a name already open joins it rather
than closing it: closing would write the first out as unanswered while its
reply was still in flight, and a routine event that reads as a failure is
worse in an alerting log than no event at all.
Keying by name and type instead would be the obvious alternative and does
not work: the lines that report a refusal carry no query type, so there is
nothing to attribute them to. Name is the only key every line has.
"""
def __init__(self, out):
self.out = out
self.open = {}
def blank(self, name):
return {
"ts": now(),
"name": name,
"types": [],
"queries": 0,
"client": None,
"outcome": None,
"reason": None,
"addresses": [],
"upstream": None,
"rank": 0,
"touched": time.monotonic(),
}
def start(self, name, fields):
entry = self.open.setdefault(name, self.blank(name))
if fields.get("type") and fields["type"] not in entry["types"]:
entry["types"].append(fields["type"])
entry["client"] = entry["client"] or fields.get("client")
# How many query lines this record stands for. Merging by name means a
# name asked for twice in the same second is one record; the count keeps
# that from reading as one lookup.
entry["queries"] += 1
entry["touched"] = time.monotonic()
def get(self, name):
entry = self.open.setdefault(name, self.blank(name))
entry["touched"] = time.monotonic()
return entry
def outcome(self, entry, outcome, reason=None):
rank = RANK[(outcome, reason)]
if rank >= entry["rank"]:
entry["rank"] = rank
entry["outcome"] = outcome
entry["reason"] = reason
def flush_name(self, name):
entry = self.open.pop(name, None)
if entry is None:
return
entry.pop("touched", None)
entry.pop("rank", None)
if entry["outcome"] is None:
# A query whose outcome never appeared: the upstream did not answer
# in time, or dnsmasq logged something this does not recognise.
entry["outcome"] = "unanswered"
for field in ("types", "addresses", "upstream", "reason"):
if not entry[field]:
entry.pop(field)
self.out.write(json.dumps(entry) + "\n")
self.out.flush()
def flush_idle(self, force=False):
cutoff = time.monotonic() - IDLE_FLUSH
for name in [n for n, e in self.open.items() if force or e["touched"] < cutoff]:
self.flush_name(name)
def handle(line, lookups):
line = PREFIX.sub("", line.rstrip("\n"))
match = PATTERNS["query"].match(line)
if match:
lookups.start(match.group("name"), match.groupdict())
return
for reason in ("not-permitted", "non-global-answer"):
match = PATTERNS[reason].match(line)
if match:
entry = lookups.get(match.group("name"))
lookups.outcome(entry, "refused", reason)
# A name nobody was allowed to ask for is noise; a permitted name
# answered with an address pointing inside the operator's own
# network is someone trying to steer the policy, and is marked so a
# supervisor can alert on it without parsing reasons.
if reason == "non-global-answer":
entry["severity"] = "alert"
# Not written out here: the client's other query for the same name
# may still be in flight, and these lines arrive once per query.
return
match = PATTERNS["forwarded"].match(line)
if match:
lookups.get(match.group("name"))["upstream"] = match.group("server")
return
match = PATTERNS["reply"].match(line)
if match:
entry = lookups.get(match.group("name"))
value = match.group("value")
if value in ("NXDOMAIN", "NODATA") or value.startswith("NODATA"):
lookups.outcome(entry, "refused", "no-such-name")
else:
lookups.outcome(entry, "answered")
if value not in entry["addresses"]:
entry["addresses"].append(value)
return
def follow(path, lookups):
"""Read the log as it is appended to, across restarts of the writer.
The resolver is restarted when a name is added to a running session; it
reopens the same file in append mode, so following the path rather than the
handle keeps working. A file that shrank was truncated by something else and
is read again from the start.
"""
handle_ = None
position = 0
last_flush = 0.0
while True:
try:
if handle_ is None:
handle_ = open(path, "r", errors="replace")
handle_.seek(position)
if os.path.getsize(path) < position:
position = 0
handle_.seek(0)
line = handle_.readline()
if line:
position = handle_.tell()
try:
handle(line, lookups)
except Exception as exc: # one bad line must not end the log
print(f"dns-lookup-log: {exc}", file=sys.stderr, flush=True)
# Busy periods must not starve the flush: a steady stream of
# queries would otherwise hold every record open indefinitely.
if time.monotonic() - last_flush > IDLE_FLUSH:
lookups.flush_idle()
last_flush = time.monotonic()
continue
except FileNotFoundError:
handle_ = None
lookups.flush_idle()
last_flush = time.monotonic()
time.sleep(0.2)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, help="the resolver's query log")
parser.add_argument("--output", required=True, help="where to write the JSON records")
args = parser.parse_args()
with open(args.output, "a", buffering=1) as out:
lookups = Lookups(out)
try:
follow(args.source, lookups)
except KeyboardInterrupt:
lookups.flush_idle(force=True)
if __name__ == "__main__":
main()
-144
View File
@@ -1,144 +0,0 @@
#!/bin/bash
# Restricts DNS resolution itself to an allowlist, rather than only
# filtering the IP connections made afterward: without this, Docker's
# embedded resolver at 127.0.0.11 will still resolve *any* domain even once
# the firewall is locked down (verified: `dig` on a non-allowlisted domain
# resolves fine, only the subsequent connect gets blocked), so a compromised
# session could exfiltrate data by encoding it into query names to an
# attacker-controlled domain. Runs a local dnsmasq bound to 127.0.0.1 with
# no fallback resolver, forwarding only allowlisted names to Docker's real
# resolver; everything else gets no upstream configured at all, so it fails
# outright instead of leaking the query. Must run before init-firewall.sh so
# that script's own dig calls (and everything afterward) go through it.
set -Eeuo pipefail
# Same reasoning as init-firewall.sh's: this runs before the firewall in the
# container's startup, and a silent `set -e` abort here is indistinguishable
# from the sandbox never starting for any other reason (#59).
trap 'status=$?; echo "ERROR: dns-proxy.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=fixed-domains.sh
source "$SCRIPT_DIR/fixed-domains.sh"
CONF_FILE=/etc/dnsmasq-sandbox.conf
SERVERS_FILE=/etc/dnsmasq-sandbox-servers.conf
LOG_FILE=/var/log/dnsmasq.log
# Every allowlisted name below is forwarded to Docker's embedded resolver at
# 127.0.0.11, which only exists for containers on a user-defined network
# (compose creates one for the real sandbox). On the default bridge the
# container gets the host's own nameservers instead, dnsmasq has nothing to
# forward to, and the readiness probe at the bottom fails ~24s later with a
# generic timeout that says nothing about why. Falling back to whatever
# resolver is in resolv.conf isn't an option: init-firewall.sh deliberately
# has no egress rule for UDP/53 to an arbitrary destination (see its comment
# on keeping DNS on loopback), so that would just move the failure. Fail
# here instead, naming the actual cause.
if ! grep -qE '^nameserver[[:space:]]+127\.0\.0\.11[[:space:]]*$' /etc/resolv.conf; then
echo "ERROR: Docker's embedded resolver (127.0.0.11) is not this container's" >&2
echo "nameserver, so the DNS allowlist has no upstream to forward to. Attach" >&2
echo "the container to a user-defined docker network (compose does this by" >&2
echo "default); the default bridge network does not provide the resolver." >&2
echo "Current /etc/resolv.conf nameservers:" >&2
grep -E '^nameserver' /etc/resolv.conf >&2 || echo " (none)" >&2
exit 1
fi
touch "$LOG_FILE"
chown dnsmasq:dnsmasq "$LOG_FILE" 2>/dev/null || true
# server=/domain/upstream lines live in their own file (rather than the main
# conf) since dnsmasq only re-reads a --servers-file on SIGHUP - lets
# bin/dejima-allow add a domain to a running session by appending a
# line here and signaling the container's dnsmasq, no restart needed.
{
for domain in "${FIXED_DOMAINS[@]}" "${GITHUB_DNS_SUFFIXES[@]}" ${EXTRA_ALLOWED_DOMAINS:-}; do
# Literal IPs (e.g. a monitoring host given as an IP, not a hostname)
# don't need a DNS entry - there's nothing to resolve.
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
continue
fi
echo "server=/$domain/127.0.0.11"
done
} > "$SERVERS_FILE"
{
cat <<EOF
no-resolv
no-hosts
bind-interfaces
# IPv6 is blocked outright at the firewall (see init-firewall.sh's
# ip6tables policies) - strip AAAA answers too so nothing wastes a
# connection attempt on an address that can never actually be reached.
filter-AAAA
# Reject (and log) any answer for FIXED_DOMAINS/GITHUB_DNS_SUFFIXES that
# resolves to a private RFC1918/loopback/link-local address. Without this,
# a hijacked upstream answer for one of those public domains could resolve
# to an internal address, and since allowed-domains is matched with no
# port restriction (see init-firewall.sh), the top-up below would then
# hand out full TCP-any-port egress to that internal host - turning one
# attacker-influenced public domain into a general internal-network pivot.
# Exempted per-domain below for EXTRA_ALLOWED_DOMAINS, which are expected
# to legitimately be internal hosts (a self-hosted Gitea/Loki instance).
stop-dns-rebind
listen-address=127.0.0.1
port=53
servers-file=$SERVERS_FILE
log-queries
log-facility=$LOG_FILE
user=dnsmasq
pid-file=/var/run/dnsmasq-sandbox.pid
EOF
# EXTRA_ALLOWED_DOMAINS (the configured forge's hosts and LOKI_URL's, plus
# the approved domains the launcher passes in ALLOWED_DOMAINS) are
# expected to potentially be internal/self-hosted addresses, unlike
# FIXED_DOMAINS/GITHUB_DNS_SUFFIXES above, which are always public SaaS
# domains - exempt just these from stop-dns-rebind so a legitimately
# internal forge or Loki host still resolves.
for domain in ${EXTRA_ALLOWED_DOMAINS:-}; do
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
continue
fi
echo "rebind-domain-ok=/$domain/"
done
# init-firewall.sh's ipset population is a one-time snapshot at
# container start; without ongoing tracking, a domain can start failing
# again when its CDN edge rotates to an IP that was never added
# (pypi.org/files.pythonhosted.org/GitHub's release-assets CDN all do
# this). A single wildcard keeps allowed-domains topped up for every
# name dnsmasq ever answers: only allowlisted names have an upstream at
# all (no-resolv + servers-file), so "gets answered" and "is
# allowlisted" are the same set - including domains added mid-session
# by bin/dejima-allow (ipset= can't live in the SIGHUP-reloadable
# servers-file).
# The explicit github-domains lines below still win for GitHub names -
# dnsmasq applies the longest matching ipset= domain - which is what
# keeps GitHub IPs out of this port-unrestricted set (github-domains
# is restricted to 443 by init-firewall.sh; that's the read-only
# guarantee).
echo "ipset=/#/allowed-domains"
for domain in "${GITHUB_DNS_SUFFIXES[@]}"; do
echo "ipset=/$domain/github-domains"
done
} > "$CONF_FILE"
dnsmasq --conf-file="$CONF_FILE"
# dnsmasq daemonizes and returns almost immediately, but give it a moment to
# actually be answering before repointing resolv.conf at it.
ready=0
for _ in $(seq 1 20); do
if dig +time=1 +tries=1 @127.0.0.1 api.anthropic.com >/dev/null 2>&1; then
ready=1
break
fi
sleep 0.2
done
if [ "$ready" -ne 1 ]; then
echo "ERROR: dnsmasq did not come up in time" >&2
exit 1
fi
echo "nameserver 127.0.0.1" > /etc/resolv.conf
+25 -7
View File
@@ -78,11 +78,26 @@ services:
# those rules even at uid 0. NET_RAW goes with it - a raw socket is the
# other way to speak past a filter, and nothing here opens one.
#
# KILL is gone for a reason worth keeping, because it was here for
# something real: `dejima-allow --container` adds a domain to a RUNNING
# session by signalling dnsmasq, which drops to its own user, so whoever
# signals it needs CAP_KILL. dnsmasq is in the filter container, so that is
# where the capability belongs and where bin/dejima declares it.
# KILL is here for tini, and for nothing else in this container.
#
# `init: true` above makes PID 1 the init that reaps for the session, and
# tini forwards every signal it receives to its child. That child is the
# entrypoint, which changes uid in place before exec'ing the session - so
# tini is uid 0 signalling a process whose real AND saved uids are the
# sandbox user's. kill(2) permits that only with CAP_KILL or a uid match,
# and there is no match, so without this every forwarded signal is EPERM.
#
# tini treats a failed forward as fatal, and PID 1 exiting ends the
# container. The signal that arrives in practice is SIGWINCH: the kernel
# sends it to the pty's foreground process group on any terminal resize,
# which includes tini. So without this line, splitting a tmux pane kills
# the session with `[FATAL tini (1)] Unexpected error when forwarding
# signal: 'Operation not permitted'`.
#
# It used to be here for `dejima-allow --container`, which signals dnsmasq;
# that reason moved to the filter container with dnsmasq, and this one was
# not noticed until the capability went away. Nothing in the suite ran an
# init at all - see runtime.bats.
#
# The general lesson, since the next capability will be the same shape:
# what a container needs is not only what its entrypoint does. Things reach
@@ -107,6 +122,7 @@ services:
cap_drop:
- ALL
cap_add:
- KILL
- CHOWN
- DAC_OVERRIDE
- FOWNER
@@ -172,7 +188,7 @@ services:
SANDBOX_HARNESS_SEED: ${SANDBOX_HARNESS_SEED:-}
SANDBOX_HARNESS_STAGED_EMPTY: ${SANDBOX_HARNESS_STAGED_EMPTY:-}
# Opt-in escape hatch from the default hard GitHub-push block - see
# init-firewall.sh and git-push-guard.sh. Off unless explicitly set.
# the egress policy and git-push-guard.sh. Off unless explicitly set.
ALLOW_GITHUB_PUSH: ${ALLOW_GITHUB_PUSH:-0}
# Base URL of a Loki push endpoint (e.g. http://192.0.2.10:3100) to
# ship DNS-query and firewall allow/reject logs to. Its host is
@@ -266,7 +282,9 @@ services:
# launch (it inspects the symlinks and appends the exact mounts
# needed), not listed statically here, since this repo can't assume
# what else might be installed on a given host.
- dejima-bashhistory:/commandhistory
# Same volume as before this path moved, so an operator's existing
# history carries over rather than being orphaned under the old name.
- dejima-bashhistory:/var/opt/shell-history
- nvim-data:${HOME}/.local/share/nvim
- nvim-state:${HOME}/.local/state/nvim
- nvim-cache:${HOME}/.cache/nvim
+1426
View File
File diff suppressed because it is too large Load Diff
+16 -9
View File
@@ -23,13 +23,18 @@ require_setpcap
#
# What this container does need is to be pointed at the resolver enforcing the
# name allowlist. /etc/resolv.conf is per container even when the namespace is
# shared, so dns-proxy.sh repointing the filter container's file says nothing
# shared, so egress-policy repointing the filter container's file says nothing
# about this one - which Docker generated pointing straight at its embedded
# resolver. Left alone, every lookup the session makes would go around dnsmasq
# and the DNS allowlist would apply to nothing.
# resolver. Left alone, every lookup the session makes would go around the
# restricted resolver and the name allowlist would apply to nothing.
#
# The same value dns-proxy.sh writes for itself, at the same loopback address,
# because loopback is shared with the container the resolver runs in.
# It would not silently succeed, either: the policy restricts which uids may
# reach a resolver outside this namespace, and the session user is not one of
# them - so a session left pointing at the embedded resolver resolves nothing
# at all rather than resolving anything it likes.
#
# The value below is the same one egress-policy writes for itself, at the same
# loopback address, because loopback is shared with the resolver's container.
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
echo "nameserver 127.0.0.1" > /etc/resolv.conf
fi
@@ -506,8 +511,8 @@ if [ -n "${DEJIMA_NOTIFY_DIR:-}" ]; then
fi
# Test harness hook (test/): everything above this point is the real
# startup sequence - DNS proxy, firewall, IPv6 block, logging,
# privilege-drop prep. Skip execing $SANDBOX_CMD so the suite can `docker
# startup sequence - resolver, packet filter, logging, privilege-drop prep.
# Skip execing $SANDBOX_CMD so the suite can `docker
# exec` into a live, fully-initialized container and assert against it,
# exercising the actual code path instead of a reimplementation of it that
# could drift out of sync.
@@ -516,8 +521,10 @@ if [ "${SANDBOX_TEST_MODE:-0}" = "1" ]; then
fi
# Drop root -> the sandbox user for the actual session; that user has no
# sudo/setuid path back to root after this, so it can't re-run
# init-firewall.sh itself to widen the allowlist mid-session.
# sudo/setuid path back to root after this, so it can't re-run the egress
# policy itself to widen the allowlist mid-session. It could not anyway: the
# policy is installed from the container beside this one, which holds the
# capability, and this one declares none.
#
# And drop the bounding set with it, so "no path back to root" and "no
# capability if there were one" are two separate things rather than one
+107 -58
View File
@@ -1,6 +1,8 @@
#!/bin/bash
# Brings up this network namespace's egress control: the DNS proxy, the packet
# filter, and the two log producers that watch them.
# Brings up this network namespace's egress control: the packet filter, the
# restricted resolver, and the log producers that watch them. All of that is
# docker/egress-policy; this file is what turns a launch's configuration into
# the permitted set that policy is given.
#
# Its own script rather than a block inside entrypoint.sh because the namespace
# it acts on and the session that runs in it need not be in the same container.
@@ -8,8 +10,8 @@
# entrypoint does is a property of a container.
#
# Called with the caller's environment: DEJIMA_FORGE_HOSTS, LOKI_URL,
# ALLOWED_DOMAINS, ENABLE_FIREWALL and the harness values fixed-domains.sh
# reads. It resolves nothing host-side itself.
# ALLOWED_DOMAINS, ALLOW_GITHUB_PUSH, GH_TOKEN, ENABLE_FIREWALL and the harness
# values fixed-domains.sh reads. It resolves nothing host-side itself.
set -Eeuo pipefail
# Same reason as entrypoint.sh's: under `set -e` alone a failure here aborts
# with no output, and the only symptom is a container that never appeared (#59).
@@ -17,65 +19,112 @@ trap 'status=$?; echo "ERROR: firewall-up.sh aborted at line $LINENO (exit $stat
# shellcheck source=caps-lib.sh
source /usr/local/bin/caps-lib.sh
# shellcheck source=fixed-domains.sh
source /usr/local/bin/fixed-domains.sh
[ "${ENABLE_FIREWALL:-1}" = "1" ] || exit 0
# DEJIMA_FORGE_HOSTS is resolved host-side by bin/dejima (forge_hosts, in
# dejima-provider-lib.sh) from whichever forge is configured, so this file
# does not derive or name any one forge's hostnames itself.
#
# LOKI_URL (a full URL) still needs its own derivation here - Loki's host
# has to be reachable for log-shipper.sh to actually deliver anything.
loki_host=""
# dejima-provider-lib.sh) from whichever forge is configured, so this file does
# not derive or name any one forge's hostnames itself. Marked internal: a
# self-hosted forge is routinely on the operator's own network, and its address
# is something they configured rather than something an answer decides.
for _host in ${DEJIMA_FORGE_HOSTS:-}; do
dejima_permit_open "$_host" internal
done
# LOKI_URL is a full URL and still needs its own derivation here - Loki's host
# has to be reachable for log-shipper.sh to deliver anything. Internal for the
# same reason as the forge.
if [ -n "${LOKI_URL:-}" ]; then
loki_host=$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')
dejima_permit_open "$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')" internal
fi
extra_domains="${DEJIMA_FORGE_HOSTS:-} ${loki_host} ${ALLOWED_DOMAINS:-}"
# A model the operator serves themselves, from [backend.<name>] base_url. Same
# class as the forge and Loki and exempted for the same reason: they configured
# the address, so there is nothing for an answer to decide - and a self-served
# endpoint is on their own network about as often as not.
dejima_permit_open "${DEJIMA_BACKEND_HOST:-}" internal
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
# dns-proxy.sh must run first: it repoints /etc/resolv.conf at a local
# dnsmasq that only resolves allowlisted names, so init-firewall.sh's own
# dig calls (and everything afterward) go through the same restriction.
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/dns-proxy.sh
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/init-firewall.sh
# Approved domains, from `dejima-allow --global` and the project's own
# `.dejima/allowed-domains`. NOT marked internal: these are reviewed with the
# project and expected to be public names, and the whole value of the answer
# check is that a public name answered with an internal address is refused. An
# approved domain that really is internal makes the container say so at start,
# naming the entry - see egress-policy's check on the permitted names.
#
# A name the forge class already covers is skipped rather than added: it is
# reachable under its suffix entry, and adding it here would give the resolver
# a more specific line feeding the open-class set, so its answers would stop
# reaching the forge set. The port restriction would then rest entirely on the
# forge's published ranges covering that address, which is one layer where the
# design has two. A project declaring github.com is the ordinary way to hit it.
for _domain in ${ALLOWED_DOMAINS:-}; do
[ "$(dejima_class_for "$_domain")" = forge ] && continue
dejima_permit_open "$_domain"
done
# NFLOG groups only exist once init-firewall.sh has run, so ulogd2 (the
# NFLOG consumer) and the Loki shipper start after it. Both are
# best-effort visibility on top of the enforcement above, not required
# for it - log-shipper.sh itself no-ops if LOKI_URL isn't set. Runs as
# its own unprivileged `ulogd` user (opens the NFLOG socket and output
# files first, then drops), not root - it's parsing headers from
# attacker-influenceable packets (whatever a compromised session sends),
# so a bug in it shouldn't be a path back to root in the container.
#
# `ulogd -d` daemonizes immediately and always exits 0 from this shell's
# point of view, regardless of whether the daemon itself goes on to
# start successfully - so `set -e` above won't catch a failure here (nor
# should it: logging is best-effort, not something worth failing the
# whole container over). Check separately so a failure is at least
# visible instead of silently producing no logs.
/usr/sbin/ulogd -d -u ulogd -c /etc/ulogd-sandbox.conf
sleep 0.5
if ! pgrep -x ulogd >/dev/null; then
echo "WARNING: ulogd2 failed to start - connection logging will be unavailable (see /var/log/ulogd.log)" >&2
fi
# Unlike ulogd2 above, this one stays root - deliberately, not an
# oversight. What it ships is an oversight log of the session running
# beside it, and letting the sandbox user own the shipper would let a
# compromised session tamper with or kill its own logging. Root buys
# tamper-resistance that a security log needs and a best-effort netflow
# counter does not.
#
# What it reads is dnsmasq's query log and ulogd2's output, both written
# by root-owned daemons here and neither session-writable.
#
# Root, and nothing else: it parses content the session's own traffic
# decides, and it needs no capability to read two files its gid already
# opens or to POST them. So it keeps the uid that makes it untamperable
# and holds none of the rest.
#
# It outlives this script, and reparents to the container's init when this
# one exits - which is where a daemon watching a session belongs. Started
# from the entrypoint, it was a child of the very session process it exists
# to keep an eye on.
drop_caps -- -c 'exec "$@"' dejima /usr/local/bin/log-shipper.sh &
# Declared internal destinations, for the case the check above names: an
# operator whose approved domain genuinely points inside their network says so
# here rather than losing the check for every other name.
#
# This exempts; it does not permit. Reaching a destination is an approval and
# lives in the lists above, and a key that did both would make naming a host
# here enough to reach it - which is not what it says it does, and is a grant
# nobody reviewing an allowlist would think to look for.
#
# Naming something nothing permitted is a disagreement inside the operator's
# own configuration, so it stops the container here rather than leaving a
# destination silently unreachable and an exemption silently unused.
_unknown=""
for _domain in ${DEJIMA_INTERNAL_DOMAINS:-}; do
dejima_mark_internal "$_domain" || _unknown="$_unknown $_domain"
done
if [ -n "$_unknown" ]; then
echo "ERROR: [core] internal_domains names destinations nothing permits:$_unknown" >&2
echo "That key exempts a name from the check that refuses an answer pointing" >&2
echo "inside a private network. It does not make the name reachable - approve" >&2
echo "it as well, per project or globally:" >&2
echo "" >&2
for _domain in $_unknown; do
echo " dejima-allow --global $_domain" >&2
done
exit 1
fi
_join() { local IFS=' '; echo "${*:-}"; }
EGRESS_FORGE_NAMES="$(_join ${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"})"
EGRESS_ALLOWED_NAMES="$(_join ${DEJIMA_OPEN_CLASS[@]+"${DEJIMA_OPEN_CLASS[@]}"})"
EGRESS_INTERNAL_OK="$(_join ${DEJIMA_INTERNAL_CLASS[@]+"${DEJIMA_INTERNAL_CLASS[@]}"})"
# What the policy should call that list when it tells an operator to add
# something to it. It knows the variable it reads; only dejima knows the
# configuration key an operator actually sets.
EGRESS_INTERNAL_OK_HINT='[core] internal_domains'
export EGRESS_FORGE_NAMES EGRESS_ALLOWED_NAMES EGRESS_INTERNAL_OK EGRESS_INTERNAL_OK_HINT
export EGRESS_FORGE_SSH="${ALLOW_GITHUB_PUSH:-0}"
# The published-ranges endpoint is rate-limited per source address when
# unauthenticated, across everything on the host - so a host starting sandboxes
# in a loop exhausts it and every launch after that fails on a credential that
# was available. Same read-only token `gh` already gets (docker-compose.yml),
# sent to the API it belongs to and nowhere else.
[ -n "${GH_TOKEN:-}" ] && export EGRESS_FORGE_META_AUTH="Authorization: Bearer $GH_TOKEN"
/usr/local/sbin/egress-policy apply
# Best-effort visibility on top of the enforcement above, not part of it -
# log-shipper.sh no-ops if LOKI_URL isn't set. It stays root, deliberately:
# what it ships is an oversight log of the session running beside it, and
# letting the sandbox user own the shipper would let a compromised session
# tamper with or kill its own logging. Root buys tamper-resistance that a
# security log needs.
#
# Root, and nothing else: it parses content the session's own traffic decides,
# and needs no capability to read two files its uid already opens or to POST
# them. So it keeps the uid that makes it untamperable and holds none of the
# rest.
#
# It outlives this script, and reparents to the container's init when this one
# exits - which is where a daemon watching a session belongs. Started from the
# entrypoint, it was a child of the very session process it exists to watch.
drop_caps -- -c 'exec "$@"' dejima /usr/local/bin/log-shipper.sh &
+126 -71
View File
@@ -1,17 +1,26 @@
# Sourced by both init-firewall.sh (IP-based ipset allowlist) and
# dns-proxy.sh (name-based DNS allowlist) so the two can't drift apart.
# Composes the permitted set egress-policy is given, from what the sandbox's
# own machinery needs plus what this launch was configured with.
#
# What belongs here is what the sandbox's own machinery cannot run without -
# the release CDN the tools baked into the image fetch binaries from, the npm
# registry those tools update through, and whatever the SELECTED HARNESS
# declared it needs (api_domains in its manifest, arriving as
# Sourced by firewall-up.sh. Exports three lists, because a destination's
# permitted ports are a property of the destination and not of how it was
# named:
#
# DEJIMA_FORGE_CLASS reachable on HTTPS only, plus SSH with the opt-in
# DEJIMA_OPEN_CLASS reachable on any port but the DNS ports
# DEJIMA_INTERNAL_CLASS entries whose answers may point inside a private
# network; every one must also be in a list above
#
# What belongs in the built-in part of these lists is what the sandbox cannot
# run without - the release CDN the tools baked into the image fetch binaries
# from, the npm registry those tools update through, and whatever the SELECTED
# HARNESS declared it needs (api_domains in its manifest, arriving as
# SANDBOX_HARNESS_DOMAINS). Nothing else.
#
# The harness's names are not written here because they are the harness's: a
# name built in is inherited by every harness while a second one still lacks
# its own. registry.npmjs.org stays, because it is not only the
# harness's - nvim's mason fetches language servers through it, and
# [core] command = nvim is a supported way to run a session.
# its own. registry.npmjs.org stays, because it is not only the harness's -
# nvim's mason fetches language servers through it, and [core] command = nvim
# is a supported way to run a session.
#
# Language ecosystems and editor tooling deliberately do not: a Python, Go or
# Rust project reaches its own package host through an approval, either
@@ -21,78 +30,124 @@
# use for it, and - since a project's declaration is also what a hardened CI
# runner reads its allowlist from (docs/ci.md) - a name that is granted by
# being built in is a name nobody ever reviewed.
FIXED_DOMAINS=(
"registry.npmjs.org"
"release-assets.githubusercontent.com"
)
# Split on spaces explicitly, not on whatever IFS the caller happens to have.
# init-firewall.sh sources this file with IFS=$'\n\t' set, dns-proxy.sh with
# the default - so an unquoted expansion gives the two consumers different
# lists from the same value, which is the drift this shared file exists to
# prevent. A harness declaring two api_domains would reach init-firewall as
# one glued element and fail its DNS check fatally, refusing to start.
# init-firewall.sh already restores IFS=' ' around its own extra-domains loop
# for exactly this reason.
if [ -n "${SANDBOX_HARNESS_DOMAINS:-}" ]; then
IFS=' ' read -ra _dejima_harness_domains <<< "$SANDBOX_HARNESS_DOMAINS"
FIXED_DOMAINS+=(${_dejima_harness_domains[@]+"${_dejima_harness_domains[@]}"})
fi
# The harness's API endpoint is allowlisted only when this session has no
# credential proxy (kill switch or API-key auth), where DNS is the only path
# to it. With the proxy on, the session reaches the API through the forwarder
# rather than by resolving this name (under the tls transport, via
# entrypoint.sh's /etc/hosts pin), so leaving it unresolvable defeats a bypass
# that looks the name up by DNS. It is not a route block: the API's address
# stays reachable as an IP literal for as long as another allowlisted name
# resolves to the same one.
# Forge class. GitHub is reachable for reading - clone, fetch, gh over HTTPS -
# and never for pushing, and the port restriction is what makes that a fact
# rather than a policy: with 22 closed there is no transport left for the
# forwarded agent's real key to authenticate a push with, whatever a hook does
# or does not catch. ALLOW_GITHUB_PUSH=1 is the only thing that opens it.
#
# Appended here rather than to EXTRA_ALLOWED_DOMAINS because it is a public
# SaaS name: dns-proxy.sh exempts the extra list from stop-dns-rebind, and
# init-firewall.sh downgrades a failed lookup there to a warning.
#
# Which name this is comes from the provider (proxied_domain); the rule
# about when to allowlist it is dejima's, and stays here.
if [ -z "${SANDBOX_CREDENTIAL_PROXY_SOCKET:-}" ] \
&& [ -n "${SANDBOX_BACKEND_PROXIED_DOMAIN:-}" ]; then
FIXED_DOMAINS+=("$SANDBOX_BACKEND_PROXIED_DOMAIN")
fi
# GitHub itself is allowlisted by IP range (see github-domains ipset,
# populated from api.github.com/meta), never by resolving these names to
# specific IPs - but the container's own tools (git, gh, curl) still need to
# resolve these names to *something* before connecting, so the DNS proxy
# needs to know they're allowed even though init-firewall.sh never digs them.
# Suffix matching means these also cover their actual subdomains in use
# (api.github.com, codeload.github.com, objects.githubusercontent.com, etc.)
# without enumerating each one.
GITHUB_DNS_SUFFIXES=(
# Suffix entries, because dnsmasq matches a domain and everything under it: one
# line covers api.github.com, codeload.github.com and the rest without
# enumerating them. Stating the class here is what keeps the restriction
# attached to the destination - an address reached by resolving one of these
# names gets the same ports as one reached from the published ranges, so a name
# cannot be the way around the range's restriction.
DEJIMA_FORGE_CLASS=(
"github.com"
"githubusercontent.com"
"githubassets.com"
)
# Which ipset an allowlisted name's addresses belong in.
# Open class: reachable on any port, because these are destinations whose
# protocol dejima does not get to decide.
DEJIMA_OPEN_CLASS=(
"registry.npmjs.org"
"release-assets.githubusercontent.com"
)
DEJIMA_INTERNAL_CLASS=()
# release-assets.githubusercontent.com is under a forge-class suffix, and the
# forge class wins wherever the two overlap - by design, not by accident. It is
# fetched over HTTPS like everything else under that suffix, so the narrower
# class costs it nothing and the alternative would be a name that quietly
# widened the forge.
# Split on spaces explicitly, not on whatever IFS the caller happens to have,
# so every consumer gets the same list from the same value. A harness declaring
# two api_domains would otherwise arrive as one glued element and fail its
# lookup, refusing to start.
if [ -n "${SANDBOX_HARNESS_DOMAINS:-}" ]; then
IFS=' ' read -ra _dejima_harness_domains <<< "$SANDBOX_HARNESS_DOMAINS"
DEJIMA_OPEN_CLASS+=(${_dejima_harness_domains[@]+"${_dejima_harness_domains[@]}"})
fi
# The harness's API endpoint is permitted only when this session has no
# credential proxy (kill switch or API-key auth), where DNS is the only path to
# it. With the proxy on, the session reaches the API through the forwarder
# rather than by resolving this name (under the tls transport, via
# entrypoint.sh's /etc/hosts pin), so leaving it unresolvable defeats a bypass
# that looks the name up by DNS. It is not a route block: the API's address
# stays reachable as an IP literal for as long as another permitted name
# resolves to the same one.
#
# GitHub's are restricted to port 443 by init-firewall.sh, and that
# restriction IS the read-only guarantee: no transport is left for a
# forwarded agent key to push with. allowed-domains has no port restriction,
# so a GitHub address landing there silently undoes the arrangement.
# Open class and not exempted from the answer check, because it is a public
# SaaS name: an answer for it pointing inside the operator's network is the
# thing that check exists to refuse.
#
# dnsmasq applies the longest matching ipset= line, so dns-proxy.sh gets this
# right for free. Every other path has to ask, and two did not - the
# extra-domains loop and bin/dejima-allow's live add both appended
# unconditionally. A project declaring github.com therefore opened port 22 to
# whatever address was cached at container start, presenting as an
# intermittent startup failure because the self-check re-resolves and only
# sees the hole when the second answer matches the first.
ipset_for_domain() {
local domain="$1" suffix
for suffix in "${GITHUB_DNS_SUFFIXES[@]}"; do
if [ "$domain" = "$suffix" ] || [[ "$domain" == *".$suffix" ]]; then
printf 'github-domains'
# Which name this is comes from the provider (proxied_domain); the rule about
# when to permit it is dejima's, and stays here.
if [ -z "${SANDBOX_CREDENTIAL_PROXY_SOCKET:-}" ] \
&& [ -n "${SANDBOX_BACKEND_PROXIED_DOMAIN:-}" ]; then
DEJIMA_OPEN_CLASS+=("$SANDBOX_BACKEND_PROXIED_DOMAIN")
fi
# Adds one entry to the open class, marking it exempt from the answer check
# when it is a destination dejima already knows is inside the operator's own
# network. That exemption is not handed out to approved domains in general:
# `.dejima/allowed-domains` is reviewed with the project and is expected to
# carry public names, and exempting a public name is what turns one
# attacker-influenced answer into egress to an internal host. A self-hosted
# forge or log sink is different - the operator configured its address, so
# there is nothing for an answer to decide.
dejima_permit_open() {
local entry="$1" internal="${2:-}"
[ -n "$entry" ] || return 0
DEJIMA_OPEN_CLASS+=("$entry")
[ "$internal" = internal ] && DEJIMA_INTERNAL_CLASS+=("$entry")
return 0
}
# Exempts an entry from the answer check WITHOUT permitting it. The two are
# separate decisions and only one of them is a grant: whether a destination may
# be reached is an approval, and whether an answer for it may point inside the
# operator's network is this. A key that did both would mean naming a host in
# [core] internal_domains was enough to make it reachable, which is not what
# the key says it does and not what anyone reviewing an allowlist would expect
# to find in it.
#
# Returns non-zero for an entry nothing has permitted, so the caller can say
# which one rather than leaving it to be discovered as a destination that is
# quietly unreachable.
dejima_mark_internal() {
local entry="$1" known
[ -n "$entry" ] || return 0
for known in ${DEJIMA_OPEN_CLASS[@]+"${DEJIMA_OPEN_CLASS[@]}"} \
${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"}; do
if [ "$known" = "$entry" ]; then
DEJIMA_INTERNAL_CLASS+=("$entry")
return 0
fi
done
printf 'allowed-domains'
return 1
}
# Which class a name belongs to, by suffix. Anything under a forge-class entry
# is forge class, whoever is asking and however it arrived.
#
# This is why the file is shared rather than restated: bin/dejima-allow adds a
# domain to a session that is already running, and a forge name added to the
# open class there would be reachable on every port - the read-only guarantee
# gone, from a command whose whole output says it granted one domain. It went
# wrong exactly that way when the answer lived in two places.
dejima_class_for() {
local domain="$1" suffix
for suffix in ${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"}; do
if [ "$domain" = "$suffix" ] || [[ "$domain" == *".$suffix" ]]; then
printf 'forge'
return 0
fi
done
printf 'open'
}
-502
View File
@@ -1,502 +0,0 @@
#!/bin/bash
# Default-deny egress firewall for the sandbox, allowlisting only GitHub's
# published ranges, the fixed/extra domains below, and the Docker host
# itself. The overall shape (ipset + iptables, GitHub ranges via api.github.com/meta,
# allowlist domains resolved via dig) follows the same idea sketched in
# Anthropic's public reference dev container
# (https://github.com/anthropics/claude-code/tree/main/.devcontainer), but
# everything here - IPv6 blocking, ulogd2 verdict logging, the GitHub
# push/pull split, GH_TOKEN auth, and the extra-domains allowlist - is this
# project's own.
set -Eeuo pipefail
IFS=$'\n\t'
# Nothing here may die quietly. Under `set -e` a failure aborts with no output
# and the container simply never appears, which reads as the sandbox being
# broken rather than one command failing. Three CI failures went to two
# instances of exactly that (#59), neither leaving a line to go on.
#
# This is the floor; the explicit `exit 1` paths below say more. -E extends it
# into functions and subshells, where it would not fire at all.
#
# A failure inside a command substitution reports twice, from the subshell and
# from the containing statement. Worth the noise: for `x=$(a | b)` the first
# line is the only thing saying whether it was `a` or `b`.
trap 'status=$?; echo "ERROR: init-firewall.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=fixed-domains.sh
source "$SCRIPT_DIR/fixed-domains.sh"
# Docker's embedded-DNS NAT rules live in the same table we're about to
# flush - snapshot them first so they can be reinstated afterward.
docker_dns_nat_rules=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
# Reset to a known-empty state before laying down the real ruleset.
iptables -F
iptables -X
iptables -t nat -F
iptables -t nat -X
iptables -t mangle -F
iptables -t mangle -X
ipset destroy allowed-domains 2>/dev/null || true
ipset destroy github-domains 2>/dev/null || true
# Block IPv6 outright - there's no IPv6 equivalent of the allowlist below, so
# without this, any host with IPv6 egress enabled would let a container
# bypass the entire IPv4-based firewall via an AAAA-resolved address. Only
# tolerate ip6tables being entirely unusable (IPv6 disabled at the kernel
# level, e.g. no ip6_tables module) - once it's confirmed to work at all, a
# failure partway through must fail the whole script (set -e, no more
# blanket `|| true`) rather than silently leaving IPv6 wide open.
#
# The `else` below used to log that the family was presumably off at the
# kernel level, and continue. That inferred a fact about the WORLD from a fact
# about a TOOL, and the two are not the same condition: a container whose kernel
# lacks ip6_tables, on a host with IPv6 enabled, took that branch, logged a
# reassuring line, and ran with IPv6 live and unfiltered. Nothing failed,
# because the only thing that had happened was a check that could not tell.
#
# So the branch asks the kernel instead. /proc/net/if_inet6 lists every IPv6
# address on every interface, and it is the netns's own view - present with
# `::1` alone when the stack is up and nothing is reachable, absent entirely
# when IPv6 is compiled out.
#
# Loopback does not count. Refusing to start over `::1` would refuse every
# container that has no IPv6 connectivity at all, which is a false refusal
# for the common case. What matters is an address on an interface that leaves
# the box.
_ipv6_off_box() {
[ -e /proc/net/if_inet6 ] || return 1
# address ifindex prefixlen scope flags devname
awk '$6 != "lo" { found = 1 } END { exit !found }' /proc/net/if_inet6
}
if ip6tables -L >/dev/null 2>&1; then
ip6tables -F
ip6tables -X
ip6tables -P INPUT DROP
ip6tables -P OUTPUT DROP
ip6tables -P FORWARD DROP
elif _ipv6_off_box; then
echo "ip6tables is unusable here, and IPv6 is live on an interface that" >&2
echo "leaves this container. Turning the family off in this network" >&2
echo "namespace, since it cannot be filtered." >&2
# Written to /proc directly rather than through sysctl(8), which is one
# more thing that has to be in the image for the firewall to hold. Per
# interface as well as `all`, because which of those flushes existing
# addresses has varied across kernels.
for _conf in /proc/sys/net/ipv6/conf/*/disable_ipv6; do
[ -w "$_conf" ] || continue
echo 1 > "$_conf" 2>/dev/null || true
done
if _ipv6_off_box; then
echo "" >&2
echo "IPv6 is still up and there is no way to filter it from here, so" >&2
echo "every rule below would be bypassable by an AAAA-resolved address." >&2
echo "Refusing to start rather than reporting a boundary that is not" >&2
echo "there. Either give the container a usable ip6tables (the" >&2
echo "ip6_tables module on the host), or disable IPv6 for its network." >&2
exit 1
fi
echo "IPv6 is off in this namespace."
else
echo "ip6tables unusable, and no IPv6 address outside loopback - nothing to filter"
fi
# Reinsert just the rules captured above - nothing else Docker had set up in
# nat comes back, so the reset above stays meaningful.
if [ -n "$docker_dns_nat_rules" ]; then
echo "Reinstating captured Docker DNS rules"
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
echo "$docker_dns_nat_rules" | xargs -L 1 iptables -t nat
else
echo "No Docker DNS rules were present to restore"
fi
# Allow localhost. Every DNS query this container makes starts here: to
# dnsmasq on 127.0.0.1, and from there to Docker's embedded resolver on
# 127.0.0.11 (see resolv.conf). What this rule does NOT cover is the hop
# after that, where the embedded resolver forwards an external name to the
# host's nameservers - see the owner-matched rule further down, which is
# there because that hop can leave this namespace. Still no "UDP/53 to
# anywhere" rule, which would be an unrestricted-destination exfil channel.
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
# Create ipsets with CIDR support. GitHub's ranges get their own set,
# restricted below to port 443 only: the sandbox is meant to be able to read
# from GitHub (clone/fetch/gh over HTTPS) but never push there, and blocking
# SSH (port 22) to it entirely means that's not just policy but physically
# enforced - there's no transport left for the forwarded ssh-agent's real
# key to authenticate a push with, regardless of what a hook does or
# doesn't catch.
ipset create allowed-domains hash:net
ipset create github-domains hash:net
# Pull GitHub's published IP ranges so GitHub traffic can be allowed without
# opening up the entire internet.
echo "Looking up GitHub's IP ranges..."
# Authenticated whenever GH_TOKEN is set: unauthenticated /meta is capped at
# 60 requests per hour per source IP, shared across every container start and
# everything else on the host, so a session that starts sandboxes in a loop
# exhausts it and every subsequent launch fails here. Same read-only token
# `gh` already gets (see docker-compose.yml), sent to the API it belongs to.
gh_auth=()
if [ -n "${GH_TOKEN:-}" ]; then
gh_auth=(-H "Authorization: Bearer $GH_TOKEN")
fi
# Bounded, retried, and reported. All three were missing, and the combination
# was worse than any of them alone (#59).
#
# `curl -s` with no --max-time has no deadline at all - it waits as long as the
# kernel keeps retrying the SYN. And under `set -euo pipefail` at the top of
# this file, a curl that exits non-zero takes the whole script down ON THE
# ASSIGNMENT, so every diagnostic below it is unreachable in exactly the case
# that needs one: the container dies having printed nothing after the line
# above, and the operator - or CI - sees only "the container never became
# ready". The `|| status=$?` is what keeps the failure ours to report.
#
# Retried because both failure modes here are transient. A blip costs a few
# seconds instead of the launch; and a burst of container starts can trip
# GitHub's secondary rate limit, which answers 403 with a JSON body rather
# than a transport error - so the retry has to wrap the response check too,
# not just the transfer. curl's own --retry would not cover that case.
fetch_github_ranges() {
local attempt status body
for attempt in 1 2 3; do
status=0
# -S so curl's own reason ("Operation timed out after ...") reaches
# stderr and therefore `docker logs`; -s alone would swallow it.
body=$(curl -sS --connect-timeout 5 --max-time 10 \
${gh_auth[@]+"${gh_auth[@]}"} https://api.github.com/meta) || status=$?
if [ "$status" -ne 0 ]; then
echo " attempt $attempt/3: curl exited $status" >&2
elif [ -z "$body" ]; then
echo " attempt $attempt/3: empty response" >&2
elif ! printf '%s' "$body" | jq -e '.web and .api and .git' >/dev/null 2>&1; then
# An API error - rate limiting, a bad token - comes back as a JSON
# body carrying a `message` and no ranges. Surface it rather than
# reporting the response as malformed, which points at the wrong
# thing entirely.
echo " attempt $attempt/3: $(printf '%s' "$body" \
| jq -re '.message' 2>/dev/null || echo 'response carried no ranges')" >&2
else
printf '%s' "$body"
return 0
fi
# `if`, not `[ ... ] && sleep`: the AND-list's status on the last
# attempt is 1, which set -e would take as the script failing here
# rather than falling through to the message below.
if [ "$attempt" -lt 3 ]; then
sleep $((attempt * 2))
fi
done
return 1
}
if ! gh_ranges=$(fetch_github_ranges); then
echo "ERROR: could not fetch GitHub's IP ranges from api.github.com/meta." >&2
echo "Tried three times over ~36s; the reason for each attempt is above." >&2
echo "The sandbox cannot start without them - the firewall would deny every" >&2
echo "GitHub range, so git, gh and the API would all fail once inside." >&2
exit 1
fi
echo "Loading GitHub's ranges into the ipset..."
while read -r cidr; do
if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
echo "ERROR: GitHub meta returned a malformed CIDR range: $cidr"
exit 1
fi
echo " + $cidr"
# -exist: aggregate -q collapses overlapping ranges within each of
# web/api/git, but not necessarily across all three combined - the same
# crash this already caused once on the allowed-domains ipset (a
# duplicate add errors, and set -e turns that into the whole firewall,
# and thus the whole container, failing to start).
ipset add github-domains "$cidr" -exist
done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
# Resolve and add other allowed domains (shared with dns-proxy.sh's name
# allowlist via fixed-domains.sh, so the two can't drift apart)
# Same treatment as the GitHub fetch above, and for the same reason: this loop
# was `ips=$(dig ... | awk ...)`, which under `set -o pipefail` dies on the
# ASSIGNMENT when dig exits non-zero - silently, before the "returned nothing"
# check below can run. It aborted a CI container twice, at a different domain
# each time, with the log simply stopping mid-loop (#59).
#
# Retried because a lookup here is the first traffic through a dnsmasq that
# entrypoint.sh started moments ago, and because a CDN's resolver being briefly
# unavailable should cost a second, not the container. +time/+tries bound each
# attempt: dig's defaults are 5s x 3 tries, so a single unanswered lookup could
# spend 15s of a startup budget on its own.
resolve_domain() {
local domain="$1" attempt status answer ips
for attempt in 1 2 3; do
status=0
answer=$(dig +noall +answer +time=3 +tries=1 A "$domain" 2>&1) || status=$?
if [ "$status" -ne 0 ]; then
echo " attempt $attempt/3: dig exited $status${answer:+ - $answer}" >&2
else
ips=$(printf '%s\n' "$answer" | awk '$4 == "A" {print $5}')
if [ -n "$ips" ]; then
printf '%s\n' "$ips"
return 0
fi
# A CNAME-only answer, an empty one, or a rebind rejection. Printing
# what did come back separates "no such name" from "dnsmasq refused
# this answer", which look identical from an empty A list.
echo " attempt $attempt/3: no A records${answer:+ - $answer}" >&2
fi
if [ "$attempt" -lt 3 ]; then
sleep "$attempt"
fi
done
return 1
}
for domain in "${FIXED_DOMAINS[@]}"; do
echo "Looking up $domain..."
if ! ips=$(resolve_domain "$domain"); then
echo "ERROR: DNS lookup for $domain failed - each attempt is above." >&2
echo "The firewall cannot allowlist a domain it cannot resolve, and the" >&2
echo "sandbox would start with that domain unreachable, so this is fatal." >&2
exit 1
fi
while read -r ip; do
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "ERROR: $domain resolved to something that isn't an IPv4 address: $ip"
exit 1
fi
echo " + $ip ($domain)"
# -exist: two domains can resolve to the same IP (e.g. pypi.org and
# files.pythonhosted.org share Fastly edge IPs), and ipset otherwise
# errors on a duplicate add, which -e/set -e turns into the whole
# firewall (and thus the container) failing to start.
ipset add allowed-domains "$ip" -exist
done < <(echo "$ips")
done
# Resolve and add caller-supplied extra domains (e.g. a Gitea host), if any.
# Unlike the fixed domains above, failures here are non-fatal: a project may
# be run without any Gitea config set, or a host may be transiently
# unresolvable, and that shouldn't prevent the firewall from coming up.
if [ -n "${EXTRA_ALLOWED_DOMAINS:-}" ]; then
# The script-wide IFS above drops plain space to tighten word splitting
# elsewhere, but EXTRA_ALLOWED_DOMAINS is a space-separated list by
# construction (entrypoint.sh joins DEJIMA_FORGE_HOSTS/LOKI_URL host/
# ALLOWED_DOMAINS with spaces) - restore it just for this
# loop, or a multi-entry value silently collapses into one bogus token.
IFS=' '
for domain in $EXTRA_ALLOWED_DOMAINS; do
# A literal IP (e.g. a monitoring host given as an IP, not a
# hostname) needs no resolution - add it directly.
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "Adding $domain (extra, literal IP)"
ipset add allowed-domains "$domain" -exist
continue
fi
echo "Resolving $domain (extra)..."
# Through resolve_domain like the fixed domains, which is what makes
# the comment above true. `ips=$(dig ... | awk ...)` did not merely
# fail to warn on a failing dig - under `set -o pipefail` it took the
# whole script down on the assignment, so "non-fatal" held for a
# domain that resolved to nothing and not for one whose lookup errored.
if ! ips=$(resolve_domain "$domain"); then
echo "WARNING: Failed to resolve extra domain $domain, skipping"
continue
fi
# Not always allowed-domains: that set carries no port restriction,
# so a GitHub address landing in it is reachable on 22 as well and the
# read-only guarantee is gone. See ipset_for_domain.
target_set=$(ipset_for_domain "$domain")
while read -r ip; do
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "WARNING: Invalid IP from DNS for $domain: $ip, skipping"
continue
fi
echo "Adding $ip for $domain (-> $target_set)"
ipset add "$target_set" "$ip" 2>/dev/null || true
done < <(echo "$ips")
done
fi
# The Docker host's IP, taken from the container's default route - needed
# below so the sandbox can still reach services running on the host itself.
HOST_IP=$(ip -4 route show default | awk '{print $3; exit}')
if [ -z "$HOST_IP" ]; then
echo "ERROR: Couldn't determine the Docker host's IP from the default route"
exit 1
fi
echo "Docker host IP: $HOST_IP"
# Only the host itself, not its whole subnet - other machines on the same
# LAN/VPC have no reason to be reachable from this container.
iptables -A INPUT -s "$HOST_IP" -j ACCEPT
iptables -A OUTPUT -d "$HOST_IP" -j NFLOG --nflog-group 1 --nflog-prefix "allow-host"
iptables -A OUTPUT -d "$HOST_IP" -j ACCEPT
# Set default policies to DROP first
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP
# First allow established connections for already approved traffic
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Docker's embedded resolver forwards external names to the host's
# nameservers, and on some versions does it from inside THIS namespace. Those
# queries then traverse the chain below and match nothing - the host's
# resolver is neither in an ipset nor the bridge gateway - so they hit the
# catch-all REJECT and every lookup SERVFAILs. Only the first hop stays on
# loopback. Two hosts with byte-identical /etc/resolv.conf behaved
# differently, so this is version-dependent and cannot be assumed either way.
#
# --uid-owner 0 keeps this from being the hole the DNS allowlist closes: the
# forwarding socket is dockerd's (root), the session is `node` with no sudo,
# so it still cannot address a resolver directly. This newly permits DNS only
# from processes already root in a NET_ADMIN container, which could flush the
# ruleset anyway.
#
# Guarded, not asserted: xt_owner is a module, and a kernel without it should
# leave a container that says why (the DNS self-check below) rather than one
# that aborts here.
if iptables -A OUTPUT -p udp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null; then
iptables -A OUTPUT -p tcp --dport 53 -m owner --uid-owner 0 -j ACCEPT 2>/dev/null \
|| echo "WARNING: could not add the TCP/53 rule for root-owned sockets - large answers may fail" >&2
else
echo "WARNING: the iptables owner match is unavailable, so Docker's embedded" >&2
echo "resolver cannot forward out of this namespace. If DNS works anyway this" >&2
echo "host forwards elsewhere and nothing is wrong; the self-check below says which." >&2
fi
# Then allow only specific outbound traffic to allowed domains. This isn't
# port-restricted, so it also covers git-over-ssh (port 22) to the
# configured forge's hosts, which are already in the ipset above - no
# separate "TCP/22 to anywhere" rule needed (that would allow SSH, or any
# raw TCP posing as it, to any host on the internet).
#
# Each terminal ACCEPT/REJECT decision below is also tagged with NFLOG so
# ulogd2 can capture every new outbound connection attempt and its verdict
# (log-shipper.sh ships these to Loki alongside dns-proxy.sh's query log).
# Only new connections reach these rules at all - the ESTABLISHED,RELATED
# rule above already short-circuits the rest of a connection's packets, so
# this logs once per attempt, not once per packet.
iptables -A OUTPUT -m set --match-set allowed-domains dst -j NFLOG --nflog-group 1 --nflog-prefix "allow"
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
# GitHub gets port 443 always - read access (clone/fetch/gh over HTTPS).
# Port 22 (needed for SSH push) stays closed unless the caller explicitly
# opted in via ALLOW_GITHUB_PUSH=1 (see bin/dejima and
# git-push-guard.sh, which independently also has to allow the host before a
# push actually gets through) - default is push there stays physically
# unreachable, not just discouraged.
iptables -A OUTPUT -p tcp --dport 443 -m set --match-set github-domains dst -j NFLOG --nflog-group 1 --nflog-prefix "allow-gh"
iptables -A OUTPUT -p tcp --dport 443 -m set --match-set github-domains dst -j ACCEPT
if [ "${ALLOW_GITHUB_PUSH:-0}" = "1" ]; then
echo "ALLOW_GITHUB_PUSH=1: opening port 22 to GitHub for SSH push"
iptables -A OUTPUT -p tcp --dport 22 -m set --match-set github-domains dst -j NFLOG --nflog-group 1 --nflog-prefix "allow-gh-push"
iptables -A OUTPUT -p tcp --dport 22 -m set --match-set github-domains dst -j ACCEPT
fi
# Explicitly REJECT all other outbound traffic for immediate feedback
iptables -A OUTPUT -j NFLOG --nflog-group 2 --nflog-prefix "reject"
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
echo "Firewall rules applied. Self-checking..."
# DNS before either connectivity check, because both of those resolve a name
# before they connect and neither can tell you that resolution is what failed.
# The negative control is the trap: with DNS dead it fails for the wrong
# reason and prints "example.com is unreachable, as it should be", so a
# container with no working DNS at all reports a passing check and then dies
# on the positive control naming a host rather than the cause. Reading that
# output backwards took an afternoon; it should take one line.
if ! dns_probe=$(dig +short +time=3 +tries=2 A api.github.com 2>&1) || [ -z "$dns_probe" ]; then
echo "ERROR: self-check failed - DNS does not resolve inside the sandbox." >&2
echo >&2
echo "The path is: this container -> dnsmasq on 127.0.0.1 (dns-proxy.sh) ->" >&2
echo "Docker's embedded resolver on 127.0.0.11 -> the host's nameservers." >&2
echo "Only the first two hops are on loopback. If Docker forwards the last" >&2
echo "one from inside this namespace, it traverses the OUTPUT chain, matches" >&2
echo "nothing, and is rejected - which reads as SERVFAIL, not as a firewall." >&2
echo >&2
echo " dig said: ${dns_probe:-(empty answer)}" >&2
echo " packets taken by the catch-all OUTPUT reject so far:" >&2
iptables -L OUTPUT -vn 2>/dev/null \
| awk '/icmp-admin-prohibited/ {print " " $1 " packets, " $2 " bytes"}' >&2
echo " dnsmasq's last words:" >&2
tail -5 /var/log/dnsmasq.log 2>/dev/null | sed 's/^/ /' >&2
exit 1
fi
echo "self-check ok - DNS resolves an allowlisted name"
# --max-time as well as --connect-timeout on both self-checks: a connect that
# succeeds and then stalls is not covered by --connect-timeout, and while these
# two sit inside `if` (so set -e cannot take the script down the way it could
# above), a stalled transfer here still hangs the container's startup with no
# deadline of its own.
if curl --connect-timeout 5 --max-time 15 https://example.com >/dev/null 2>&1; then
echo "ERROR: self-check failed - a non-allowlisted host (example.com) was reachable"
exit 1
else
echo "self-check ok - example.com is unreachable, as it should be"
fi
# Confirm the allowlist itself actually works, not just that the default-deny does.
#
# Retried, unlike the negative control above, because the two ask opposite
# questions. This one asserts something CAN be reached, and a transient DNS
# answer or a CDN edge rotating mid-boot answers it wrongly in the direction
# that kills the container - which is how it presented, one container failing
# here while a dozen others in the same CI run came up fine. The example.com
# check must NOT be retried: there a single success is the real answer, and
# retrying would only be asking until it says what we want.
github_reachable=0
for attempt in 1 2 3; do
if curl --connect-timeout 5 --max-time 15 https://api.github.com/zen >/dev/null 2>&1; then
github_reachable=1
break
fi
echo " attempt $attempt/3: api.github.com not reachable yet" >&2
# `if`, not `[ ... ] && sleep` - see fetch_github_ranges above for what
# that costs on the last attempt under set -e.
if [ "$attempt" -lt 3 ]; then
sleep $((attempt * 2))
fi
done
if [ "$github_reachable" -ne 1 ]; then
echo "ERROR: self-check failed - api.github.com should be reachable but wasn't"
exit 1
else
echo "self-check ok - api.github.com is reachable, as expected"
fi
# Verify GitHub SSH (port 22) matches whichever mode was requested - this is
# what makes GitHub actually read-only by default (or actually push-capable
# when ALLOW_GITHUB_PUSH=1), not just discouraged either way. A mismatch here
# means the firewall rules above and this check drifted apart - fail loudly
# rather than silently running in the wrong mode.
if [ "${ALLOW_GITHUB_PUSH:-0}" = "1" ]; then
if timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22" 2>/dev/null; then
echo "self-check ok - github.com:22 is reachable, as expected with ALLOW_GITHUB_PUSH=1"
else
echo "ERROR: self-check failed - github.com:22 should be reachable under ALLOW_GITHUB_PUSH=1 but wasn't"
exit 1
fi
else
if timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22" 2>/dev/null; then
echo "ERROR: self-check failed - github.com:22 was reachable without ALLOW_GITHUB_PUSH=1"
exit 1
else
echo "self-check ok - github.com:22 is unreachable, as it should be"
fi
fi
+14 -10
View File
@@ -1,10 +1,14 @@
#!/bin/bash
# Tails dns-proxy.sh's dnsmasq query log and ulogd2's NFLOG-derived JSON
# output, shipping them to Loki as raw log streams. Lets DNS lookups and
# firewall allow/reject decisions from inside the sandbox show up alongside
# the rest of the Grafana/Loki setup, labeled by job so they're easy to
# filter. No-op if LOKI_URL isn't set - logging is a bonus on top of the
# firewall/DNS-proxy enforcement, not required for either to work.
# Tails the two records egress-policy produces - one JSON object per name
# lookup, one per connection attempt - and ships them to Loki as raw log
# streams. Lets lookups and allow/reject decisions from inside the sandbox show
# up alongside the rest of the Grafana/Loki setup, labeled by job so they're
# easy to filter. No-op if LOKI_URL isn't set - logging is a bonus on top of
# the enforcement, not required for it to work.
#
# Both files are already one JSON object per line, so nothing here parses them.
# A refused lookup is the record that matters most and never becomes a
# connection, which is why there are two streams rather than one.
#
# Batches rather than pushing one line at a time: a burst of activity (e.g.
# npm install resolving/connecting to dozens of hosts in quick succession)
@@ -74,8 +78,8 @@ ship_stream() {
done
}
# Only the logs that exist in this container: dnsmasq's queries and ulogd2's
# output. Session transcripts are shipped host-side, by
# Only the logs that exist in this container: the name lookups and the
# connection attempts. Session transcripts are shipped host-side, by
# bin/dejima-conversation-ship, because an oversight log of a session must
# not run in a process that session can reach.
@@ -83,8 +87,8 @@ if [ "${LOG_SHIPPER_LIB_ONLY:-0}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi
ship_stream "dejima-dns" /var/log/dnsmasq.log &
ship_stream "dejima-net" /var/log/ulogd.json &
ship_stream "dejima-dns" /var/log/egress-policy/lookups.ndjson &
ship_stream "dejima-net" /var/log/egress-policy/connections.ndjson &
wait
+127
View File
@@ -0,0 +1,127 @@
# Interactive shell configuration for the sandbox user.
#
# Loaded from /etc/bash.bashrc, so it applies to every interactive bash, and
# again from the sandbox user's ~/.bashrc, so it wins over the Debian skeleton
# rc file that would otherwise reset PS1 and the history limits after us.
# Sourcing it twice is harmless: everything below is an assignment or an
# idempotent binding.
#
# Not a shell framework fetched at build time. This image is a security
# boundary, and a prompt is not worth an unpinned third party in the build - so
# what a framework would have configured is written out here instead, where it
# can be read.
case $- in *i*) ;; *) return 0 ;; esac
# The sandbox user's shell only, even though /etc/bash.bashrc sources this for
# every interactive bash in the container - root's included, which is what an
# operator attaching to debug a session gets.
#
# Root must not take its history file from the volume below. That directory
# belongs to the sandbox user, and `history -a` follows a symlink, so a session
# that replaces .bash_history with one pointed elsewhere gets whatever root
# types appended to a file of its choosing, as uid 0. In the other direction it
# reads back anything the operator typed, including a command carrying a token.
#
# Bailing out of the whole file rather than guarding the HISTFILE assignment:
# nothing here is written for root, and a guard on one setting would have to be
# remembered by whoever adds the next one.
if [ "$(id -u)" -eq 0 ]; then
return 0
fi
# Debian's /etc/profile rewrites PATH from scratch for login shells: it drops
# the global Node tool directory the image put in the environment, and it leaves
# out the sbin directories, where the network tools a session is expected to use
# (ip, nft, dnsmasq) all live. Put them back, without duplicating an
# entry when this file is sourced twice.
# Appended, for the reason the Dockerfile gives: the harness wrapper in
# /usr/local/bin has to keep shadowing the npm-installed binary it wraps.
case ":$PATH:" in
*":/opt/node-tools/bin:"*) ;;
*) PATH="$PATH:/opt/node-tools/bin" ;;
esac
for __dir in /usr/local/sbin /usr/sbin /sbin; do
case ":$PATH:" in
*":$__dir:"*) ;;
*) PATH="$PATH:$__dir" ;;
esac
done
unset __dir
# History on the volume docker-compose.yml mounts, so tomorrow's session sees
# what was typed today. If the mount is missing or not writable by this user,
# fall back to the home directory rather than making every prompt fail to save.
if [ -w /var/opt/shell-history ]; then
HISTFILE=/var/opt/shell-history/.bash_history
else
HISTFILE="$HOME/.bash_history"
fi
HISTSIZE=50000
HISTFILESIZE=500000
HISTCONTROL=ignoreboth
HISTTIMEFORMAT='%F %T '
shopt -s histappend cmdhist checkwinsize
# Append each command to $HISTFILE as it is entered. Without this, bash holds
# the whole session in memory and writes on a clean exit only - and a sandbox
# session is killed far more often than it exits.
PROMPT_COMMAND='history -a'
if ! declare -F _completion_loader >/dev/null 2>&1; then
[ -r /usr/share/bash-completion/bash_completion ] && . /usr/share/bash-completion/bash_completion
fi
# Git state in the prompt comes from git-sh-prompt, which ships inside the git
# package itself, so it is present whenever git is.
if ! declare -F __git_ps1 >/dev/null 2>&1 && [ -r /usr/lib/git-core/git-sh-prompt ]; then
. /usr/lib/git-core/git-sh-prompt
fi
GIT_PS1_SHOWDIRTYSTATE=1
GIT_PS1_SHOWSTASHSTATE=1
GIT_PS1_SHOWUNTRACKEDFILES=1
GIT_PS1_SHOWUPSTREAM=auto
if declare -F __git_ps1 >/dev/null 2>&1; then
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0;33m\]$(__git_ps1 " (%s)")\[\e[0m\]\$ '
else
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]\$ '
fi
# Fuzzy search over history and over paths, written here rather than sourced
# from whatever path the fzf package happens to keep its example bindings in -
# that path has moved between releases, and a key binding that silently stops
# existing after a distribution upgrade is worse than twenty lines of rc file.
if command -v fzf >/dev/null 2>&1; then
export FZF_DEFAULT_OPTS='--height=40% --layout=reverse --border --info=inline'
# Ctrl-R: search the history, most recent first, duplicates dropped. The sed
# removes the entry number and the HISTTIMEFORMAT stamp that the history
# builtin prints ahead of each command.
__fzf_history_widget() {
local picked
picked=$(history |
sed -E 's/^ *[0-9]+ +([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} )?//' |
tac |
awk '!seen[$0]++' |
fzf --prompt='history> ' --query="$READLINE_LINE") || return 0
READLINE_LINE=$picked
READLINE_POINT=${#READLINE_LINE}
}
# Ctrl-T: insert one or more paths from below the current directory at the
# cursor. fd rather than find, so a checkout's ignored build output does not
# bury the file being looked for.
__fzf_path_widget() {
local picked
picked=$(fd --hidden --exclude .git --strip-cwd-prefix 2>/dev/null |
fzf --prompt='path> ' --multi |
tr '\n' ' ') || return 0
picked=${picked% }
[ -n "$picked" ] || return 0
READLINE_LINE=${READLINE_LINE:0:READLINE_POINT}$picked${READLINE_LINE:READLINE_POINT}
READLINE_POINT=$((READLINE_POINT + ${#picked}))
}
bind -x '"\C-r": __fzf_history_widget'
bind -x '"\C-t": __fzf_path_widget'
fi
+60
View File
@@ -0,0 +1,60 @@
#!/bin/sh
# Remove every setuid bit, setgid bit and file capability in the image, then
# prove none is left.
#
# The session user has no sudo and no capabilities, and entrypoint.sh drops to
# it 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, so the image must contain neither - and no_new_privs, which the
# container also sets, is a second lock on the same door rather than a reason to
# skip this one.
#
# Nothing here needs its bits. su, mount, passwd, chsh, ssh-keysign and
# unix_chkpwd are of no use to an unattended non-root session that authenticates
# to nothing, and each is a well-worn step in an escape.
#
# The one with a visible consequence is utempter, setgid utmp, which terminal
# multiplexers call to record a login. Its failure is not fatal to them - tmux
# carries on without the record - and the cost is that `who` and `w` inside the
# container stop listing sessions. Worth saying because that looks like a fault
# rather than a decision.
#
# Run as the LAST instruction of a complete image, after every stage that
# installs anything: the base image's own final stage, and again at the end of
# a derived per-project layer, which installs apt packages of its own after the
# base image is finished (bin/dejima-project-lib.sh). Run earlier it would only
# cover what existed at that point, and a package installed afterwards could
# reintroduce a privileged binary with nothing to notice.
#
# The verification is why this is a build step rather than a package list:
# adding a dependency that ships a setuid binary fails the build instead of
# quietly widening the image.
set -eu
# Real filesystems only. /proc, /sys and /dev are mounted during a build and
# have nothing to strip; -xdev keeps find out of them, and getcap has no
# equivalent flag, so it is pointed at the directories that can hold binaries.
roots=""
for d in /bin /boot /etc /home /lib /lib32 /lib64 /libx32 /opt /root /sbin /srv /usr /var; do
[ -d "$d" ] && roots="$roots $d"
done
# shellcheck disable=SC2086
find $roots -xdev -type f -perm /6000 -exec chmod -s {} +
# getcap prints "<path> <capabilities>"; strip the capabilities from each file
# it names. A failure here is left to fail the script: a capability that cannot
# be removed is exactly the thing this exists to catch.
# shellcheck disable=SC2086
getcap -r $roots 2>/dev/null | cut -d' ' -f1 | while read -r f; do
setcap -r "$f"
done
# shellcheck disable=SC2086
remaining="$(find $roots -xdev -type f -perm /6000; getcap -r $roots 2>/dev/null)"
if [ -n "$remaining" ]; then
printf 'privileged bits remain after the sweep:\n%s\n' "$remaining" >&2
exit 1
fi
printf 'no setuid, setgid or file-capability binaries remain\n'
-31
View File
@@ -1,31 +0,0 @@
# Consumes the NFLOG-tagged packets from init-firewall.sh's OUTPUT rules
# (group 1: allowed connections, group 2: rejected ones) and writes each as
# a JSON line that log-shipper.sh tails and forwards to Loki. Static config
# (no per-run domain/env dependence, unlike dns-proxy.sh's generated
# dnsmasq config), so this is copied into the image as-is.
[global]
logfile="/var/log/ulogd.log"
loglevel=3
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_inppkt_NFLOG.so"
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_raw2packet_BASE.so"
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_filter_IP2STR.so"
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_output_JSON.so"
stack=log1:NFLOG,base1:BASE,ip2str1:IP2STR,json1:JSON
stack=log2:NFLOG,base1:BASE,ip2str1:IP2STR,json1:JSON
# Allowed connections (matches init-firewall.sh's --nflog-group 1 rules)
[log1]
group=1
numeric_label=1
# Rejected connections (matches init-firewall.sh's --nflog-group 2 rule)
[log2]
group=2
numeric_label=0
[json1]
sync=1
file="/var/log/ulogd.json"
boolean_label=1
+5 -3
View File
@@ -365,9 +365,11 @@ concurrent jobs share a broadcast domain and can reach each other's service
containers. Check that before raising it.
The DNS and firewall shape is the same one dejima uses inside its own
container (`docker/dns-proxy.sh` and `docker/init-firewall.sh`), applied one
layer out: allowlist the *names* and let the answers populate the set of
allowed *addresses*.
container (`docker/egress-policy`), applied one layer out: allowlist the
*names* and let the answers populate the set of allowed *addresses*. The
toolchain differs - the hardener is written against `iptables` and `ipset`,
dejima's own policy against `nftables` - but the arrangement is the same, and
so is what it is worth.
`dnsmasq` binds the bridge gateway. With `no-resolv` and one `server=` line
per allowed name, every other name gets `REFUSED`, not resolved-then-blocked,
+38 -9
View File
@@ -70,6 +70,7 @@ which `[forge.<name>]`.
| `command` | *(the selected harness's own)* | container's main process (e.g. `nvim`), also settable per launch with `--cmd`. Overriding it changes what dejima launches, not which harness is wrapped |
| `tools` | unset | optional tools to put in the image, colon-separated; also `.dejima/tools` in a project (approved first) and `--include` per launch. None by default. See [docs/tools.md](tools.md) |
| `firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
| `internal_domains` | unset | approved domains that really do resolve inside your own network, colon-separated. The policy refuses an answer for a permitted name that points at a private address, because an answer decides what the allowlist contains and comes from outside; this exempts the ones you know are internal. It exempts only - a name here is not reachable unless it is also approved, and naming one that is not stops the launch saying so. The configured forge, the Loki host and a self-served backend's `base_url` host are permitted and exempt already, so this is for destinations dejima does not configure itself. `dejima-allow` reads this same key when adding a domain to a running session |
| `share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir. One directory per project underneath it, and only that project's is mounted at `~/shared`. `dejima-share` is the host side of it |
| `known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
| `update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord. The answer is cached in `~/.local/share/dejima/update-check`, which is safe to delete |
@@ -163,7 +164,8 @@ you selected, the second is push access when GitHub *is* your forge.
| Key | Default | Purpose |
| --- | --- | --- |
| `readonly` | unset | extra host paths (colon-separated) a personal config needs, mounted read-only at their own path |
| `read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
| `read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session and be shared by every project; refused if they overlap anything dejima needs out of a session's reach |
| `read_write_per_project` | unset | the same, mounted at the path named but backed by `<path>/projects/<slug>` - one directory per project rather than one for the host. For anything a session writes to be read later as background, where another project reading it would be a channel rather than a convenience |
| `nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
### `[audit]` - the exit-time audit
@@ -842,15 +844,41 @@ session is meant to write to and whose contents should outlive the container:
```
[mounts]
read_write = ~/specs
read_write = ~/.cache/shared-toolchain
```
Both lists mount at the host path they name, so a convention that says
`[mounts] read_write_per_project` mounts at the same path, but backs it with
one directory per project instead of the whole thing:
```
[mounts]
read_write_per_project = ~/specs
```
The container sees `~/specs` either way. Behind it, `read_write` puts the
directory itself - every session's contents, including other projects' - and
`read_write_per_project` puts `~/specs/projects/<slug>`, keyed the same way
`[core] share_dir` and the audit store are.
**Which to reach for is decided by one question: should another project's
session read what this one leaves there?** For a build cache or a directory of
scripts, yes, and `read_write` is right. For design docs, scratch notes,
anything an agent writes to be read later as background - no, and that is what
`read_write_per_project` is for. A spec is written to be read as context, so a
shared one is a channel from any project into every other.
All three lists mount at the host path they name, so a convention that says
"specs live in `~/specs`" keeps working inside the container without being
rewritten. That is the difference from `[core] share_dir`, which is
mounted at `~/shared` regardless of where it lives on the host because its job
is to be a well-known handoff point.
dejima creates the per-project directory; it never creates the one you named,
so a path that is not there is refused as the typo it is. Naming a
`read_write_per_project` root's `projects` directory in either of the other
two lists is refused as well - handing that over is handing over every
project's.
An entry is **refused, not warned about**, if it overlaps anything dejima
relies on being out of a session's reach: the approval store, either audit
store, the epitaph store, every project's share, `~/.local/bin`, dejima's own
@@ -958,13 +986,14 @@ Treat it as a handoff point, not as general-purpose storage. If your own
project instructions tell agents to write somewhere at a fixed host path -
design docs, scratch notes, anything meant to outlive the session but stay out
of the project checkout - mount that directory instead, with
`[mounts] read_write`. It arrives at its own absolute path, so a convention
that says "put specs in `~/specs`" keeps working inside the container without
being rewritten to point at `~/shared`.
`[mounts] read_write_per_project`. It arrives at its own absolute path, so a
convention that says "put specs in `~/specs`" keeps working inside the
container without being rewritten to point at `~/shared` - and, like `~/shared`
itself, what a session finds there is this project's and not every project's.
`~/shared`, the project mount, and anything in `[mounts] read_write` are the
only writable host paths in the container. Everything else a session writes
goes away with it.
`~/shared`, the project mount, and anything in `[mounts] read_write` or
`[mounts] read_write_per_project` are the only writable host paths in the
container. Everything else a session writes goes away with it.
**Clear it out periodically.** The per-project split bounds which sessions can
read what you left, and bounds nothing about how long. A project's share
+21 -18
View File
@@ -12,13 +12,13 @@ run them.
| --- | --- |
| `docker/Dockerfile` | Base image: dev tools, nvim, Playwright, the sandbox user. Carries **no harness** - the selected one installs into a layer above it, from its manifest's `install` |
| `docker/install-forge-bins.sh` | Installs each forge's CLIs into the image from its manifest's `install` list, and fails the build on a file that is shipped but not declared, or declared but not shipped |
| `docker/fixed-domains.sh` | Shared domain list sourced by both `init-firewall.sh` and `dns-proxy.sh` |
| `docker/dns-proxy.sh` | Starts the DNS-allowlisting `dnsmasq`, repoints `resolv.conf` at it |
| `docker/init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at filter-container start |
| `docker/firewall-up.sh` | Brings a network namespace's egress control up: the DNS proxy, the packet filter, and the two log producers. What a namespace needs, rather than what a container does |
| `docker/fixed-domains.sh` | Composes the permitted set by class, shared by `firewall-up.sh` in the container and `dejima-allow` on the host so the two cannot disagree about which class a name belongs to |
| `docker/egress-policy` | The egress policy itself: packet filter, restricted resolver, connection log, and the self-checks that must pass before a session starts. Also `permit`, which adds a destination to a running session |
| `docker/dns-lookup-log` | Turns the resolver's query log into one JSON record per name lookup, with its outcome |
| `docker/cidr-collapse` | Collapses a CIDR list into the smallest equivalent one, validating every entry |
| `docker/firewall-up.sh` | Turns a launch's configuration into the permitted set `egress-policy` is given, then starts it. What a namespace needs, rather than what a container does |
| `docker/filter-entrypoint.sh` | PID 1 of the filter container: runs `firewall-up.sh`, signals readiness, then holds the namespace open for the sandbox to join |
| `docker/caps-lib.sh` | `drop_caps` and the `CAP_SETPCAP` precondition, sourced by both entrypoints so the two cannot drift |
| `docker/ulogd.conf` | `ulogd2` config: turns `init-firewall.sh`'s NFLOG-tagged packets into JSON |
| `docker/log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to the configured Loki endpoint (`[logging] loki_url`). Container-side, because those files only exist there |
| `docker/entrypoint.sh` | PID 1 of the session container: runs as root to prepare the session's own state, points `resolv.conf` at the namespace's resolver, then execs the session's command as the sandbox user with an empty capability bounding set. The egress control is the filter container's to bring up, not this one's |
| `docker/harness-wrapper.sh` | Shadows the selected harness's command on `PATH` to add its no-confirm flags and dejima's settings layer. Named by no harness; `entrypoint.sh` links it into place |
@@ -79,18 +79,21 @@ the security invariants the README states, not a substitute for reading it,
but a way to actually catch a future change silently breaking one of them
instead of finding out the hard way.
`static.bats` greps the repo's own files - no sudo in the `Dockerfile`, every
`ipset add` is duplicate-safe, no `docker.sock`/host-credential mounts, the
`~/.claude` mount inventory is exactly what it should be, `dejima-allow`
rejects a malicious domain argument. `runtime.bats` builds the image, boots
a real container running the actual `entrypoint.sh` (a `SANDBOX_TEST_MODE`
env var makes it stop just short of execing the session's command, so the suite can attach
to a fully-initialized container instead of reimplementing its own copy of
the setup sequence), and asserts against it: IPv6 blocked, DNS/IP allowlist
enforcement in both directions, GitHub read-only, no working `sudo`/
`iptables`/`init-firewall.sh` as `node`, capabilities actually stripped
(not just absent from a `docker exec` that doesn't have them to begin
with), `ulogd2` unprivileged, and the dynamic-ipset CDN-rotation fix.
`static.bats` greps the repo's own files - no sudo in the `Dockerfile`, no
`docker.sock` or host-credential mounts, the `~/.claude` mount inventory is
exactly what it should be, `dejima-allow` rejects a malicious domain argument.
`runtime.bats` builds the image, boots a real container running the actual
`entrypoint.sh` (a `SANDBOX_TEST_MODE` env var makes it stop just short of
execing the session's command, so the suite can attach to a fully-initialized
container instead of reimplementing its own copy of the setup sequence), and
asserts against it: IPv6 refused outright with no permit path in the chain and
no AAAA from the resolver, name and address enforcement in both directions,
inbound and
forwarded traffic dropped, GitHub reachable on 443 and not on 22, no working
`sudo` and no way to change the packet filter as `node`, capabilities actually
stripped (not just absent from a `docker exec` that doesn't have them to begin
with), both log producers unprivileged, and the address-set CDN-rotation fix.
`project.bats`, `remote.bats` and `audit.bats` need no Docker either.
`project.bats` covers the
@@ -125,7 +128,7 @@ what stops the first from passing in an environment where the container
reaches nothing at all. That pair is the ttyd front door's whole containment
argument, so run it before trusting a front door you just set up.
Every mutation in `runtime.bats` (firewall rules, `ipset` entries) happens
Every mutation in `runtime.bats` (firewall rules, address-set entries) happens
inside the test container's own network namespace, same as any other
container - none of it touches the host. Container/network names are
derived from bats' own per-run temp directory, not anything guessable or
+1 -1
View File
@@ -13,7 +13,7 @@
#
# So two things carry this feature's entire security, and both live here:
#
# 1. The listen address. docker/init-firewall.sh:187 accepts container
# 1. The listen address. docker/egress-policy permits container
# OUTPUT to the default-route address - the Docker bridge gateway, which
# is the host. A vhost bound to every interface, or to that gateway
# address, is reachable FROM a sandbox, which would let a compromised
+3 -3
View File
@@ -138,7 +138,7 @@ operator can set, resolved the same way as any other config key
| `host_keys` | `?list:identifier` | (empty) | Config keys (from `keys`) that hold a literal hostname - unioned by `forge_hosts` into the firewall allowlist and the push guard's allowed hosts. |
| `url_keys` | `?list:identifier` | (empty) | Config keys that hold a URL - `forge_hosts` strips scheme and path and unions the bare host in the same way as `host_keys`. |
| `fixed_hosts` | `?list:hostname` | (empty) | Hostnames this forge always needs reachable, independent of any config value (`github` uses this - `github.com`, `ssh.github.com`, `api.github.com` are the same three hosts for every operator, so there's nothing to configure and nothing to get wrong). |
| `hosts_allowlisted_by_core` | `bool` | `off` | `on` means this forge's hosts are ones core already allowlists in its own right - GitHub's published ranges, which every session can read from over 443 whatever forge is selected. Core then keeps them out of `DEJIMA_FORGE_HOSTS` (the extra-domains path, which resolves a name into the port-unrestricted `allowed-domains` ipset and exempts it from DNS-rebind protection - right for an internal self-hosted host, a silent widening for a public one) and, when the workflow pushes, opens core's own GitHub push grant to them instead: port 22 to those same ranges, logged and verified by the firewall's startup self-check. They still reach `DEJIMA_PUSH_HOSTS`, so `git-push-guard.sh` permits the push. Name-free in core, but GitHub-specific in effect: this key means "core already allowlists these hosts in its own right", which today is only true of GitHub's ranges, so a future forge whose hosts are not among those must leave it `off` - setting it `on` for anything else grants no firewall allowance at all plus an irrelevant GitHub push grant. |
| `hosts_allowlisted_by_core` | `bool` | `off` | `on` means this forge's hosts are ones core already allowlists in its own right - GitHub's published ranges, which every session can read from over 443 whatever forge is selected. Core then keeps them out of `DEJIMA_FORGE_HOSTS` (the open-class path, which permits a name on every port and exempts it from the check that refuses an answer pointing inside a private network - right for an internal self-hosted host, a silent widening for a public one) and, when the workflow pushes, opens core's own GitHub push grant to them instead: port 22 to those same ranges, logged and verified by the policy's startup self-check. They still reach `DEJIMA_PUSH_HOSTS`, so `git-push-guard.sh` permits the push. Name-free in core, but GitHub-specific in effect: this key means "core already allowlists these hosts in its own right", which today is only true of GitHub's ranges, so a future forge whose hosts are not among those must leave it `off` - setting it `on` for anything else grants no firewall allowance at all plus an irrelevant GitHub push grant. |
| `install` | `?list:installspec` | (empty) | The CLIs this forge puts on PATH in the image - the only manifest key naming something that actually gets copied and executed. Either a path under the forge's own directory, or `system:<name>` for a command the image already provides from a package. The list is the gate, not a description of one: `docker/install-forge-bins.sh` installs exactly what is declared, and fails the build when a declared file is missing, when a declared `system:` command is not in the image, and when a forge ships a file under its `bin/` that it does not declare. Every command a forge names anywhere - `pr_command`, `issue_command`, `merge_block`, `disclose` - has to appear here, because a CLI that is not behind the shadow is one the guards cannot reach: the entrypoint refuses to start rather than leave a block or a disclosure looking enforced. |
| `env_export` | `?list:export` | (empty) | `NAME=key` (the config key's resolved value) or `NAME=@key` (the *contents* of the file the config key names) pairs - the environment variables `bin/dejima` passes into the container for this forge. The `@` form is how a token reaches the container without its path being mistaken for the secret. |
@@ -231,7 +231,7 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
| `image_env` | `?list:envpair` | (empty) | `NAME=VALUE` pairs set in this harness's image layer, for anything it wants in the environment of every session of its own. Deliberately narrow - no whitespace, nothing shell-active - because each becomes an `ENV` line in a generated Dockerfile. |
| `command` | `identifier` | `claude` | The container's main process for this harness, and the name the wrapper shadows on `PATH`. `[core] command` defaults to it. |
| `command_path` | `?path` | (empty) | Where the real binary lives, behind the shadow. |
| `api_domains` | `?list:hostname` | (empty) | Hostnames this harness's own machinery must reach - its update host, its telemetry endpoint. They join the *fixed* allowlist rather than the extra one, which `dns-proxy.sh` exempts from stop-dns-rebind and `init-firewall.sh` treats as non-fatal when it fails to resolve. |
| `api_domains` | `?list:hostname` | (empty) | Hostnames this harness's own machinery must reach - its update host, its telemetry endpoint. They join the built-in part of the permitted set rather than the operator's, and are not exempted from the check that refuses an answer pointing inside a private network - a harness's endpoints are public names, and exempting a public name is what turns one influenced answer into egress to an internal host. |
| `api_styles` | `?list:identifier` | (empty) | Which backend `api_style`s this harness has a client for. `claude-code` speaks `anthropic`; `cline` speaks both, which is what makes it the backend-agnostic one. Empty means unchecked. |
| `guards` | `?list:identifier` | (empty) | Which of dejima's guard rails this harness can host: `bash-deny`, `push-guard`, `merge-guard`, `notify`. A name outside that set is refused rather than treated as an unknown guard, since a typo would otherwise turn a guard off silently. The ones a harness does not declare are named at launch - see "What a harness must be able to do" in [development.md](development.md). |
| `briefing_args` | `?args` | (empty) | The flag that carries the briefing to a harness taking it on the command line - for Claude Code, `--append-system-prompt`. `dejima-harness-wrapper` reads the rendered briefing and passes it as a single value, so every invocation carries it and not only the entrypoint's. Set, the session-start hook stops carrying the briefing and reduces to the session id, which is the one thing that does not exist until the harness starts. |
@@ -247,7 +247,7 @@ Worked example, `harnesses/claude-code/harness.conf`:
```
command = claude
command_path = /usr/local/share/npm-global/bin/claude
command_path = /opt/node-tools/bin/claude
guards = bash-deny push-guard merge-guard notify
no_confirm_args = --dangerously-skip-permissions
settings_args = --settings /etc/dejima-settings.json
+1 -1
View File
@@ -52,7 +52,7 @@ running.
## The bind address is the one thing to get right
`docker/init-firewall.sh` deliberately ACCEPTs OUTPUT to the session's
`docker/egress-policy` deliberately permits traffic to the session's
default-route address - the Docker bridge gateway, which is your host. That is
the one host address a sandbox can reach at all. Combine it with the fact above
(a tmux client is a host shell) and the consequence is direct: **a front door
+9 -9
View File
@@ -12,7 +12,7 @@ August, turned up two things worth having found sooner than that:
a `.google.com` suffix while the resolver read `attacker-host`. dejima's
matcher refused that shape and every neighbouring one, for structural reasons
now recorded beside it - it matches whole domains rather than suffixes, and
the decision and the dial are bound by the ipset being filled from the
the decision and the dial are bound by the address set being filled from the
answer the resolver returned. One laxity turned up and was fixed.
- **The closest comparable project ships the opposite default.** Trail of Bits'
Claude Code devcontainer lists network under "not sandboxed" and has full
@@ -156,11 +156,11 @@ both distinctions vanish within a week of writing:
A bypass from the literature is a hypothesis about *their* implementation until
it is run against this one. dejima differs in ways that plausibly change the
answer: enforcement is `iptables` and a DNS proxy rather than an in-process
proxy, the matcher is an anchored shell regex rather than a string-suffix test,
the allowlist matches whole domains rather than suffixes, and the ipset is
populated from the answer the resolver returned rather than from a second parse
of a string.
answer: enforcement is a packet filter and a restricting resolver rather than an
in-process proxy, the matcher is an anchored shell regex rather than a
string-suffix test, the allowlist matches whole domains rather than suffixes,
and the address sets are populated from the answer the resolver returned rather
than from a second parse of a string.
Each entry states a prediction that can fail.
@@ -170,8 +170,8 @@ Each entry states a prediction that can fail.
has since been fixed.*
- **A published SOCKS-style parser differential, run end to end** rather than
against the validator alone. Prediction: no path, because nothing in dejima
speaks SOCKS and the decision and the dial are bound by the ipset. Not yet
speaks SOCKS and the decision and the dial are bound by the address set. Not yet
run.
- **Redirect chains to a host outside the allowlist.** Prediction: fail at the
ipset, because dejima does not mediate HTTP and the connection is judged by
address. Not yet run.
packet filter, because dejima does not mediate HTTP and the connection is
judged by address. Not yet run.
+146 -66
View File
@@ -271,7 +271,7 @@ In that configuration the provider directories are in the exit-time audit's
TCB list, alongside `bin/`, `hooks/`, `docker/`, `test/` and `tools/`. They
hold no code and nothing executes them, but what they hold decides what the
next launch permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`,
which `entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so
which `firewall-up.sh` joins into the permitted set the egress policy is given, so
a line added to a manifest widens the egress boundary on the next run.
`tools/*/tool.conf` is the same class, and is read by the host rather than the
container: it names a tarball URL, the sha256 checked against it and the
@@ -1022,9 +1022,8 @@ reasoning below is what both are built on.
the packet filter, and the sandbox, which joins that namespace with
`network_mode: container:` rather than getting one of its own. Capabilities
are per process rather than per namespace, so the sandbox cannot alter the
filter bounding it even at uid 0 - measured, not reasoned: `iptables` and
`ipset` both return `Operation not permitted` to a joiner that lacks
`NET_ADMIN`.
filter bounding it even at uid 0 - measured, not reasoned: `nft` returns
`Operation not permitted` to a joiner that lacks `NET_ADMIN`.
Two things follow. The namespace has its rules before anything is created in
it, so there is no window in which a session holds an interface and no
@@ -1049,10 +1048,13 @@ reasoning below is what both are built on.
`entrypoint.sh` runs as root, prepares the session's own state, then drops to
that user before execing the configured command (`[core] command`, or the
selected harness's own if that is unset). A compromised session can't
re-run `init-firewall.sh` with a wider allowlist to open its own
exfiltration path: that script runs in the container beside it, so there is
no copy of the allowlist within reach to re-run against. Verified: that user
gets a permission error running `iptables` directly.
re-run the egress policy with a wider allowlist to open its own
exfiltration path. The script is in reach - it is the same image, and
`/usr/local/sbin` is on that user's PATH - and reaching it is not the
question: it refuses outright as a non-root caller, and the capability it
would need to change anything belongs to the container beside this one.
Verified both ways, in `runtime.bats`: the session user cannot run it, and
root in the session container cannot either.
- **The session's capability bounding set is empty**, not merely its effective
one. The distinction is the whole of it: an empty effective set is what
changing uid gives you anyway, while the bounding set is what a process that
@@ -1084,12 +1086,20 @@ reasoning below is what both are built on.
again before execing the session, and since Linux 4.3 it grants nothing a
process does not already hold.
The filter container holds the network capabilities, and `KILL` with them:
adding a domain to a running session signals dnsmasq, dnsmasq runs as its own
user, and dnsmasq is in there. The session's list is in
The filter container holds the network capabilities. The session's list is in
`docker/docker-compose.yml` and the filter's is in `bin/dejima`, each beside
the container it describes.
`KILL` is on both lists, for unrelated reasons, and it is the one worth
reading twice. The filter container needs it because adding a domain to a
running session signals dnsmasq, which runs as its own user. The session
container needs it because `init: true` makes PID 1 an init that forwards
signals to the session - and the entrypoint changes uid in place before
exec'ing it, so that is uid 0 signalling a process whose real and saved uids
are the sandbox user's. Without the capability every forwarded signal is
`EPERM`, tini treats a failed forward as fatal, and an ordinary terminal
resize ends the session.
`no-new-privileges` means a setuid binary or a file capability cannot raise
privileges on exec, which is the shape any escalation from the session user
would have to take. The floor means the capabilities nothing uses were never
@@ -1204,9 +1214,9 @@ the window this closes.
## Network egress firewall
On start, the container runs an allowlist-only `iptables`/`ipset` firewall
(`init-firewall.sh`), matching the reference dev container. Allowed by
default:
On start, the filter container establishes an allowlist-only egress policy
(`docker/egress-policy`): one `nftables` inet table with a closed default and
everything the session may reach expressed as named sets. Allowed by default:
- DNS resolution via a local proxy (see "DNS is allowlisted too")
- the Docker host itself (not its whole subnet)
@@ -1243,18 +1253,42 @@ Everything else is rejected. Run `dejima-allow --global <domain>`
`crates.io` for a Rust project), or `dejima --no-firewall` to disable the
firewall entirely for one launch (debugging only).
IPv6 is blocked outright (`ip6tables` DROP on INPUT/OUTPUT/FORWARD, `dnsmasq`
strips AAAA answers): there's no IPv6 equivalent of the allowlist above, so
leaving it open would bypass the whole firewall on any host with IPv6
egress.
Inbound and forwarded traffic default to drop as well, in both families, with
narrow exceptions for loopback, replies to connections the container itself
started, and the container's own host. Nothing in either container is meant to
be reachable from the bridge - the resolver and the credential forwarder both
bind loopback - and a session process that binds every interface is binding one
by accident. A launch puts each pair on a network of its own, so there is
rarely anything else on that bridge to reach it, but that is a property of the
launcher; this is the filter's own answer to the same question. 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.
Where `ip6tables` cannot be used at all, the firewall does not conclude that
IPv6 is therefore absent - it asks the kernel, and treats an address on any
interface other than loopback as the family being live. It then turns IPv6
off in the container's own network namespace, and **refuses to start** if it
cannot. A tool that will not run and a family that is not there are different
conditions, and reporting the second on evidence of the first is how a
boundary comes to be described rather than held.
IPv6 is refused outright. Not filtered alongside IPv4, not allowlisted - a
sandbox has no IPv6 egress at all. An allowlist naming one address family says
nothing about the other, so every restriction above would be worth only as much
as the v6 route to the same host.
It is said twice, pointing the same way. The first rule of the egress chain
refuses the whole family, above even the established and loopback passes, since
a rule below either is one a v6 packet can get past - 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. Either alone would hold; both is the posture.
One table is what makes this cheaper to hold than it used to be, rather than a
reason to hold something weaker. The firewall this replaced 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" - so
it ran with IPv6 live and unfiltered, and said nothing. There is nothing left
to infer: the refusal loads in the same transaction as everything else, or none
of it loads. A tool that will not run and a family that is not there are
different conditions, and reporting the second on evidence of the first is how
a boundary comes to be described rather than held.
The startup self-check asserts the shape of the ruleset, not a connection that
fails: a v6 connection fails identically on a host with no v6 route, so a probe
alone would pass whether the family is refused or merely absent.
If your Gitea host only resolves via a LAN-local DNS server, make sure the
Docker host itself can resolve it: containers inherit the host's resolver.
@@ -1262,14 +1296,37 @@ Docker host itself can resolve it: containers inherit the host's resolver.
### DNS is allowlisted too
Resolution itself is restricted, not just the IP connections made
afterward: `dns-proxy.sh` runs a local `dnsmasq` with no fallback resolver,
and `/etc/resolv.conf` is repointed at it. Only names on the same allowlist
above (plus `github.com`/`githubusercontent.com`/`githubassets.com`, needed
since GitHub itself is allowlisted by IP range; see `fixed-domains.sh`) get
forwarded to Docker's real resolver; anything else gets no upstream
configured at all and is refused outright. Without this, a compromised
session could exfiltrate data by encoding it into query names to an
attacker-controlled domain, even with no direct connection ever possible.
afterward: the policy runs a local `dnsmasq` with no fallback resolver, and
`/etc/resolv.conf` in both containers is repointed at it. Only permitted names
are forwarded to Docker's real resolver; everything else gets NXDOMAIN.
Without this, a compromised session could exfiltrate data by encoding it into
query names to an attacker-controlled domain, even with no direct connection
ever possible.
Two rules make that hold rather than merely intend it. Reaching a resolver
outside the namespace is restricted by uid: the forwarder, running as an
account the image creates for that and nothing else, and root, because the
container runtime's own embedded resolver forwards onwards from inside this
namespace on some versions and every lookup fails without it. The session user
is neither - uid 1000, no sudo, no setuid binary, no capability to change uid -
so the only resolver it can address is the restricting one, which is what
closes the query-name channel. Root in the session container would be a way
around it; that is the same posture the firewall this replaced had, and
narrowing it further needs a discriminator finer than uid, which the packet
filter cannot express between two containers sharing one namespace. And an answer for
a permitted name that points at a private, loopback or link-local address is
refused, because an answer decides what the allowlist contains and an answer
comes from outside: one influenced reply for a public name would otherwise be
egress to a host on your own network. That refusal is stated twice, once in
the resolver and once independently in the packet filter, so it does not rest
on one component being configured correctly. Destinations you configured
yourself - the forge, Loki, and anything in `[core] internal_domains` - are
exempt, because you chose their addresses and there is nothing for an answer
to decide.
Every lookup is recorded, with its outcome and - for a refusal - its reason.
A refused lookup never becomes a connection, so it would otherwise leave no
trace at all, and it is the query itself that carries data out.
### Adding a domain to a running session
@@ -1282,14 +1339,21 @@ dejima-allow crates.io # this running session only
dejima-allow --container <id> crates.io # a specific session, if more than one is running
```
It first adds the domain to the running dnsmasq's allowlist (appends a
`server=` line to its servers-file and sends `SIGHUP` to reload just that
file - no restart), then resolves it through the now-unblocked container
resolver and adds the IP(s) to the running firewall's `ipset`, all via
`docker exec -u root`. It finishes by checking the domain is actually
reachable from inside that same container and prints a definitive verdict:
It runs `egress-policy permit` in the filter container via `docker exec -u
root`, which records the entry, regenerates the resolver's configuration and
restarts it (under a second, losing only its cache), then proves the change:
it resolves the name through the policy's own resolver and confirms the
address reached the set its class uses. If any of that fails it puts the
permitted set back the way it was, so there is no half-added name that
resolves and is then refused. It finishes by checking the domain is actually
reachable from inside the session container and prints a definitive verdict:
exit code 2 if it isn't.
The class comes from the same file the container composes its own lists from,
so a name under a forge suffix is added to the forge class and stays confined
to 443 - adding a domain cannot widen how a destination is reachable, only
whether.
`--global` and `--project` are the opposite of this: they record an approval
for *future* sessions and deliberately do not touch a running container, even
together - see
@@ -1313,23 +1377,33 @@ sandbox, so there is no need for it to guess one and risk "successfully"
updating someone else's session instead.
A domain allowed this way stays allowed for the rest of the session even
if its CDN later rotates edge IPs: `dns-proxy.sh` tops up the firewall's
`ipset` from every DNS answer it serves, not just the one resolved at
allow-time, since only allowlisted names get an upstream to answer from in
the first place. GitHub's own domains sit outside this - their existing
port-443-only `ipset` entries still win by dnsmasq's longest-match rule -
so that restriction is unaffected.
if its CDN later rotates edge IPs: the resolver writes every answer it serves
into the address set its entry's class names, not just the one resolved at
allow-time, since only permitted names get an upstream to answer from in the
first place. GitHub's own domains keep their port-443 restriction through it:
what confines them is which class the name is in, and `dejima-allow` reads that
from the same file the container composes its own lists from. The resolver's
longest match decides which set an answer is written into; the ruleset's order
decides which ports that set is reachable on, with the forge class's
permit-and-refuse pair ahead of the open class. Adding a domain can widen what
a session reaches, never how.
One gap remains: allowing an *internal* hostname (one that resolves to an
RFC1918 address) mid-session doesn't work. dnsmasq's `stop-dns-rebind`
rejects private answers for any domain not exempted at startup, and that
exemption list can only be extended by restarting dnsmasq. `dejima-allow`
re-asks the container's upstream resolver when a name fails to resolve, so it
names this case and the relaunch that fixes it rather than reporting the host
as unresolvable and sending you to look at a DNS server that is working.
Internal hosts named before the container starts (`[forge.gitea] ssh_host`,
`api`, `[logging] loki_url`, and any globally or per-project approved domain)
are already exempted and unaffected by this.
An *internal* hostname - one that resolves to an address inside your own
network - is the one case that needs saying twice. The policy refuses such an
answer for a permitted name, because an answer decides what the allowlist
contains and comes from outside, so one influenced reply for a public name
would be egress to a host on your network. Destinations dejima resolves itself
(`[forge.gitea] ssh_host`, `api`, `[logging] loki_url`) are exempt, because you
configured their addresses and there is nothing for an answer to decide. An
approved domain is not: `.dejima/allowed-domains` is reviewed with the project
and expected to carry public names.
So an internal host you approve also goes in `[core] internal_domains`. Both
halves read that one key: a launch exempts those entries and nothing else, and
`dejima-allow` passes the same exemption only for a name that is in it. Adding
such a name to a running session works when it is listed and is refused when it
is not, with the refusal naming the key - rather than the two commands having
different ideas and the disagreement surfacing as a failed launch later.
The session briefing tells the agent to ask for this by name when a task is
blocked on a single missing domain, rather than stopping the whole session
@@ -1424,15 +1498,21 @@ If `[logging] loki_url` is set (see Prerequisites in the README), every DNS quer
outbound connection attempt - allowed or rejected - is shipped to that Loki
endpoint. Two sources, both tailed and pushed by `log-shipper.sh`:
- `dns-proxy.sh`'s dnsmasq logs every query and whether it was forwarded
(allowed) or refused (not on the allowlist) - labeled `job=dejima-dns`.
- `init-firewall.sh` tags its terminal ACCEPT/REJECT rules with `NFLOG`
(group 1 for allowed, group 2 for rejected), which `ulogd2`
(`ulogd.conf`) turns into JSON lines with source/dest IP, port, and an
`action` field - labeled `job=dejima-net`. Only new connection
attempts are logged, not every packet: the firewall's existing
ESTABLISHED,RELATED rule short-circuits the rest of a connection before it
ever reaches the tagged rules.
- every name lookup is recorded as one JSON object, with its outcome and, for
a refusal, its reason: not permitted at all, permitted but answered with an
address inside a private network, or permitted and no such name. The second
carries a severity, because it is someone trying to steer the policy rather
than a name nobody was allowed to ask for. Labeled `job=dejima-dns`. A
refused lookup never becomes a connection, so this is the only record it
leaves - and it is the query itself that carries data out.
- the policy tags its terminal permit/refuse rules with `NFLOG`, on two groups
rather than one prefix to parse, which `ulogd2` turns into JSON lines with
source/dest IP, port, and an `action` field - labeled `job=dejima-net`. Its
configuration is generated per launch alongside the ruleset, so the group
numbers cannot drift from the rules that write to them. Only new connection
attempts are logged, not every packet: the chain's ESTABLISHED,RELATED rule
short-circuits the rest of a connection before it ever reaches the tagged
rules.
This is visibility on top of the firewall/DNS-proxy enforcement above, not a
substitute for it - nothing about logging changes what's actually allowed to
@@ -1649,9 +1729,9 @@ token there would make HTTPS push possible independent of
every change, and `runtime.bats` asserts against a real running container
rather than a mock: IPv6 blocked, a non-allowlisted domain refused and a
non-allowlisted destination unreachable, `node` unable to re-run
`init-firewall.sh`, no working `sudo`. Coverage is not completeness: the
the egress policy, no working `sudo`. Coverage is not completeness: the
reasoning here is judgement, nothing tests it, and a property nobody wrote
a test for can regress quietly. A missing `ipset` dedup flag broke firewall
a test for can regress quietly. A missing address-set dedup flag broke firewall
startup twice before anyone caught it; there is a test for that now, which
is the pattern to expect - each of these exists because something was got
wrong first.
+5 -5
View File
@@ -15,11 +15,11 @@
#
# hosts_allowlisted_by_core is what keeps those three hosts on the path core
# already has for them (the GitHub ranges, port 443, plus port 22 under the
# push grant) instead of the extra-domains path a self-hosted forge host
# takes. Without it they would land in the port-unrestricted allowed-domains
# ipset AND lose DNS-rebind protection - a silent widening of exactly the
# posture the line above describes - and the firewall's own startup
# self-check would refuse to bring the container up at all.
# push grant) instead of the open-class path a self-hosted forge host takes.
# Without it they would be permitted on every port AND exempted from the check
# that refuses an answer pointing inside a private network - a silent widening
# of exactly the posture the line above describes - and the policy's own
# startup self-check would refuse to bring the container up at all.
capabilities = pr issues
pr_command = gh pr
issue_command = gh issue
+1 -1
View File
@@ -3,7 +3,7 @@ image_dirs = .claude .claude/projects .claude/file-history
image_files = .claude/.credentials.json
image_env = CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
command = claude
command_path = /usr/local/share/npm-global/bin/claude
command_path = /opt/node-tools/bin/claude
api_domains = platform.claude.com
guards = bash-deny push-guard merge-guard notify
+1 -1
View File
@@ -24,7 +24,7 @@ image_dirs = .cline .cline/data .cline/hooks .agents .agents/skills .age
image_env = DO_NOT_TRACK=1 DISABLE_TELEMETRY=1 CLINE_NO_AUTO_UPDATE=1
command = cline
command_path = /usr/local/share/npm-global/bin/cline
command_path = /opt/node-tools/bin/cline
# Nothing is fetched at startup: the whole assessment ran against a loopback
# endpoint with the firewall up, so there is no catalogue or feature-flag host
+12 -3
View File
@@ -197,9 +197,18 @@ if [ -f "$decl_dir/allowed-domains" ]; then
# verbatim into the agent's context under a heading that reads as the
# project speaking.
[[ "$dom" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)+[a-zA-Z0-9-]{1,63}$ ]] || continue
# Every valid domain has a dot, a BRE metachar that would match any
# char - escape it so "a.b" can't false-positive against "aXb".
grep -q "^server=/${dom//./\\.}/" /etc/dnsmasq-sandbox-servers.conf 2>/dev/null && continue
# Asked of the resolver rather than read out of its configuration.
# That configuration is in the FILTER container, which owns this
# namespace's egress control and which the session cannot read - so a
# probe that grepped it found nothing here and reported every declared
# domain as missing, including ones the session could plainly reach.
#
# Resolution is the right question anyway: the resolver answers for
# permitted names and returns NXDOMAIN for everything else, so "it
# resolves" and "it is permitted" are the same set, asked from where
# the answer actually matters. Bounded, because this runs while an
# agent waits for its briefing.
[ -n "$(dig +short +time=2 +tries=1 "$dom" 2>/dev/null)" ] && continue
missing_domains_n=$((missing_domains_n + 1))
[ "$missing_domains_n" -le "$MAX_LISTED" ] && missing_domains="$missing_domains $dom"
done < <(read_declared_capped "$decl_dir/allowed-domains")
+1 -1
View File
@@ -7,7 +7,7 @@
# not a hard security boundary - a sandbox session has full shell access
# and could in principle edit .git/config or re-export these variables to
# route around it; that is true today and is not a regression, and the
# firewall (see init-firewall.sh) is the actual boundary. No-op outside the
# firewall (see docker/egress-policy) is the actual boundary. No-op outside the
# sandbox (DEJIMA unset), since this restriction doesn't apply to normal
# host usage.
#
+3 -4
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env bash
# SessionStart hook: when running inside the dejima Docker container
# (see https://github.com/anthropics/claude-code/tree/main/.devcontainer for
# the reference this is based on, and this repo's own README for the actual
# setup), hand Claude Code the session briefing.
# SessionStart hook: when running inside the dejima Docker container, hand
# Claude Code the session briefing. The container is dejima's own - the README
# describes what it is and docs/security.md what it bounds.
#
# Everything that composes that briefing lives in dejima-briefing-render.sh,
# baked into the image and shared with every harness. What is left here is the
+1 -1
View File
@@ -2112,7 +2112,7 @@ EOF
@test "TCB: a manifest hidden from git is a CRITICAL finding" {
# A forge's fixed_hosts reaches DEJIMA_FORGE_HOSTS, which entrypoint.sh
# joins into extra_domains and init-firewall.sh allowlists - so a line
# joins into the permitted set the egress policy is given - so a line
# added to a manifest widens the egress allowlist on the next launch.
# Manifests are data and are never executed, which is why they were not
# in the TCB list; what they are is a decision about what the next launch
+175 -2
View File
@@ -63,6 +63,46 @@ EOF
[ "$status" -ne 0 ]
}
@test "core.internal_domains parses as a list of hostnames, and rejects what is not one" {
# The key gates whether a security check applies to a name, so a typo in it
# must not be read as a value. `?list:hostname` is what makes the whole
# file refuse rather than the entry being skipped.
run lib config_type core.internal_domains
[ "$output" = "?list:hostname" ]
write_config '[core]' 'internal_domains = mirror.example:logs.example'
run lib config_get core internal_domains
[ "$status" -eq 0 ]
[ "$output" = "mirror.example:logs.example" ]
# A colon-separated list, so the launch path's `tr ':' ' '` gets two
# entries rather than one nonsense destination.
local -a entries=()
IFS=':' read -ra entries <<< "$output"
[ "${#entries[@]}" -eq 2 ] \
|| { echo "the list did not split into two entries: ${entries[*]}"; return 1; }
# And it comes back with no trailing newline, which every consumer that
# reads it line by line has to allow for: a `while read` over a
# single-entry list gets EOF on the only line and never runs its body.
# bin/dejima-allow was written without that guard and silently treated
# every declared name as undeclared.
run bash -c 'source "$1"; printf "[%s]" "$(config_get core internal_domains)"' _ "$LIB"
[ "$output" = "[mirror.example:logs.example]" ]
write_config '[core]' 'internal_domains = not a hostname'
run lib config_get core internal_domains
[ "$status" -ne 0 ] \
|| { echo "a malformed entry was accepted: $output"; return 1; }
# Unset is the default and is not an error - most hosts have no internal
# destination to declare.
write_config '[core]' 'firewall = on'
run lib config_get core internal_domains
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "config_type and config_default split the spec" {
run lib config_type audit.keep
[ "$output" = "int:1:1000" ]
@@ -1394,8 +1434,8 @@ EOF
@test "the github forge's hosts reach the push guard, never the extra-domains allowlist" {
# entrypoint.sh folds DEJIMA_FORGE_HOSTS into EXTRA_ALLOWED_DOMAINS,
# which init-firewall.sh resolves into the port-UNRESTRICTED
# allowed-domains ipset and dns-proxy.sh exempts from stop-dns-rebind.
# which firewall-up.sh puts in the port-unrestricted open class and
# exempts from the check that refuses an answer pointing inside.
# Right for a self-hosted forge host, a silent widening for GitHub's
# public names - and the firewall's own startup self-check refuses to
# bring the container up when github.com:22 is reachable without the
@@ -2221,6 +2261,139 @@ EOF
rm -rf "$workspace" "$RW_HOME"
}
@test "a read_write_per_project entry mounts this project's slice at the path it names" {
_rw_setup
pp="$(mktemp -d)"
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write_per_project = $pp"
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# The container gets the path the operator named; the host gives it one
# directory of that path, keyed the same way share_dir and the audit store
# are. Both halves asserted, because either alone is satisfied by the
# arrangement this replaces.
slug="$(printf '%s' "$workspace" | sed 's/[^a-zA-Z0-9]/-/g')"
grep -qxF "$pp/projects/$slug:$pp" "$docker_log" \
|| { echo "not mounted as a per-project slice:"; grep "$pp" "$docker_log"; return 1; }
run grep -qxF "$pp:$pp" "$docker_log"
[ "$status" -ne 0 ] \
|| { echo "the whole directory was handed over as well"; return 1; }
[ -d "$pp/projects/$slug" ] || { echo "the slice was never created"; return 1; }
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
}
@test "two projects get different slices of one read_write_per_project entry" {
# The whole point, and not deducible from one launch: the same declared
# directory has to give two workspaces two directories, or it is a shared
# pile with extra steps.
_rw_setup
pp="$(mktemp -d)"
ws_a="$(mktemp -d)"; ws_b="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write_per_project = $pp"
_rw_launch "$ws_a"; [ "$status" -eq 0 ] || { echo "$output"; return 1; }
a_log="$docker_log"
_rw_launch "$ws_b"; [ "$status" -eq 0 ] || { echo "$output"; return 1; }
a="$(grep -oE "$pp/projects/[^:]+" "$a_log" | head -1)"
b="$(grep -oE "$pp/projects/[^:]+" "$docker_log" | head -1)"
[ -n "$a" ] && [ -n "$b" ] || { echo "no slice mounted for one of them"; return 1; }
[ "$a" != "$b" ] \
|| { echo "both projects were given the same directory: $a"; return 1; }
rm -rf "$pp" "$ws_a" "$ws_b" "$RW_HOME" "$a_log" "$docker_log"
}
@test "read_write is refused when it names a read_write_per_project root" {
# The share guard's reason, one directory over: handing over the root that
# holds every project's slice puts back the pile the slicing removed.
_rw_setup
pp="$(mktemp -d)"
workspace="$(mktemp -d)"
mkdir -p "$pp/projects"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $pp/projects" "read_write_per_project = $pp"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "the pile was handed back: $output"; return 1; }
[[ "$output" == *"holds one directory per project"* ]] || { echo "$output"; return 1; }
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
}
@test "readonly is refused when it names a read_write_per_project root" {
_rw_setup
pp="$(mktemp -d)"
workspace="$(mktemp -d)"
mkdir -p "$pp/projects"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "readonly = $pp/projects" "read_write_per_project = $pp"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "every project's slice was readable: $output"; return 1; }
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
}
@test "a read_write_per_project entry overlapping a protected path is refused" {
# Slicing does not make a guarded store safe to hand over - it only makes
# less of it reachable, which is not the property the guard is about.
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write_per_project = $CONFIG_DIR"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "the approval store was mounted: $output"; return 1; }
[[ "$output" == *"only how much of it there is"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace" "$RW_HOME" "$docker_log"
}
@test "the same path in read_write and read_write_per_project is refused" {
# Not by a duplicate check: the two mean different things, so naming a path
# in both makes it an ancestor of its own per-project root, and the refusal
# that fires is the one about handing over every project's directory. That
# is the right answer, and it says which list the path belongs in.
_rw_setup
pp="$(mktemp -d)"
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $pp" "read_write_per_project = $pp"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"holds one directory per project"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"one list or the other and not in both"* ]] || { echo "$output"; return 1; }
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
}
@test "a read_write_per_project entry that is not an existing directory is refused" {
# The slice is dejima's to create; the root is not. A path that is not
# there is a typo, and creating it would turn the typo into a directory.
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write_per_project = /nonexistent/specs"
_rw_launch "$workspace"
[ "$status" -ne 0 ]
[[ "$output" == *"not an existing directory"* ]] || { echo "$output"; return 1; }
[ ! -d /nonexistent/specs ]
rm -rf "$workspace" "$RW_HOME" "$docker_log"
}
@test "--print-briefing names a per-project mount as this project's own" {
# A separate sentence from the read_write one, because the read_write
# sentence is not true of these: a session told "these host directories at
# their own paths" would believe the whole directory is behind the mount.
pp="$(mktemp -d)"
rw="$(mktemp -d)"
write_config '[core]' 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $rw" "read_write_per_project = $pp"
run "$REPO_ROOT/bin/dejima" --print-briefing
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"at their own paths"*"$rw"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"THIS project's own directory"*"$pp"* ]] || { echo "$output"; return 1; }
# And composing a briefing leaves nothing behind: the slice belongs to a
# launch, not to a diagnostic that prints one.
[ ! -d "$pp/projects" ] || { echo "--print-briefing created the slice"; return 1; }
rm -rf "$pp" "$rw"
}
@test "a path in both read_write and readonly is refused rather than resolved by mount order" {
_rw_setup
d="$(mktemp -d)"
+81 -11
View File
@@ -36,6 +36,18 @@ stub_launch() {
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
# `docker run -e NAME` carries the value in the environment rather than on the
# command line, so the log above cannot show whether a flag had anything behind
# it. Record what this process inherited, which is the same thing the flag
# would forward. GH_TOKEN is deliberately not among them: 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.
case "$*" in
"run -d --init"*)
env | grep -E '^(DEJIMA_|ALLOWED_DOMAINS=|ENABLE_FIREWALL=|ALLOW_GITHUB_PUSH=|LOKI_URL=|SANDBOX_)' \
> "${SANDBOX_TEST_FILTER_ENV:-/dev/null}" || true
;;
esac
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
@@ -52,6 +64,7 @@ launch() {
"$LAUNCH_DIR/sandbox-key" >> "$conf"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_FILTER_ENV="$LAUNCH_DIR/filter-env" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
}
@@ -79,7 +92,7 @@ log_pos() { grep -n -- "$1" "$LAUNCH_DIR/docker-log" | tail -1 | cut -d: -f1; }
|| { echo "the filter container does not run the filter entrypoint: $run"; return 1; }
# One network per launch, and the filter container on that one - not on
# whatever bridge Docker would have picked. dns-proxy.sh forwards to the
# whatever bridge Docker would have picked. The restricted resolver forwards to the
# embedded resolver at 127.0.0.11, which only exists on a user-defined
# network, so this is load-bearing rather than tidiness.
local id; id="$(sed -E 's/.*dejima-run-([0-9a-f]{16}).*/\1/' <<< "$net")"
@@ -152,7 +165,7 @@ case "$*" in
# what a firewall that refused to start leaves behind.
*dejima-firewall-ready*) exit 1 ;;
*inspect*State.Running*) echo false; exit 0 ;;
*logs*) echo "init-firewall.sh: DNS lookup for registry.npmjs.org failed"; exit 0 ;;
*logs*) echo "[egress-policy] FAILED: registry.npmjs.org did not resolve"; exit 0 ;;
esac
exit 0
EOF
@@ -225,31 +238,60 @@ EOF
done
}
@test "every variable the firewall path reads is passed to the filter container" {
# The drift guard this arrangement needs most. The sandbox gets its
# environment from docker-compose.yml; the filter container is started with
# `docker run` and gets nothing that is not named on that command line.
#
# So a firewall script that starts reading a new variable does not fail - it
# reads an empty one, and allowlists less than it was told to, which
# presents as a domain the operator approved being unreachable rather than
# as anything about this. Derived from the scripts rather than listed here,
# so the test cannot drift from them either.
# reads an empty one, and permits less than it was told to, which presents
# as a domain the operator approved being unreachable rather than as
# anything about this. Derived from the scripts rather than listed here, so
# the test cannot drift from them either.
stub_launch
launch "$PROJ"
local run; run="$(log_line 'run -d --init')"
[ -n "$run" ]
# A name these files ORIGINATE is their own working value, not something
# the launcher supplies, so only names they read without originating have
# to be passed in. Derived rather than listed, because a hand-kept
# exclusion list is the same drift this test exists to catch, one level up.
#
# `X="${X:-}"` does not count as originating X: it reads the launcher's
# value and supplies a default for its absence, which is the shape every
# variable here is consumed with. Counting it would have quietly dropped
# LOKI_URL out of the required set.
local scripts=("$SANDBOX_DIR/firewall-up.sh" "$SANDBOX_DIR/egress-policy"
"$SANDBOX_DIR/fixed-domains.sh" "$SANDBOX_DIR/log-shipper.sh")
# The awk program is held in a variable rather than written inline: it
# contains a double quote, and bash mis-nests one inside a single-quoted
# program inside "$( )" - the whole file then fails to parse, as a syntax
# error pointing at an unrelated line much further down.
local prog assigned
prog=$(cat <<'AWK'
match($0, /^[[:space:]]*(export |readonly |local )?[A-Z][A-Z0-9_]*\+?=/) {
decl = substr($0, RSTART, RLENGTH)
rest = substr($0, RSTART + RLENGTH)
gsub(/^[[:space:]]*(export|readonly|local)[[:space:]]+/, "", decl)
sub(/\+?=$/, "", decl)
if (rest ~ ("[$]{?" decl)) next
print decl
}
AWK
)
assigned="$(cat "${scripts[@]}" | awk "$prog" | sort -u)"
local v missing=""
while read -r v; do
[ -n "$v" ] || continue
grep -qxF "$v" <<< "$assigned" && continue
[[ "$run" == *"-e $v"* ]] || missing="$missing $v"
done < <(cat "$SANDBOX_DIR/firewall-up.sh" "$SANDBOX_DIR/dns-proxy.sh" \
"$SANDBOX_DIR/init-firewall.sh" "$SANDBOX_DIR/fixed-domains.sh" \
"$SANDBOX_DIR/log-shipper.sh" \
done < <(cat "${scripts[@]}" \
| grep -oE '\$\{?(ALLOW[A-Z_]*|DEJIMA_[A-Z_]*|ENABLE_[A-Z_]*|GH_TOKEN|LOKI_URL|SANDBOX_[A-Z_]*)' \
| tr -d '${' | sort -u \
| grep -vE '^(EXTRA_ALLOWED_DOMAINS|SANDBOX_TEST.*)$')
| grep -vE '^(SANDBOX_TEST.*)$')
[ -z "$missing" ] \
|| { echo "the firewall path reads these, and the filter container is not given them:$missing"; return 1; }
@@ -257,7 +299,7 @@ EOF
# And the other half of the same failure, which the flag alone cannot show.
# `docker run -e NAME` takes the value from the launcher's environment, so
# a name that is assigned but never exported passes an empty one - the
# filter container then allowlists less than it was told to, and what an
# filter container then permits less than it was told to, and what an
# operator sees is a domain they approved being unreachable, with nothing
# pointing here.
#
@@ -284,6 +326,34 @@ EOF
[ -z "$unexported" ] \
|| { echo "passed as -e NAME but never exported, so the value is empty:$unexported"; return 1; }
}
@test "a declared internal domain reaches the filter container as a value, not just a name" {
# The test above proves the flag is on the command line. `docker run -e
# NAME` takes the value from the launcher's environment, so a flag with
# nothing behind it passes an empty string - and this key decides whether a
# security check applies to a name, so an empty one silently applies it to
# every name the operator declared internal.
#
# Colon-separated in the file, space-separated in the environment, which is
# what firewall-up.sh splits on.
stub_launch
local conf
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
mkdir -p "$(dirname "$conf")"
printf '[core]\ninternal_domains = mirror.example:logs.example\n' >> "$conf"
launch "$PROJ"
local run; run="$(log_line 'run -d --init')"
[ -n "$run" ]
[[ "$run" == *"-e DEJIMA_INTERNAL_DOMAINS"* ]] \
|| { echo "the flag is not on the filter container's command line"; return 1; }
# The value itself, read out of the environment the launcher handed over.
# The stub records it, because `-e NAME` puts the value nowhere the command
# line can show it - which is the whole reason this can go wrong quietly.
local seen; seen="$(cat "$LAUNCH_DIR/filter-env" 2>/dev/null || true)"
[[ "$seen" == *"DEJIMA_INTERNAL_DOMAINS=mirror.example logs.example"* ]] \
|| { echo "the filter container was given: '$seen'"; return 1; }
}
# A `docker` that reports one filter container and one network, with whatever
# age, joiner and identity a test needs, and records every removal.
+90 -27
View File
@@ -32,7 +32,7 @@ if [ "\$1" = ps ]; then
fi
case "\$*" in
# A session container shares a filter container's network namespace, and
# that is where the dnsmasq and the ipsets are. dejima-allow derives the
# that is where the resolver and the packet filter are. dejima-allow derives the
# one from the other rather than being told, so a stub that answers this
# with nothing is a session with no filter to add a domain to.
*inspect*NetworkMode*) echo "container:dejima-test-filter"; exit 0 ;;
@@ -126,7 +126,7 @@ dockerfile_for() {
@test "valid_domain refuses the hostname shapes a resolver would read differently" {
# The matcher is a trust boundary: what it accepts becomes a dnsmasq
# `server=` line and an ipset entry. The published bypass in this space
# `server=` line and an entry in the packet filter's address sets. The published bypass in this space
# came from an egress control accepting a string its resolver then read as
# a different host, so the shapes below are the ones worth asserting on
# rather than only "does it reject evil.com".
@@ -656,6 +656,7 @@ EOF
grep -qx 'crates.io' "$(store_of "$PROJ")/allowed-domains"
}
@test "dejima-allow takes the filter container's id, which is what a session reads" {
# A session asked for its own container id runs `cat /etc/hostname`, and
# Docker names a container that joined another's network namespace after
@@ -673,7 +674,6 @@ case "$*" in
# And one running container is in its namespace.
"ps -q") echo sessionid ;;
*'{{.Id}} {{.HostConfig.NetworkMode}}'*) echo "sessionid container:filterid" ;;
*dig*) echo 10.0.0.1 ;;
esac
exit 0
EOF
@@ -685,8 +685,8 @@ EOF
# Asserted on the calls rather than the messages: which container each
# half runs in is the whole point, and a message could name either while
# the exec went to the wrong one.
grep -q 'exec -u root c1 ipset add' "$STUB_DIR/calls" \
|| { echo "the ipset add did not go to the filter container:"; cat "$STUB_DIR/calls"; return 1; }
grep -q 'exec -u root c1 /usr/local/sbin/egress-policy permit crates.io' "$STUB_DIR/calls" \
|| { echo "the permitted-set change did not go to the filter container:"; cat "$STUB_DIR/calls"; return 1; }
grep -q 'exec sessionid curl' "$STUB_DIR/calls" \
|| { echo "the probe did not run in the session container:"; cat "$STUB_DIR/calls"; return 1; }
rm -rf "$STUB_DIR"
@@ -760,55 +760,117 @@ EOF
[[ "$output" == *"still NOT reachable"* ]]
grep -qx 'crates.io' "$CONFIG_DIR/allowed-domains"
}
# A fake `docker` whose in-container dig answers only when asked upstream
# directly (@127.0.0.11), which is what an internally-routed name looks like
# from here: dnsmasq drops the private answer, the upstream still has it.
# A fake `docker` whose filter container refuses to permit the name, the way
# egress-policy does when the answer points inside a private network and
# nothing exempted the entry. $2 of the exec is the entry; --internal-ok on the
# command line is what makes it succeed, which is exactly the distinction the
# two tests below are about.
stub_docker_rebind() {
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${DOCKER_CALL_LOG:-/dev/null}"
case "$*" in
*inspect*NetworkMode*) echo "container:dejima-test-filter"; exit 0 ;;
*dig*@127.0.0.11*) echo 10.0.0.1 ;;
*egress-policy\ permit*--internal-ok*)
echo "[egress-policy] internal.example.com permitted (open class, internal-ok): 10.0.0.1"
exit 0 ;;
*egress-policy\ permit*)
echo "[egress-policy] FAILED: internal.example.com did not resolve after being permitted (an answer pointing inside a private network is refused unless --internal-ok was given); nothing was changed" >&2
exit 1 ;;
esac
exit 0
EOF
chmod +x "$STUB_DIR/docker"
}
@test "dejima-allow names the relaunch for an internally-routed domain" {
# "Failed to resolve" sent people looking at their DNS server, which is
# working - the container's rebind protection is what dropped the answer,
# and no live patch can lift it.
@test "dejima-allow names the approval that would let an internal name through" {
# A name that resolves inside the operator's own network is refused unless
# the entry was exempted, and a session-only grant deliberately is not:
# exempting an unreviewed name is how one influenced answer becomes egress
# to an internal host. So the useful answer is the approval that carries
# the exemption, not "failed to resolve" - which sent people looking at
# their DNS server, which was working.
stub_docker_rebind
run env PATH="$STUB_DIR:$PATH" \
"$REPO_ROOT/bin/dejima-allow" --container c1 internal.example.com
[ "$status" -eq 1 ]
[[ "$output" == *"private address (10.0.0.1)"* ]]
[[ "$output" == *"resolves inside your own network"* ]]
[[ "$output" == *"dejima-allow --global internal.example.com"* ]]
# And the container's own reason survives, because it names the mechanism.
[[ "$output" == *"internal-ok"* ]]
}
@test "dejima-allow points an internally-routed domain at the approval it just recorded" {
@test "a name declared internal is added to a running session, without a relaunch" {
# This used to be impossible: the exemption could only be written at
# startup, so an internally-routed name meant restarting the session. The
# permitted set is now regenerated in place, so a name the operator has
# declared internal can be added to the session already running.
printf '[core]\ninternal_domains = internal.example.com\n' > "$CONFIG_DIR/config"
stub_docker_rebind
run env PATH="$STUB_DIR:$PATH" \
run env PATH="$STUB_DIR:$PATH" DOCKER_CALL_LOG="$STUB_DIR/calls" \
"$REPO_ROOT/bin/dejima-allow" --global --container c1 internal.example.com
[ "$status" -eq 1 ]
[[ "$output" == *"approval recorded above already does this"* ]]
[[ "$output" == *"Relaunch the session"* ]]
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
grep -qx 'internal.example.com' "$CONFIG_DIR/allowed-domains"
grep -q 'egress-policy permit internal.example.com.*--internal-ok' "$STUB_DIR/calls" \
|| { echo "the declaration did not carry the exemption:"; cat "$STUB_DIR/calls"; return 1; }
[[ "$output" != *"Relaunch"* ]] \
|| { echo "still telling the operator to relaunch:"; echo "$output"; return 1; }
}
@test "dejima-allow still reports a domain that resolves nowhere as unresolvable" {
# The rebind branch above must not swallow the ordinary failure: a typo'd
# or dead name gets no answer from the upstream either.
@test "an approval alone does not exempt a name from the answer check" {
# The rule a launch applies, applied here too. 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
# what the two halves used to disagree about - this side handed out the
# exemption, a launch withheld it, and the operator found out when the next
# launch refused to start on a name that had just worked.
stub_docker_rebind
run env PATH="$STUB_DIR:$PATH" DOCKER_CALL_LOG="$STUB_DIR/calls" \
"$REPO_ROOT/bin/dejima-allow" --global --container c1 internal.example.com
[ "$status" -ne 0 ] || { echo "an approval exempted a name by itself"; return 1; }
! grep -q 'internal-ok' "$STUB_DIR/calls" \
|| { echo "the exemption was sent without a declaration:"; cat "$STUB_DIR/calls"; return 1; }
# The approval is still recorded - it is a decision about reachability and
# this command does not get to discard it over a separate question.
grep -qx 'internal.example.com' "$CONFIG_DIR/allowed-domains"
# And the refusal names the key that fixes it, not the variable the
# container reads.
[[ "$output" == *"internal_domains = internal.example.com"* ]] \
|| { echo "the refusal does not name the remedy:"; echo "$output"; return 1; }
}
@test "a session-only grant is never exempted from the answer check" {
# The other half of the test above, and the one that matters: without an
# approval there is no exemption, because exempting a name nobody reviewed
# is what turns one attacker-influenced answer into egress to an internal
# host.
stub_docker_rebind
run env PATH="$STUB_DIR:$PATH" DOCKER_CALL_LOG="$STUB_DIR/calls" \
"$REPO_ROOT/bin/dejima-allow" --container c1 crates.io
grep -q 'egress-policy permit crates.io' "$STUB_DIR/calls" \
|| { echo "no permit call at all:"; cat "$STUB_DIR/calls"; return 1; }
! grep -q 'internal-ok' "$STUB_DIR/calls" \
|| { echo "a session-only grant was exempted:"; cat "$STUB_DIR/calls"; return 1; }
}
@test "dejima-allow reports a domain that resolves nowhere, and says nothing changed" {
# The refusal branch above must not swallow the ordinary failure: a typo'd
# or dead name gets no answer either, and the operator needs to know the
# permitted set was left alone rather than half-changed.
STUB_DIR="$(mktemp -d)"
# Answers where the filter container is and nothing else, so every lookup
# comes back empty - which is the case this is about.
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/bash
case "$*" in
*inspect*NetworkMode*) echo "container:dejima-test-filter" ;;
*inspect*NetworkMode*) echo "container:dejima-test-filter"; exit 0 ;;
*egress-policy\ permit*)
echo "[egress-policy] FAILED: nowhere.example.com did not resolve after being permitted; nothing was changed" >&2
exit 1 ;;
esac
exit 0
EOF
@@ -816,7 +878,8 @@ EOF
run env PATH="$STUB_DIR:$PATH" \
"$REPO_ROOT/bin/dejima-allow" --container c1 nowhere.example.com
[ "$status" -eq 1 ]
[[ "$output" == *"Failed to resolve nowhere.example.com"* ]]
[[ "$output" == *"nowhere.example.com did not resolve"* ]]
[[ "$output" == *"left as it was"* ]]
}
@test "dejima-project approve persists every domain even when a docker call would have failed" {
+1 -1
View File
@@ -1115,7 +1115,7 @@ assert_remedy_forges_exist() {
# ("the github forge's hosts never reach the extra-domains allowlist").
# Those three hosts are public SaaS names already covered by core's
# GitHub ranges: sending them down the extra-domains path would put them
# in the port-unrestricted allowed-domains ipset AND exempt them from
# in the port-unrestricted open class AND exempt them from
# dnsmasq's stop-dns-rebind, and the firewall's startup self-check
# ("github.com:22 was reachable without ALLOW_GITHUB_PUSH=1") would then
# refuse to bring the container up at all.
+1 -1
View File
@@ -2548,7 +2548,7 @@ PY
}
@test "nothing shipped by the front doors binds a routable address" {
# Load-bearing, not stylistic. docker/init-firewall.sh:187 accepts
# Load-bearing, not stylistic. The egress policy permits traffic to
# container OUTPUT to the default-route address, which is the Docker
# bridge gateway - the host - and attaching tmux gives a host shell. A
# listener on any address the container can route to is the sandbox's own
+597 -173
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -17,7 +17,12 @@ list_scripts() {
cd "$root"
{
ls bin/dejima bin/dejima-* bin/harness/*.sh 2>/dev/null || true
ls hooks/*.sh docker/*.sh install.sh 2>/dev/null || true
# docker/* rather than docker/*.sh: the scripts installed into the
# image as commands carry no extension, and the shebang filter below
# is what separates them from the Dockerfile, the compose file and the
# Python and Lua that live here too. Missing one of them would leave
# the egress policy itself unchecked.
ls hooks/*.sh docker/* install.sh 2>/dev/null || true
ls forges/*/bin/* 2>/dev/null || true
# test/, for the reason .dejima.md gives for being careful what goes
# there: CI checks the commit out on a host and runs it, so a helper
@@ -32,7 +37,8 @@ list_scripts() {
# version of that. Harness plugins are one kind; docker/ has two more,
# and they are not incidental: caps-lib.sh holds the drop that decides
# what a session's capability set is, and fixed-domains.sh is the seam
# keeping the DNS allowlist and the ipset allowlist from drifting.
# keeping the permitted set's classes from drifting between the
# container that enforces them and the host command that adds to them.
#
# Matched by directory rather than by name, because a list is a thing
# to forget to add to. Everything under docker/ is in the trusted
+1057 -330
View File
File diff suppressed because it is too large Load Diff