Compare commits

...
239 Commits
Author SHA1 Message Date
branderson 2184bc76b2 Put the action first, and last, where a reader will meet it
tests / confined (conversation) (pull_request_target) Successful in 1m9s
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (providers) (pull_request_target) Successful in 2m4s
tests / confined (launch) (pull_request_target) Successful in 2m22s
tests / confined (share) (pull_request_target) Successful in 1m58s
tests / runner-env (confined) (pull_request_target) Successful in 52s
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 3m20s
tests / confined (audit) (pull_request_target) Successful in 3m40s
tests / confined (static) (pull_request_target) Successful in 3m4s
tests / confined (config) (pull_request_target) Successful in 5m4s
tests / privileged (pull_request_target) Successful in 6m14s
tests / runner-env (privileged) (pull_request_target) Successful in 51s
The message said the same thing twice before reaching the verb, and carried
the provider's answer in the same line. In a terminal already full of harness
output that is a paragraph to scan before finding out that the fix is one
command.

Now one short sentence, action first: what went wrong is the subordinate
half, because by the time this prints it is the only part that changes
anything.

The host log is two lines rather than one, detail first. bin/dejima's exit
audit prints the LAST line when a session ends, so the line an operator reads
without going looking is the one that says what to do - and the provider's
own words sit directly above it for anyone who does. That ordering is now
asserted, since nothing else would notice it reversing.
2026-09-15 20:03:05 -07:00
branderson 393853a024 Name the login that fixes an expired credential, where it is met
A session left open overnight meets this in the morning: the access token has
expired, the refresh token with it, and the provider answers the refresh with
invalid_grant. Nothing about that is transient, and no amount of waiting or
retrying improves it - the operator has to run `claude` on the host and log
in. What they got instead was `502 credential proxy: could not load or refresh
the credential - see host terminal` in the session, and on the host a line
quoting the provider's error code with no remedy in it.

The remedy already existed, in the startup check, for the case where the
credential file records its own refresh-token expiry. A session that was
running before the token died has already passed that check, so the path it
actually takes is the refresh, which had no idea what invalid_grant means.
One definition now, reached by both.

It is sent to the session as well as logged. The session is where the failure
is met and the host terminal is behind a window the agent cannot read, so
"see host terminal" costs a trip to find one sentence. The rule about what
crosses that boundary is intact and is the reason the exception carries two
texts: the remedy is written in this file, and the provider's bytes stay in
the log beside it.

Also raised on the 401-retry path, which reported it as an upstream request
failure - sending whoever read it to look at the provider's availability
rather than at their own login.

The host line now leads with the remedy, because the exit audit prints the
last one of them when a session ends, and that is where an operator who was
away while it failed will read it.
2026-09-15 19:49:51 -07:00
branderson 09d1881633 Merge pull request 'Assert every permitted name reaches the set its class names' (#356) from verify-entries into development
tests / confined (conversation) (pull_request_target) Successful in 1m6s
tests / confined (launch) (pull_request_target) Successful in 2m20s
tests / confined (proxy) (pull_request_target) Successful in 1m18s
tests / confined (providers) (pull_request_target) Successful in 2m3s
tests / confined (shellcheck) (pull_request_target) Successful in 1m20s
tests / confined (share) (pull_request_target) Successful in 1m58s
tests / confined (project) (pull_request_target) Successful in 3m41s
tests / confined (audit) (pull_request_target) Successful in 4m26s
tests / runner-env (confined) (pull_request_target) Successful in 50s
tests / confined (remote) (pull_request_target) Successful in 2m40s
tests / confined (static) (pull_request_target) Successful in 3m18s
tests / confined (config) (pull_request_target) Successful in 6m9s
tests / privileged (pull_request_target) Successful in 6m23s
tests / runner-env (privileged) (pull_request_target) Successful in 51s
Reviewed-on: #356
Reviewed-by: branderson <brad@branderson.io>
2026-09-15 19:47:08 -07:00
branderson 26ee847e35 Merge pull request 'Stop a pre-existing approval reading as tampering' (#355) from approval-fix into development
tests / confined (conversation) (pull_request_target) Successful in 1m5s
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (providers) (pull_request_target) Successful in 2m1s
tests / confined (launch) (pull_request_target) Successful in 2m20s
tests / confined (share) (pull_request_target) Successful in 1m58s
tests / runner-env (confined) (pull_request_target) Successful in 50s
tests / confined (remote) (pull_request_target) Successful in 2m53s
tests / confined (shellcheck) (pull_request_target) Successful in 1m20s
tests / confined (project) (pull_request_target) Successful in 3m27s
tests / confined (audit) (pull_request_target) Successful in 4m11s
tests / confined (static) (pull_request_target) Successful in 4m7s
tests / privileged (pull_request_target) Successful in 5m50s
tests / runner-env (privileged) (pull_request_target) Successful in 44s
tests / confined (config) (pull_request_target) Successful in 7m58s
Reviewed-on: #355
Reviewed-by: branderson <brad@branderson.io>
2026-09-15 19:46:37 -07:00
branderson c805208fac Handle the empty answer the policy's own set -e turns into an abort
tests / confined (conversation) (pull_request_target) Successful in 1m0s
tests / confined (launch) (pull_request_target) Successful in 2m13s
tests / confined (share) (pull_request_target) Successful in 1m59s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / confined (remote) (pull_request_target) Successful in 2m50s
tests / confined (shellcheck) (pull_request_target) Successful in 1m19s
tests / confined (project) (pull_request_target) Successful in 3m15s
tests / privileged (pull_request_target) Successful in 6m21s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (providers) (pull_request_target) Successful in 1m56s
tests / confined (audit) (pull_request_target) Successful in 3m37s
tests / confined (static) (pull_request_target) Successful in 3m3s
tests / confined (config) (pull_request_target) Successful in 4m51s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
resolve_one returns non-zero for a name that answered with no address, which
is an ordinary outcome. The sweep assigned from it bare, and under
`set -Eeuo pipefail` that aborts - so a permitted name whose host is
unreachable took the container down, which is the opposite of what the note
two lines below it exists to do. The previous form tested the substitution
inside `[ -n ... ]`, where a failure is part of a condition and exempt.

The harness could not see it. bats runs a test with errexit off, and `set -e`
inside a subshell of that shell does not restore it - measured rather than
assumed, because the first attempt at this fix was exactly that subshell and
it reported the regression as passing. The extracted functions now run in a
real child bash with the options egress-policy sets, which reproduces the
failure and then the fix.

It also wanted a table name the harness had never supplied, which the
stricter shell surfaced immediately as an unbound variable. Read out of the
script, like the exemption hint beside it.

Covered by the case that reaches it: a lookup that succeeds and carries no A
record. The existing empty-answer test stubs a dig that fails, and resolve_one
returns zero on that path, so it never reached the abort.
2026-09-14 23:57:09 -07:00
branderson ebe63cfc30 Merge pull request 'Bring the documentation up to what the release actually does' (#354) from release-docs into development
tests / confined (conversation) (pull_request_target) Successful in 1m6s
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 2m11s
tests / confined (share) (pull_request_target) Successful in 1m54s
tests / runner-env (confined) (pull_request_target) Successful in 50s
tests / confined (project) (pull_request_target) Successful in 3m11s
tests / confined (config) (pull_request_target) Successful in 4m43s
tests / privileged (pull_request_target) Successful in 5m41s
tests / confined (remote) (pull_request_target) Successful in 2m47s
tests / confined (shellcheck) (pull_request_target) Successful in 1m17s
tests / confined (audit) (pull_request_target) Successful in 3m33s
tests / confined (static) (pull_request_target) Successful in 2m58s
tests / runner-env (privileged) (pull_request_target) Successful in 48s
Reviewed-on: #354
Reviewed-by: branderson <brad@branderson.io>
2026-09-14 23:54:14 -07:00
branderson 9764ddd4fc Record a name once, in the narrower class, so its entry can be checked
tests / confined (conversation) (pull_request_target) Successful in 1m5s
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / privileged (pull_request_target) Failing after 1m25s
tests / runner-env (privileged) (pull_request_target) Successful in 45s
tests / confined (providers) (pull_request_target) Successful in 1m59s
tests / confined (launch) (pull_request_target) Successful in 2m17s
tests / confined (share) (pull_request_target) Successful in 1m58s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (remote) (pull_request_target) Successful in 2m53s
tests / confined (shellcheck) (pull_request_target) Successful in 1m19s
tests / confined (project) (pull_request_target) Successful in 3m19s
tests / confined (audit) (pull_request_target) Successful in 3m39s
tests / confined (static) (pull_request_target) Successful in 3m4s
tests / confined (config) (pull_request_target) Successful in 5m3s
A name can reach both class lists. release-assets.githubusercontent.com is an
open-class entry of this component's own and also sits under a forge suffix,
and any harness or backend domain under one would do the same - so this
repository's own allowlist produces it.

Two lines meant two resolver lines naming different sets for one name, of
which the resolver honours one. The entries file and `status` could not say
which class was in force, and the check added alongside this - which reads an
answer back out of its class's set - would look in the set that lost and stop
the launch. That is how this was found: the check refused the project's own
configuration.

Forge is recorded first and the deduplication keeps the first occurrence, so
a name that is forge class by any route keeps the port restriction rather
than inheriting the open class from a second mention. That also makes
fixed-domains.sh's claim that the forge class wins wherever the two overlap
true of the entries, which it was not.
2026-09-14 23:01:41 -07:00
branderson 5ee405fd6e Assert every permitted name reaches the set its class names
Check 4 asserted this for one destination: the host of the GitHub metadata
URL, which is the one hardest to get wrong. The operator's own forge, Loki, a
self-served backend and every approved domain were never subjected to it.

That is the gap four review rounds kept falling into. The class an entry was
recorded with, the resolver line built from it, and the set the ports are
keyed to are three separate statements, and each can be right about a
different set. Reading the code cannot tell them apart, and every defect of
that shape here reached a green suite: a forge name recorded in the open
class, a name skipped out of every class, a name that could not resolve at
all. All three present at boot as a name that answers and an address that is
not where the ports expect it.

The sweep that already resolves every permitted name now reads each answer
back out of its class's set. Fatal, unlike a name that did not answer: that
one is the world being unavailable and deliberately costs nothing, while this
one is the policy disagreeing with itself.

Which set a class feeds now has one definition. The config that fills a set
and the check that reads it back asking separately is the same shape as the
bug, and would have let the check pass while the class meant nothing.

The harness for these tests stubs nft as well as dig, because a sandbox's own
nft answers for a table that is not there - every name would look stranded
and the failure would be about the test environment.
2026-09-14 22:59:32 -07:00
branderson 39b7c9888d Stop a pre-existing approval reading as tampering, and check the derived host properly
tests / confined (conversation) (pull_request_target) Successful in 1m2s
tests / confined (launch) (pull_request_target) Successful in 2m16s
tests / confined (audit) (pull_request_target) Successful in 3m31s
tests / confined (config) (pull_request_target) Successful in 4m51s
tests / confined (project) (pull_request_target) Successful in 3m14s
tests / confined (providers) (pull_request_target) Successful in 1m58s
tests / confined (proxy) (pull_request_target) Successful in 1m9s
tests / confined (remote) (pull_request_target) Successful in 2m48s
tests / confined (share) (pull_request_target) Successful in 1m59s
tests / confined (static) (pull_request_target) Successful in 3m20s
tests / confined (shellcheck) (pull_request_target) Successful in 1m18s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / runner-env (privileged) (pull_request_target) Successful in 51s
tests / privileged (pull_request_target) Successful in 6m17s
Two holes at either end of the two-label rule.

The rule was applied where approvals are READ. approved_entries validates
every entry on every read and bin/dejima assigns from it under set -e, so a
single-label domain already in the store failed the read - in every project,
on every launch, with the store's "corrupt approval entry" message, which
means tampering. The last release accepted those domains, so that store is
the one an upgrading operator has, and their only mistake was upgrading.
`dejima-allow` has no remove verb, so the recovery was hand-editing the file
the message had just called corrupt. The launch-time message written for this
case was unreachable behind it.

Granting is where a tightened rule belongs. Reading keeps the shape check it
had, which still refuses real corruption.

The launch check counted dots, which only catches a derivation that failed in
a way that happens to leave a bare label. `https://user@host.example/` gives
`user@host.example` and `http://loki./` gives `loki.` - both have a dot,
neither is a host name, and both were the sed slips the check was written
for. It now asks the same question of the value that the policy will ask.

Its message changed shape with it, so the assertion naming the old wording
moved to the rule rather than the phrasing.
2026-09-14 22:29:02 -07:00
branderson 10aed998aa Bring the documentation up to what the release actually does
tests / confined (conversation) (pull_request_target) Successful in 1m0s
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 2m12s
tests / confined (share) (pull_request_target) Successful in 1m58s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / confined (remote) (pull_request_target) Successful in 2m49s
tests / confined (shellcheck) (pull_request_target) Successful in 1m18s
tests / confined (project) (pull_request_target) Successful in 3m15s
tests / confined (audit) (pull_request_target) Successful in 3m35s
tests / confined (static) (pull_request_target) Successful in 3m22s
tests / confined (config) (pull_request_target) Successful in 4m55s
tests / privileged (pull_request_target) Successful in 5m58s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
Five things this release changed and their documentation did not.

The port-22 self-check claim was corrected in security.md and left standing
in providers.md twice - the same one-of-three that the case fold got wrong a
round earlier. Both now say what the check covers: the refusal when the
opt-in is off, skipped when it is on.

The correction in security.md then over-claimed in the other direction, that
with the opt-in on the self-check "checks nothing". Only that one check is
skipped.

The documented LogQL query for refused lookups matches `REFUSED`, which was
dnsmasq's raw log. That stream is structured now and the token does not
appear in it; the outcome is a JSON field. The same document describes the
new shape correctly four paragraphs earlier.

log-shipper.sh's row in the development table still described tailing the
dnsmasq and ulogd2 logs. That file's own header was rewritten in this
release and the row was missed.

The two-label rule for a permitted destination was documented nowhere, so an
operator met it as a rejected declaration or a refused launch. It is stated
once, with the reason, next to the file it most often applies to, and lists
the other keys it covers. valid_domain's comment claimed the shape it no
longer gates.
2026-09-14 22:10:03 -07:00
branderson f4017b878f Merge pull request 'Stop telling a sandbox to create a token file it must not create' (#353) from token-message into development
tests / confined (conversation) (pull_request_target) Successful in 1m7s
tests / confined (launch) (pull_request_target) Successful in 2m17s
tests / confined (proxy) (pull_request_target) Successful in 1m17s
tests / confined (providers) (pull_request_target) Successful in 2m2s
tests / confined (shellcheck) (pull_request_target) Successful in 1m21s
tests / confined (share) (pull_request_target) Successful in 1m57s
tests / confined (project) (pull_request_target) Successful in 3m39s
tests / confined (audit) (pull_request_target) Successful in 4m27s
tests / runner-env (confined) (pull_request_target) Successful in 49s
tests / confined (remote) (pull_request_target) Successful in 2m38s
tests / confined (static) (pull_request_target) Successful in 3m17s
tests / confined (config) (pull_request_target) Successful in 6m10s
tests / privileged (pull_request_target) Successful in 6m10s
tests / runner-env (privileged) (pull_request_target) Successful in 44s
Reviewed-on: #353
Reviewed-by: branderson <brad@branderson.io>
2026-09-14 21:59:05 -07:00
branderson 10aab4846a Merge pull request 'Revert the dotless-name acceptance, which could not work and granted a suffix' (#351) from revert-singlelabel into development
tests / confined (conversation) (pull_request_target) Successful in 1m2s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (providers) (pull_request_target) Successful in 1m54s
tests / confined (launch) (pull_request_target) Successful in 2m11s
tests / confined (share) (pull_request_target) Successful in 1m59s
tests / runner-env (confined) (pull_request_target) Successful in 53s
tests / confined (remote) (pull_request_target) Successful in 2m51s
tests / confined (shellcheck) (pull_request_target) Successful in 1m21s
tests / confined (project) (pull_request_target) Successful in 3m26s
tests / confined (audit) (pull_request_target) Successful in 4m12s
tests / confined (static) (pull_request_target) Successful in 4m15s
tests / privileged (pull_request_target) Successful in 5m31s
tests / runner-env (privileged) (pull_request_target) Successful in 44s
tests / confined (config) (pull_request_target) Successful in 7m58s
Reviewed-on: #351
Reviewed-by: branderson <brad@branderson.io>
2026-09-14 21:58:39 -07:00
branderson 35cd88c25a Stop telling a sandbox to create a token file it must not create
tests / confined (conversation) (pull_request_target) Successful in 1m8s
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (providers) (pull_request_target) Successful in 2m0s
tests / confined (launch) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 53s
tests / confined (share) (pull_request_target) Successful in 1m58s
tests / confined (remote) (pull_request_target) Successful in 2m53s
tests / confined (shellcheck) (pull_request_target) Successful in 1m18s
tests / confined (project) (pull_request_target) Successful in 3m18s
tests / confined (audit) (pull_request_target) Successful in 3m37s
tests / confined (static) (pull_request_target) Successful in 3m3s
tests / confined (config) (pull_request_target) Successful in 5m1s
tests / privileged (pull_request_target) Successful in 6m45s
tests / runner-env (privileged) (pull_request_target) Successful in 49s
The token reaches a sandbox as GITEA_TOKEN, read host-side from whatever the
configured forge's token_file names. The file these tools fall back to is
deliberately absent in the container - the credential does not enter it as a
file at all.

Both messages named that file as the requirement and the variable as the
exception, "for this invocation only". Inside a sandbox that is backwards,
and an agent reading it concluded it had no way to push and stopped, on a
session whose token was present and working the whole time. Following the
advice instead would mean writing a credential into a disposable filesystem,
or asking the operator to paste one in.

The sandbox branch says where the token comes from and that there is nothing
to create in here, and points at the two config keys that decide it. The
host-side message is unchanged, because there the file is the remedy.

Not a regression from any release: these lines predate the egress and image
rework and were not touched by it.
2026-09-14 20:30:24 -07:00
branderson 64cc56589c Require a permitted destination to be fully qualified, where it is configured
tests / confined (conversation) (pull_request_target) Successful in 1m9s
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (providers) (pull_request_target) Successful in 1m58s
tests / confined (launch) (pull_request_target) Successful in 2m14s
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 2m50s
tests / confined (shellcheck) (pull_request_target) Successful in 1m16s
tests / confined (project) (pull_request_target) Successful in 3m15s
tests / confined (config) (pull_request_target) Successful in 4m53s
tests / privileged (pull_request_target) Successful in 6m5s
tests / confined (audit) (pull_request_target) Successful in 3m35s
tests / confined (static) (pull_request_target) Successful in 3m0s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
The policy refuses a dotless entry at container start, and correctly: a
permitted name covers everything under it, because the resolver is configured
per name and matches the name and its subdomains with no exact-name form to
use instead. A bare label therefore grants its whole suffix, and `org` would
ask for every .org name. The requirement is a floor on how much one entry can
grant.

Refusing it at container start is the wrong place. The operator meets it as a
sandbox that will not boot, on a value every check on the way in accepted.
This moves the refusal to the two places they can act on it: the project
declaration, so `dejima-project` refuses the entry while they are looking at
the file, and the launch, before anything starts, naming the key it came
from.

valid_domain is left alone. It also gates host-side consumers, where a single
label is an ordinary thing to write, so the rule belongs with the entries
that reach the firewall rather than with host names in general.

The derived hosts are checked, not the values they came from. A URL yields
its host through a sed, and a slip there - a base_url carrying userinfo
reduces to the user name - produces a bare label nobody typed. That is the
case this most wants to catch, because it is the one nobody would look for.
2026-09-14 14:05:59 -07:00
branderson 3c6912b0f9 Fold case in the two places the last commit left comparing verbatim
tests / confined (conversation) (pull_request_target) Successful in 1m4s
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (providers) (pull_request_target) Successful in 1m55s
tests / confined (launch) (pull_request_target) Successful in 2m6s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / confined (share) (pull_request_target) Successful in 1m56s
tests / confined (remote) (pull_request_target) Successful in 2m46s
tests / confined (shellcheck) (pull_request_target) Successful in 1m19s
tests / confined (project) (pull_request_target) Successful in 3m13s
tests / confined (audit) (pull_request_target) Successful in 3m33s
tests / confined (static) (pull_request_target) Successful in 2m59s
tests / confined (config) (pull_request_target) Successful in 4m50s
tests / privileged (pull_request_target) Successful in 6m28s
tests / runner-env (privileged) (pull_request_target) Successful in 49s
dejima_class_for was folded; dejima_mark_internal beside it and the
internal_domains lookup in dejima-allow were not. The principle was stated
and applied once.

Here the cost is not a lost restriction but a refused launch.
dejima_mark_internal returns non-zero for an entry nothing permits and the
caller stops the container naming it, so [core] internal_domains and the key
that permitted the same host, differing only in case, is a correct
configuration that will not start - and the message says the name is
permitted by nothing when it is permitted. No forge need be involved: two
ordinary keys naming one host is enough.

dejima-allow loses the exemption instead, silently, and the next launch then
refuses on the same mismatch.

The test guard added with the previous commit checked the wrong thing: it
grepped the whole file for `domain-needed`, which that commit had just put
into a comment and a refusal message, so it was satisfied by the text whose
accuracy it existed to check. It now matches the directive on its own line,
and was run against a copy with the directive deleted and the prose left.

Both folds are covered on both sides of the comparison. Removing only the
suffix half previously left every test green.
2026-09-14 01:01:56 -07:00
branderson fc750688c1 Fold case before deciding a domain's class
tests / confined (conversation) (pull_request_target) Successful in 1m15s
tests / confined (proxy) (pull_request_target) Successful in 1m23s
tests / confined (providers) (pull_request_target) Successful in 2m8s
tests / confined (launch) (pull_request_target) Successful in 2m19s
tests / runner-env (confined) (pull_request_target) Successful in 53s
tests / confined (share) (pull_request_target) Successful in 1m59s
tests / confined (remote) (pull_request_target) Successful in 3m1s
tests / confined (shellcheck) (pull_request_target) Successful in 1m18s
tests / confined (project) (pull_request_target) Successful in 3m26s
tests / confined (audit) (pull_request_target) Successful in 3m46s
tests / confined (config) (pull_request_target) Successful in 5m6s
tests / runner-env (privileged) (pull_request_target) Successful in 49s
tests / confined (static) (pull_request_target) Successful in 2m59s
tests / privileged (pull_request_target) Successful in 6m41s
DNS names are case-insensitive and the resolver matches them so, but the
class decision compared them verbatim: GITHUB.COM came back open while
github.com came back forge. An allowlist entry in that spelling, which an
operator reviews as an ordinary GitHub name, would be permitted on every port
by its own class.

Not reachable today - the forge clamp on the published ranges sits above the
open-class permit and ends in a refusal, so the address is caught whatever
class its name landed in. That is one layer doing the work of two, which is
the arrangement this function exists to prevent.
2026-09-14 00:09:14 -07:00
branderson 7f828e8416 Refuse a dotless permitted entry again, and say why it is refused
Accepting one did not make it work and did open a grant. Two things were
missed: the resolver config sets domain-needed, so dnsmasq never forwards a
dotless query and the name resolves to nothing however it is permitted; and
an entry becomes a `server=/<entry>/` line, which matches the name and
everything under it, so `org` permits every .org name into the open class,
which carries no port restriction. Both were measured against dnsmasq 2.90:
with domain-needed `loki` is NXDOMAIN and `sub.loki` answers, and with
`server=/org/` both `example.org` and `anything.org` answer.

So the refusal was right and only its message was wrong - it read as a typo
for the one shape an operator writes on purpose. It now says what would have
to be true for the name to work, which is also what anyone proposing to
support these has to change.

The test asserts the reason as well as the refusal, and checks domain-needed
is still what makes it true, so the two cannot drift apart silently.
2026-09-14 00:07:39 -07:00
branderson bf82907e88 Merge pull request 'Two boot-stopping regressions found by a second review pass' (#349) from boot-regressions into development
tests / confined (audit) (pull_request_target) Successful in 3m34s
tests / confined (config) (pull_request_target) Successful in 4m48s
tests / confined (conversation) (pull_request_target) Successful in 57s
tests / confined (launch) (pull_request_target) Successful in 2m2s
tests / confined (project) (pull_request_target) Successful in 3m13s
tests / confined (providers) (pull_request_target) Successful in 1m54s
tests / confined (proxy) (pull_request_target) Successful in 1m6s
tests / confined (remote) (pull_request_target) Successful in 2m48s
tests / confined (share) (pull_request_target) Successful in 1m59s
tests / confined (shellcheck) (pull_request_target) Successful in 1m18s
tests / privileged (pull_request_target) Successful in 5m34s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / runner-env (privileged) (pull_request_target) Successful in 46s
tests / confined (static) (pull_request_target) Successful in 2m24s
Reviewed-on: #349
Reviewed-by: branderson <brad@branderson.io>
2026-09-13 23:49:59 -07:00
branderson 554bda9003 Say which direction the port 22 self-check actually covers
tests / confined (conversation) (pull_request_target) Successful in 1m5s
tests / confined (proxy) (pull_request_target) Successful in 1m10s
tests / confined (providers) (pull_request_target) Successful in 1m57s
tests / confined (launch) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (share) (pull_request_target) Successful in 1m59s
tests / confined (remote) (pull_request_target) Successful in 2m52s
tests / confined (shellcheck) (pull_request_target) Successful in 1m15s
tests / confined (project) (pull_request_target) Successful in 3m15s
tests / confined (audit) (pull_request_target) Successful in 3m38s
tests / confined (static) (pull_request_target) Successful in 3m2s
tests / confined (config) (pull_request_target) Successful in 4m57s
tests / privileged (pull_request_target) Successful in 6m23s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
The document claimed the push opt-in was verified by the startup self-check.
It is not: with the opt-in on the check is skipped, and only the refusal is
asserted. A reader relying on that would expect a container that refuses to
start, and get a failed push instead.

Says where the property is covered rather than leaving the absence implied.
2026-09-13 23:26:30 -07:00
branderson dbcbe5c89e Accept a single-label host name as a permitted destination
The launcher's own validator accepts a name with no dots, so a LAN host, a
container-runtime service name or an /etc/hosts entry passes every check an
operator can see. The policy then refused it and stopped the container, which
is the worst place for the two to disagree: the operator has already been
told the value is good, and the failure surfaces as a sandbox that will not
start rather than as a message about the key they set.

Reachable from [logging] loki_url, a self-hosted forge host, and a
self-served backend's base_url, each of which is seded down to a bare host.

Nothing covered classify_entry, which is how the two drifted. The test drives
the function out of the file against both sides of the boundary, and asserts
the refusals it exists for still refuse - an IPv6 entry in particular, since
this policy refuses the family rather than filtering it.
2026-09-13 23:26:15 -07:00
branderson 1cc9d8c7d7 Record an approved forge name in its class, rather than leaving it out
Skipping it left the name reachable - the forge suffix entry covers it - but
absent from every list. [core] internal_domains is checked against those
lists by exact string, so declaring split-horizon DNS for a forge subdomain
read as a name nothing permits, and the launch stopped on a configuration
that was correct. The remedy the error printed put the name back in the
approved list, where it was skipped again.

Recording it as forge class keeps what the skip was protecting: the resolver
line more specific than the suffix entry now feeds the forge set rather than
the open one, so the port restriction does not come to rest on the published
ranges alone.

The new test drives the loop and the internal_domains check as they are in
the file, so it fails if either is changed to disagree with the other. It was
run against the previous behaviour, where it reports the boot stopping.
2026-09-13 23:24:23 -07:00
branderson 2f2475a66d Merge pull request 'Stop the privileged job walking /proc to prove the image has no setuid binary' (#344) from ci-timings into development
tests / confined (conversation) (pull_request_target) Successful in 1m3s
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (providers) (pull_request_target) Successful in 1m59s
tests / confined (launch) (pull_request_target) Successful in 2m2s
tests / runner-env (confined) (pull_request_target) Successful in 50s
tests / confined (share) (pull_request_target) Successful in 1m57s
tests / confined (remote) (pull_request_target) Successful in 2m49s
tests / confined (shellcheck) (pull_request_target) Successful in 1m15s
tests / confined (project) (pull_request_target) Successful in 3m12s
tests / confined (audit) (pull_request_target) Successful in 3m34s
tests / confined (static) (pull_request_target) Successful in 2m59s
tests / confined (config) (pull_request_target) Successful in 4m53s
tests / privileged (pull_request_target) Successful in 6m25s
tests / runner-env (privileged) (pull_request_target) Successful in 48s
Reviewed-on: #344
Reviewed-by: branderson <brad@branderson.io>
2026-09-13 21:04:34 -07:00
branderson 40feada4bb Merge pull request 'Two findings from the release-pass security review' (#346) from release-fixes into development
Reviewed-on: #346
Reviewed-by: branderson <brad@branderson.io>
2026-09-13 21:03:34 -07:00
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 86be44752c Have the privileged job report where its time goes
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 1m55s
tests / confined (launch) (pull_request_target) Successful in 2m3s
tests / runner-env (confined) (pull_request_target) Successful in 49s
tests / confined (share) (pull_request_target) Successful in 1m53s
tests / confined (remote) (pull_request_target) Successful in 2m44s
tests / confined (shellcheck) (pull_request_target) Successful in 1m14s
tests / confined (project) (pull_request_target) Successful in 3m8s
tests / confined (audit) (pull_request_target) Successful in 3m25s
tests / confined (static) (pull_request_target) Successful in 2m53s
tests / confined (config) (pull_request_target) Successful in 4m43s
tests / privileged (pull_request_target) Successful in 6m19s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
The slow job is the one nobody can run locally, so what a test costs there
has been inferred rather than read. -T prints each test's duration, and this
suite runs serially, so the numbers add up to the job.

The expensive tests here are the ones that build an image, not the ones that
assert the most, and the two are not the same tests. The remaining items on
#343 need that attribution before they are worth doing.

Refs #343
2026-09-13 14:58:29 -07:00
branderson 1df757afe6 Check the privilege sweep by running it, not by restating it
The runtime assertion walked the filesystem with its own find and getcap.
`getcap` has no `-xdev`, so `getcap -r /` descended into /proc and /sys as
well: 30s against 4s for the same answer, measured on a session container,
and more on an image carrying a browser.

strip-privilege-bits already names the roots that can hold a binary, and
says why. Giving it a --check mode that runs its verification alone lets the
test ask the same question of the same roots. A check that walks fewer roots
than the sweeper would pass for the wrong reason, and now it cannot walk a
different set at all.

Refs #343
2026-09-13 14:58:25 -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
branderson 3b6c5c016f Merge pull request 'Fixes from the review of the filter-container stack' (#333) from review-fixes into allow-pair
tests / confined (conversation) (pull_request_target) Successful in 1m5s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / runner-env (confined) (pull_request_target) Successful in 49s
tests / confined (remote) (pull_request_target) Successful in 2m42s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / confined (project) (pull_request_target) Successful in 3m3s
tests / confined (audit) (pull_request_target) Successful in 3m23s
tests / privileged (pull_request_target) Successful in 4m15s
tests / confined (launch) (pull_request_target) Successful in 1m54s
tests / confined (providers) (pull_request_target) Successful in 1m54s
tests / confined (share) (pull_request_target) Successful in 1m50s
tests / confined (static) (pull_request_target) Successful in 2m50s
tests / confined (config) (pull_request_target) Successful in 4m36s
tests / runner-env (privileged) (pull_request_target) Successful in 45s
Reviewed-on: #333
Reviewed-by: branderson <brad@branderson.io>
2026-09-10 01:55:26 -07:00
branderson 0a6b55b5b9 Give the privileged job the compose plugin it was missing
tests / confined (conversation) (pull_request_target) Successful in 1m3s
tests / confined (proxy) (pull_request_target) Successful in 1m7s
tests / confined (providers) (pull_request_target) Successful in 1m52s
tests / confined (launch) (pull_request_target) Successful in 1m57s
tests / confined (shellcheck) (pull_request_target) Successful in 1m13s
tests / confined (project) (pull_request_target) Successful in 3m7s
tests / runner-env (privileged) (pull_request_target) Successful in 45s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (share) (pull_request_target) Successful in 1m51s
tests / confined (remote) (pull_request_target) Successful in 2m45s
tests / confined (audit) (pull_request_target) Successful in 3m32s
tests / confined (static) (pull_request_target) Successful in 2m55s
tests / privileged (pull_request_target) Successful in 4m23s
tests / confined (config) (pull_request_target) Successful in 4m44s
`docker compose` is a CLI plugin, so without it the subcommand does not exist
at all - which is why the compose-file test failed there with "unknown
shorthand flag: 'f'" and a usage listing with no compose in it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

It exits rather than warning. A gate that says something is wrong and then
prints its report anyway is one whose output still ends up in a release PR.
2026-09-03 17:42:25 -07:00
branderson 483e44978d Merge pull request 'Ask git where the push is going, not where the fetch comes from' (#319) from secreview into development
Reviewed-on: #319
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 16:02:41 -07:00
branderson f51403a3dc Ask git where the push is going, not where the fetch comes from
tests / confined (conversation) (pull_request_target) Successful in 54s
tests / confined (proxy) (pull_request_target) Successful in 1m2s
tests / confined (providers) (pull_request_target) Successful in 1m44s
tests / confined (share) (pull_request_target) Successful in 1m43s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m24s
tests / confined (project) (pull_request_target) Successful in 2m50s
tests / confined (audit) (pull_request_target) Successful in 3m9s
tests / confined (static) (pull_request_target) Successful in 2m40s
tests / privileged (pull_request_target) Successful in 3m47s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (config) (pull_request_target) Successful in 4m17s
Found by the release pass's security review, reading the two push guards
against what they claim.

Both resolved the remote with `git remote get-url`, which answers with
remote.<name>.url. git pushes to remote.<name>.pushurl when one is set, so
a pushurl naming any other host was checked against the fetch URL and
allowed. No evasion needed: .git/config is in the read-write project mount
- deliberately, since `git push -u` writes there - so a session can set one
and every later push is checked against the wrong host. `get-url --push` is
the one question that answers where the push goes.

A one-command `-c remote.origin.pushurl=...` slipped a second way: both
guards skipped `-c` and its value while finding the subcommand, then
resolved the URL without it, answering for a different configuration than
the push would run under. Both now carry those overrides into their own
lookup.

And the hook had two problems of its own, neither shared with the shadow:

It recognised `git push` and `git -C <dir> push` by pattern and nothing
else, so `git -c k=v push`, `git --git-dir=... push` and `git --no-pager
push` were not pushes as far as it was concerned and it exited 0 before any
check ran. It walks tokens now, splitting compound commands on the shell
separators first so that only a segment beginning with git is read as a git
invocation - `echo push` is not one.

It also failed OPEN on a remote it could not resolve where the shadow fails
closed, and the shadow's comment says why closed is right: an unresolvable
remote is what a disagreement between a parser and git's own looks like,
and that has to be a refusal rather than a push nobody checked. Reaching
that state was easy, because splitting the command on the first occurrence
of "push" split inside `pushurl` and produced a nonsense remote.

The shadow catches every one of these regardless - it is the layer that
holds whichever harness is selected - so what was wrong was the earlier
warning being quieter than it claimed, not the boundary. The test that the
two guards agree now has cases where they did not.
2026-09-03 15:46:49 -07:00
branderson ebfc5ce7ff Merge pull request 'Fix the release blocker, and report the axis the rollback check was missing' (#315) from release-fixes into development
Reviewed-on: #315
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 15:29:48 -07:00
branderson 3e1521c412 Describe what the code does, not what it stopped doing
tests / confined (conversation) (pull_request_target) Successful in 56s
tests / confined (proxy) (pull_request_target) Successful in 1m2s
tests / confined (share) (pull_request_target) Successful in 1m41s
tests / confined (providers) (pull_request_target) Successful in 1m43s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m22s
tests / confined (project) (pull_request_target) Successful in 2m44s
tests / confined (audit) (pull_request_target) Successful in 3m6s
tests / confined (static) (pull_request_target) Successful in 2m36s
tests / privileged (pull_request_target) Successful in 3m39s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (config) (pull_request_target) Successful in 4m15s
The passage fixing the credential blocker narrated the change instead of
the state: "used to fall through to it", "that is now a refusal". Someone
reading it to find out what happens today has to work out which half is
still true, and .dejima.md says exactly this - cut what the code replaced,
git holds it, and a reader debugging the line has no use for it.

Two more of the same voice, both mine from the prose pass: the credential
proxy "is no longer on that list", and a session-start hook being "no
longer the only route". Neither past state is load-bearing; both sentences
say more without it.
2026-09-03 15:15:02 -07:00
branderson 6baf78d05c Print nothing when there is nothing to print, and accept a bracketed model
Two small things from a real install and a real launch.

install.sh emitted a blank line before the defaults rebuild whether or not
the rebuild had anything to say, so a run that added, dropped and reported
nothing left a double gap between two paragraphs that were already
separated. The blank line belongs to each message instead, which makes
silence silent.

And `claude-opus-5[1m]` was refused as a model id. The modelid type was
added a week ago because `identifier` had no slash and no OpenRouter id
could be configured; the set it grew was built from the ids in that report,
and Anthropic brackets a context variant. So the same bug, one release
later, for the same reason: a charset derived from the failure at hand
rather than from what the catalogues actually spell.

Brackets carry no meaning anywhere this value goes - it is written with jq
--arg - and the reason the type stays narrow is unchanged: a newline or a
tab would split a record every reader of this config expects to be one
line. Those are still refused, along with a space, a semicolon and a
backtick.

The test grew the bracketed id beside the others rather than replacing
them, and says why: real ids from every catalogue dejima can reach, not the
ones that prompted a fix.
2026-09-03 15:15:02 -07:00
branderson 26ed2e6a5b Wait for the byte to arrive rather than pumping for a fixed 0.8s
CI failed static.bats 144 with

    FAIL ctrl-c-still-reaches-the-session b'ped\n'

The last four bytes the container received were the tail of `typed\n`,
the line sent immediately before: ^C had not arrived yet when the check
looked. Nothing was wrong with the code under test.

Two sites sent a byte, pumped the pty for 0.8 seconds and then asserted -
timing the machine rather than waiting for the thing. A byte written to
the master has to reach the client, cross into the container stand-in and
be appended to a file before a check can see it, and on a loaded host that
is not reliably under 0.8s.

This is the third instance of the same bug in this file. The reclaim wait
and await_raw are the first two, and await_raw's own comment names the
shape - "Sampling the flags right after fg was timing the machine, and on
a loaded one it read the shell's own canonical mode". So it gets the same
answer they got, factored: await_received polls until what the container
received satisfies a predicate, and returns the predicate's answer at the
deadline either way, so a real failure still fails rather than hanging
until the suite's own timeout.

Verified under load rather than by argument: three consecutive runs green
with twenty busy loops on ten cores, which is heavier than the CI host
that failed it.
2026-09-03 15:15:02 -07:00
branderson 1f251efd5b Stop describing a fallthrough that is now a refusal
Release blocker, found by a release pass rather than by a test.

c306c32 made an unproxied credential a launch refusal: a Claude Code
signed in with a console API token has no claudeAiOauth block, so
backends/anthropic cannot serve it, and dejima used to skip the proxy and
mount the real file. docs/security.md's arrangements table went on listing
that as the fourth row - the one where the real credential is mounted and
a compromised session can take it.

A document promising the weakest posture for a launch that no longer
happens is worse than one saying nothing, because it is the posture an
operator plans around. Only proxy = off lands there now, and only where
they wrote it. The passage says what happens instead, and that the refusal
is scoped to a credential that exists: an absent one still launches,
because that is a host nobody has signed in on yet.

A static test pins the two together, since the drift was silent for a
release and nothing would have caught the next one. It reads the table's
own row rather than the prose around it, and asserts the code still
refuses - so removing the refusal fails the test about the document, which
is the direction that matters.

Also from the same pass: test/release-pass.sh enumerated four provider
axes under a comment claiming every one, and tools is the fifth. A new
tool is the same rollback hazard - [core] tools = rtk is refused by a
dejima that does not ship rtk - so it now reports one, names it through
the value that refuses rather than as a config section it does not have,
and a check against the tree fails when an axis is added and not listed.
A change under tools/ gets a manual check too; nothing here runs one.

And a flourish trimmed in docs/development.md: "confirmed rather than
argued" said nothing the sentence after it did not. What is left is the
fact, now stronger - Cline has run real completions through the terminator
against two backends of different api_style.
2026-09-03 15:15:02 -07:00
branderson 58c6f1b64d Merge pull request 'Read the rejection whatever its framing, and stop calling a 429 a refusal' (#317) from proxy-log2 into development
Reviewed-on: #317
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 15:14:24 -07:00
branderson 3642156e7c Merge pull request 'Say that a Claude subscription is served to Claude Code, and that dejima will not pretend otherwise' (#318) from subscription-gating into development
Reviewed-on: #318
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 15:14:00 -07:00
branderson df7c32154c Keep the gating in providers.md, where a limit on dejima belongs
tests / confined (conversation) (pull_request_target) Successful in 59s
tests / confined (proxy) (pull_request_target) Successful in 1m2s
tests / confined (share) (pull_request_target) Successful in 1m46s
tests / confined (shellcheck) (pull_request_target) Successful in 1m10s
tests / confined (providers) (pull_request_target) Successful in 2m18s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m29s
tests / confined (project) (pull_request_target) Successful in 2m53s
tests / confined (audit) (pull_request_target) Successful in 3m13s
tests / confined (config) (pull_request_target) Successful in 4m29s
tests / privileged (pull_request_target) Successful in 3m52s
tests / runner-env (privileged) (pull_request_target) Successful in 43s
tests / confined (static) (pull_request_target) Successful in 2m45s
responsible-use.md is where dejima makes a judgement about what an operator
does - don't spam maintainers, own what you merge, security is not
retrofittable. Working around a vendor's client gating is not that, and
dejima has no view on whether someone should try.

What dejima has is a limit: it does not do it. That is a fact about the
credential proxy, so it lives beside the fact that prompted it, in
providers.md, where someone choosing a backend is already reading.

The reasons are the engineering ones. The proxy substitutes the credential
and changes nothing else, so getting past the gate would mean sending a
system prompt the harness never wrote - a second answer to "which client is
this" inside the component whose only job is to pass the first one through.
And it would be brittle where the cost lands on the operator's account
rather than on dejima.

Gone with the section: the sentence about a sandbox not getting to pick
which boundaries it honours. True of the sandbox, and an argument nobody
reading this needed.
2026-09-03 15:03:24 -07:00
branderson ea352d21ce Say that a Claude subscription is served to Claude Code, and that dejima
tests / confined (conversation) (pull_request_target) Successful in 53s
tests / confined (proxy) (pull_request_target) Successful in 1m1s
tests / confined (share) (pull_request_target) Successful in 1m45s
tests / confined (providers) (pull_request_target) Successful in 1m48s
tests / confined (shellcheck) (pull_request_target) Successful in 1m9s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (remote) (pull_request_target) Successful in 2m27s
tests / confined (project) (pull_request_target) Successful in 2m51s
tests / confined (audit) (pull_request_target) Successful in 3m12s
tests / confined (static) (pull_request_target) Successful in 2m42s
tests / confined (config) (pull_request_target) Successful in 4m22s
tests / privileged (pull_request_target) Successful in 3m37s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
will not pretend otherwise

The compatibility table said `anthropic` x `cline` and stopped there, which
is true of the dialects and not of the answer a session gets. A Claude
subscription's OAuth credential is served to Claude Code and refused to
other clients per model: claude-haiku-4-5 is served, claude-opus-5 and
claude-sonnet-5 are not, while an otherwise identical request from Claude
Code succeeds.

The refusal is a 429 rate_limit_error whose body message is the word
Error, so from inside it is indistinguishable from a quota problem - an
account nowhere near any limit still has every request refused. That is
worth documenting on its own: it cost an afternoon of reading usage graphs
before an experiment settled it.

The table itself is unchanged and stays derived - it answers whether dejima
will pair the two, which is a question about api_style. Whether the
endpoint then serves the request is the endpoint's to answer, and now has
a section of its own beside the table rather than a hedge inside it. The
matrix test keeps checking the cells against the manifests, which is what
it is for.

R5's "confirmed end to end" is qualified rather than withdrawn: the
arrangement is confirmed - placeholder in, real credential injected
host-side, a real completion out - and which models the credential will
serve a client that is not Claude Code is a separate question it never
asked.

And the non-goal, in responsible-use.md where this project's positions
live: dejima will not make one harness present itself as another to get
past that gate. It would forge a product's identity against the operator's
own account, on a control the provider put there deliberately. It would be
brittle in the worst way - working until the day it does not, and that day
being a question about someone's account rather than a bug report. And it
would contradict what the rest of the project spends its effort on: the
credential proxy forwards the harness's own request so that what the API
sees is true, down to declining to send a header the client did not.

The supported answers are both real ones - a credential that is yours to
spend for another harness, or the harness the subscription was issued for.
2026-09-03 14:37:25 -07:00
branderson ec1b662429 Log the request id, because the body turned out to say nothing
tests / confined (proxy) (pull_request_target) Successful in 59s
tests / confined (conversation) (pull_request_target) Successful in 1m0s
tests / confined (providers) (pull_request_target) Successful in 1m41s
tests / confined (share) (pull_request_target) Successful in 1m40s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m22s
tests / confined (project) (pull_request_target) Successful in 2m46s
tests / confined (audit) (pull_request_target) Successful in 3m7s
tests / confined (static) (pull_request_target) Successful in 2m36s
tests / confined (config) (pull_request_target) Successful in 4m15s
tests / privileged (pull_request_target) Successful in 3m37s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
The decoded 429 reads, in full:

    {"type":"error","error":{"type":"rate_limit_error","message":"Error"}}

The word Error and nothing else. So on the response this whole thread of
changes was chasing, the body is not where the information is - the
headers are, and a reader should not have to know to go looking for them.

request-id is what Anthropic support asks for and is the only thing on
that response that identifies it from the other side; retry-after is the
only thing that says how long. Both are logged when present.

Deliberately not the account and workspace ids the same response carries.
They name the operator's organisation, and this line is written to be
pasted into a bug report - request-id is enough to find the request
without carrying who made it. A test asserts they stay out, and fails when
one is added back.

The fixture gained all four headers, so the assertion that two are kept
and two are dropped has something to be true about.
2026-09-03 13:59:08 -07:00
branderson 9007e34c20 Read brotli where the host can, and say what to install where it cannot
tests / confined (conversation) (pull_request_target) Successful in 55s
tests / confined (proxy) (pull_request_target) Successful in 1m2s
tests / confined (share) (pull_request_target) Successful in 1m42s
tests / confined (providers) (pull_request_target) Successful in 1m44s
tests / confined (shellcheck) (pull_request_target) Successful in 1m7s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (remote) (pull_request_target) Successful in 2m20s
tests / confined (project) (pull_request_target) Successful in 2m44s
tests / confined (audit) (pull_request_target) Successful in 3m4s
tests / confined (static) (pull_request_target) Successful in 2m37s
tests / confined (config) (pull_request_target) Successful in 4m12s
tests / privileged (pull_request_target) Successful in 3m54s
tests / runner-env (privileged) (pull_request_target) Successful in 43s
gzip was the guess; the real answer came back br. So the encoding this
could not read was the common case rather than an edge - brotli is what a
Node client negotiates when offered, and cline offers it.

Decoded through brotli or brotlicffi when either is importable, using the
incremental decoder in both, since what this reads is a bounded prefix
rather than a whole stream. Optional on purpose: the proxy runs on the
host as the operator's user, and a diagnostic is not worth a hard
dependency on a module Debian packages separately. Absent, the line names
the encoding, the size, and the two commands that would fix it - a note an
operator can act on rather than one that only explains why they cannot.

Not solved by asking the API for an encoding this can read. The request it
sees is deliberately the harness's own header block, byte for byte, and
Accept-Encoding is part of what makes a session look like a stock install
- there is a comment about not sending CPython's own `identity` tell for
exactly that reason. Rewriting it so a log line is easier to read would
trade a property the sandbox exists for against a convenience.

The test asserts whichever half the host can demonstrate, and requires the
absent branch to name the fix rather than only the problem. Both branches
still produce no unprintable bytes, which is the invariant that does not
depend on any of this.
2026-09-03 13:41:24 -07:00
branderson 3f4d6f217d Decompress the rejection before logging it, and never log bytes
tests / confined (conversation) (pull_request_target) Successful in 58s
tests / confined (proxy) (pull_request_target) Successful in 1m2s
tests / confined (providers) (pull_request_target) Successful in 1m45s
tests / confined (share) (pull_request_target) Successful in 1m44s
tests / confined (shellcheck) (pull_request_target) Successful in 1m9s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (remote) (pull_request_target) Successful in 2m25s
tests / confined (project) (pull_request_target) Successful in 2m48s
tests / confined (audit) (pull_request_target) Successful in 3m9s
tests / confined (static) (pull_request_target) Successful in 2m45s
tests / confined (config) (pull_request_target) Successful in 4m20s
tests / privileged (pull_request_target) Successful in 3m53s
tests / runner-env (privileged) (pull_request_target) Successful in 42s
The logging reached a real 429 and put this on the operator's terminal:

    dejima: line 1333: warning: command substitution: ignored null byte in input
    ...f0303U=....$..&.....o.IB..b..W.H,%..S..an,....;.G..2...f..m2.t.L/jz5..

The client's own Accept-Encoding is forwarded verbatim - deliberately, so
the request the API sees is the harness's own - and every Node client asks
for gzip. So the error body came back compressed and the prefix read for
the log was compressed bytes, written out raw. Which is also why the
message that would have explained the 429 was unreadable: the thing added
to answer the question was the thing hiding the answer.

Two changes, and the second is the one that matters. gzip and deflate are
decompressed with decompressobj, which works on the bounded prefix that is
all this reads; br and zstd have no stdlib decoder and are named rather
than printed. Then, whatever the encoding, only printable characters reach
the file - unconditionally, not as a consequence of recognising the
encoding.

That second half is an invariant this had no business breaking in the
first place. dejima-audit hex-encodes control characters in a finding
precisely because they scroll, blank and reorder a terminal, and an
upstream error body is no more trustworthy than a session-authored path.
An unknown encoding now degrades to a note about its size rather than to
bytes.

Control characters become spaces rather than being dropped, so a message
cannot be silently re-joined into a different one.

The fixture grew the shape this actually met: gzip, chunked, retry-after,
against a client that asked for gzip. The previous one answered plain, so
it could not have caught this - the same way the one before it answered
with a Content-Length and could not catch the chunked case.
2026-09-03 13:13:00 -07:00
branderson d751f53e68 Read the rejection whatever its framing, and stop calling a 429 a refusal
tests / confined (conversation) (pull_request_target) Successful in 57s
tests / confined (proxy) (pull_request_target) Successful in 1m2s
tests / confined (share) (pull_request_target) Successful in 1m49s
tests / confined (providers) (pull_request_target) Successful in 1m54s
tests / confined (shellcheck) (pull_request_target) Successful in 1m12s
tests / runner-env (confined) (pull_request_target) Successful in 45s
tests / confined (remote) (pull_request_target) Successful in 2m32s
tests / confined (project) (pull_request_target) Successful in 2m57s
tests / confined (audit) (pull_request_target) Successful in 3m16s
tests / confined (static) (pull_request_target) Successful in 2m47s
tests / confined (config) (pull_request_target) Successful in 4m20s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / privileged (pull_request_target) Failing after 2h14m16s
The logging added last change met its first real rejection and recorded
half of it:

    12:07:25 dejima-credential-proxy: upstream answered 429 for /v1/messages
    12:07:28 dejima-credential-proxy: upstream answered 429 for /v1/messages
    12:07:32 dejima-credential-proxy: upstream answered 429 for /v1/messages

Three status codes and nothing to act on. The body was read only where
upstream declared a small Content-Length, and a rate limiter answers
chunked - so the one thing that says which limit was hit, and the
retry-after that says for how long, were both dropped.

It reads a bounded prefix whatever the framing now, with read1, which
returns what has arrived rather than waiting for a buffer. That costs one
chunk, is written out before the stream picks up where it left off, and
retry-after is named on its own rather than left among the headers for a
reader to spot.

The summary called every one of those a refusal. A 429 is a client being
asked to wait, and cline retried through these - the session may well have
got its answer. It groups by status now, prints the most recent line
whole, and says plainly that a retried status is not a verdict on the
session. Grouping is the point rather than tidiness: one condition a
client retries becomes several identical lines, and a list of them says no
more than a count does.

The fixture grew a rate limiter's actual shape - chunked, no
Content-Length, retry-after - because the previous one answered with a
length and so could not have caught this.

One incidental: the grouping awk matches on the whole line rather than
looping over fields. A loop needs an index, and an awk index inside single
quotes is indistinguishable to the test that every name cleanup() uses is
bound before the EXIT trap fires. That test is right to be strict; it also
caught the index in the comment explaining this, which is the correct
amount of strict.
2026-09-03 12:45:57 -07:00
branderson 288d28a210 Merge pull request 'Say what the API refused, instead of forwarding a rejection in silence' (#316) from proxy-log into development
Reviewed-on: #316
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 11:57:14 -07:00
branderson 1e4d28021e Say what the API refused, instead of forwarding a rejection in silence
tests / confined (conversation) (pull_request_target) Successful in 59s
tests / confined (proxy) (pull_request_target) Successful in 1m11s
tests / confined (share) (pull_request_target) Successful in 2m2s
tests / confined (providers) (pull_request_target) Successful in 2m6s
tests / confined (shellcheck) (pull_request_target) Successful in 1m13s
tests / runner-env (confined) (pull_request_target) Successful in 46s
tests / confined (remote) (pull_request_target) Successful in 2m50s
tests / confined (project) (pull_request_target) Successful in 3m21s
tests / privileged (pull_request_target) Successful in 3m26s
tests / confined (audit) (pull_request_target) Successful in 3m42s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Successful in 3m9s
tests / confined (config) (pull_request_target) Successful in 4m48s
Reported from a real launch: cline against the anthropic backend started,
listed models, ran a prompt, spun, and errored. No logs anywhere to act on.

The proxy is the only thing that sees both the request the session made and
the answer the API gave, and it forwarded a non-2xx verbatim and recorded
nothing. What the harness then shows is the harness's business, and for
that session it was nothing an operator could use. Every other part of this
repo says what happened; this path said nothing for exactly the case
someone is trying to debug.

It records the status, the path and the API's own message now. Only for a
rejection: logging a successful request would put the operator's traffic in
a file, and reading the body to do it would buffer a streaming response.
The body is read whole only where upstream declared a small Content-Length,
and forwarded byte for byte afterwards, so what the session sees is
unchanged - a streaming or oversized rejection is recorded by status alone
rather than buffered here.

To a file, not stderr. The proxy inherits bin/dejima's stderr, which is the
operator's terminal, and the session owns it while it runs: a line written
mid-request lands in the middle of whatever the harness is drawing.
bin/dejima reads it back during cleanup, before the temp directory goes and
before the exit audit, which stays the last thing on the terminal. With no
--log it still goes to stderr, so a proxy run by hand is not the case with
no diagnostics.
2026-09-03 11:45:07 -07:00
branderson 9c609f344d Merge pull request 'Say that a release pass ends at the report, not at the release PR' (#314) from release-pass-scope into development
Reviewed-on: #314
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 02:39:21 -07:00
branderson 49f68f4066 Say that a release pass ends at the report, not at the release PR
tests / confined (conversation) (pull_request_target) Successful in 53s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (share) (pull_request_target) Successful in 1m35s
tests / confined (providers) (pull_request_target) Successful in 1m44s
tests / confined (shellcheck) (pull_request_target) Successful in 1m9s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m12s
tests / confined (project) (pull_request_target) Successful in 2m36s
tests / confined (audit) (pull_request_target) Successful in 3m6s
tests / confined (static) (pull_request_target) Successful in 2m40s
tests / privileged (pull_request_target) Successful in 3m23s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (config) (pull_request_target) Successful in 3m50s
An agent that has just run the pass holds everything the release PR is made of, so the instructions now say where the pass stops: the report is the deliverable, the release PR is never opened unasked, and the sanctioned next move is to prompt the user to create it.
2026-09-03 02:23:41 -07:00
branderson ecd683b192 Merge pull request 'Pin dejima's defaults per machine, so a release cannot move a setting you never chose' (#313) from pinned-defaults into development
Reviewed-on: #313
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 02:14:21 -07:00
branderson 2954afda34 Say which file is whose, and leave --print-config's format alone
tests / confined (conversation) (pull_request_target) Successful in 59s
tests / confined (proxy) (pull_request_target) Successful in 59s
tests / confined (share) (pull_request_target) Successful in 1m43s
tests / confined (providers) (pull_request_target) Successful in 1m46s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (remote) (pull_request_target) Successful in 2m26s
tests / confined (project) (pull_request_target) Successful in 2m50s
tests / confined (audit) (pull_request_target) Successful in 3m11s
tests / privileged (pull_request_target) Successful in 3m24s
tests / confined (static) (pull_request_target) Successful in 2m39s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (config) (pull_request_target) Successful in 4m17s
Documents the two files and the property they buy, in the reference and in
the README's first-run section: your config is yours and dejima never
writes it, defaults.conf beside it is dejima's and holds what its defaults
were when this machine first saw each key.

`required` gets a second, mechanical reading - it is exactly the set with
no line in defaults.conf, because a key with no default has nothing to
pin. The document and the file agree without anyone keeping them in step.

config_origin says which of the three layers answered, and --print-config
does NOT print it. Appending it as a second column broke eleven callers in
this repo, all using `grep -qx key=value`, which is the obvious way to read
that output and evidence that more exist outside it. A format with callers
is one to extend deliberately rather than in passing, and the question it
answers is already readable from the files: in config it is yours, in
defaults.conf it is pinned, in neither it is what this release ships.
2026-09-03 02:09:29 -07:00
branderson 0c5ae0e371 Rebuild the pinned defaults on install, and name what has moved
install.sh rewrites defaults.conf: keys this release added appended, keys
it retired dropped, every value already decided left exactly where it is.
Both halves fall out of rendering with "keep" rather than being two rules
someone has to remember.

Pruning is not the tidier half. An unknown key there is one this dejima
cannot read, so a file that only ever grew would collect them until the
launch notice about it became permanent.

A shipped default that no longer matches the pinned one is named here and
nowhere else, because here is when it moves - you pulled and re-ran this.
Both values are printed and neither is taken: taking one would be dejima
writing a change to a session's behaviour into an upgrade, which is what
this exists to stop. The line to adopt it goes in the operator's own
config, never in the file the next rebuild rewrites.

The starter config stops writing every key commented out at its default.
That was a second copy of the defaults, in comments nothing regenerates,
drifting against the schema as keys were added; defaults.conf beside it is
that reference now, complete and rewritten every install. What is left is
what was actually seeded - the selection and any legacy variables still
exported - with each section's header written by the first key that lands
under it, so a section nothing seeded leaves no empty stanza.
2026-09-03 01:29:49 -07:00
branderson 2110b9bc16 Pin the defaults on the first launch, and say so once
Nothing wrote defaults.conf, so the layer read in the last commit was
always empty and the schema always answered. A launch writes it when it is
absent.

dejima creating its own file is not the write this arrangement removes -
it is the opposite of one. Doing it here rather than only in install.sh is
what makes the property hold for an operator who never ran the installer,
and dejima runs perfectly well from a checkout without it.

A launch that finds the file does nothing further: no rebuild, no prune,
no drift check. Those belong to install.sh, which is when the shipped
defaults actually change, and doing them per launch would put a standing
condition in front of every one - the failure the orphaned-share list
already demonstrates.

Every installed provider's keys, not only the selected one. Pinning just
the harness in use leaves the next one an operator selects handing them
ITS current defaults, which is the same failure one axis over and a year
later. Keys with no default are absent, because there is nothing to pin:
those are exactly the ones docs/configuration.md marks required, so the
file and the document agree by construction.

After --print-config's exit, deliberately. That flag is what an operator
runs to inspect their configuration, and a query that creates a file as a
side effect is not one. It also kept a test honest, which is how the
ordering got noticed: --print-config emits one line per schema key, and
the announcement was an extra one.

A retired key in the file is named once, with ./install.sh, and the launch
continues on the schema's answer.
2026-09-03 01:08:36 -07:00
branderson 7051a01cf1 Read a pinned defaults layer between the operator's config and the schema
[core] harness has no default because a default that could change later is
a config file that is not the source of truth. That argument is not
special to harness: it holds for every defaulted key, and there is a
recorded instance of the failure - the [core] forge and workflow defaults
changed from gitea+worktree-pr to none+local-only, silently stopping
existing sandboxes pushing. install.sh still carries the note.

So a key resolves through three layers: the operator's config, then
defaults.conf, then the schema. The schema is reachable only for a key
defaults.conf has no line for - a fresh machine, or a key added since the
last ./install.sh - which is the one case where dejima's current answer is
the right one. Nothing writes defaults.conf yet; that is the next commit.

It lives beside the operator's file rather than under ~/.local/share,
because it explains the file next to it and the answer to "why is this
value what it is" belongs where someone would look for it.

The pinned layer gets its own two arrays rather than being seeded into the
operator's, and that is the whole load-bearing decision here.
config_is_set has ten callers and every one means "did the operator write
this" - the [core] ssh_key / [identity.default] ssh_key refusal fires only
when both are present, an identity's forge keys take precedence only when
it names one, and the ttyd knobs read unset as "the page decides from the
pointer type". A file naming every key would answer yes to all of them.

Its parse is lenient where the operator's is strict, line by line rather
than wholesale. An unknown key there is one dejima RETIRED, on a machine
whose defaults.conf predates the release that dropped it: the operator did
not write it and is told not to edit the file, so refusing would leave
them a launch that will not start and nothing to do about it. The schema
answers for whatever is skipped and the count is kept for the launch to
name once.

Both parsers stopped clearing their own arrays; config_validate clears all
four before either runs. That is not tidying: with the old shape, seeding
the pinned layer into the operator's arrays was a bug the following parse
happened to wipe, so the test for it passed against the mutation. It
fails now.
2026-09-03 00:47:13 -07:00
branderson dff6a40dd1 Merge pull request 'One table per config section, in the order an operator fills them in' (#312) from config-ref into development
Reviewed-on: #312
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 00:42:03 -07:00
branderson 642fcd15b3 Merge pull request 'Say what decides a scroll step, rather than sending fewer of them' (#309) from scroll-step into development
Reviewed-on: #309
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 22:57:46 -07:00
branderson 79c8c25983 Merge pull request 'Guard the selected harness's config, and name the proxy's machinery after the proxy' (#308) from mounts-guard into development
Reviewed-on: #308
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 21:08:48 -07:00
branderson 757360a742 Name the binding that was actually doing it, and how to find yours
tests / confined (conversation) (pull_request_target) Successful in 59s
tests / confined (proxy) (pull_request_target) Successful in 1m5s
tests / confined (project) (pull_request_target) Successful in 2m59s
tests / confined (share) (pull_request_target) Successful in 1m31s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (audit) (pull_request_target) Successful in 3m48s
tests / confined (config) (pull_request_target) Successful in 4m38s
tests / confined (static) (pull_request_target) Successful in 2m34s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / confined (providers) (pull_request_target) Successful in 1m51s
tests / confined (shellcheck) (pull_request_target) Successful in 1m11s
tests / confined (remote) (pull_request_target) Successful in 2m19s
tests / privileged (pull_request_target) Successful in 3m33s
The section said stock tmux, which scrolls five lines. The configuration
that prompted this bound halfpage-up instead - half the pane, so twenty
lines in a forty-line one, which is what "more than half the screen" was.

Both are worth naming, and neither is worth guessing at from here: the
first line of the section is now the list-keys that answers it. The
example scrolls four rather than one, which is what the reported fix
settled on and reads better on a desktop sharing the same server.
2026-09-02 19:02:44 -07:00
branderson 62eea1002d Revert the scroll step, and name what actually decides one
tests / confined (conversation) (pull_request_target) Successful in 1m0s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (providers) (pull_request_target) Successful in 1m56s
tests / confined (project) (pull_request_target) Successful in 3m3s
tests / confined (share) (pull_request_target) Successful in 1m34s
tests / confined (shellcheck) (pull_request_target) Successful in 1m9s
tests / runner-env (confined) (pull_request_target) Successful in 45s
tests / confined (audit) (pull_request_target) Successful in 4m12s
tests / confined (remote) (pull_request_target) Successful in 2m18s
tests / privileged (pull_request_target) Successful in 3m25s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Successful in 3m37s
tests / confined (config) (pull_request_target) Successful in 6m41s
Reverts "Make a scroll notch cost more finger travel where a notch is
worth more" and its doc commit.

The diagnosis was half right. A notch is worth several rows to whoever
receives it, so one notch per row did outrun the finger - but sending
fewer notches trades a fast scroll for a stiff one and leaves each jump
exactly as big. On a phone that jump is what matters: five lines is more
than half the screen, and reported as still needing several tries to place
a line after the change.

Confirmed by asking where the notch lands: the pane enters copy mode with
a position counter, so tmux is receiving it and the five is tmux's own
binding.

Nothing in dejima can make that smaller. The page chooses how many notches
to send and nothing else, and `dejima-attach` joins the tmux session the
operator launched from, whose configuration is theirs. So the answer is
four lines in their ~/.tmux.conf, and docs/remote-access.md now carries
them - both key tables, since which is active follows mode-keys - along
with why this is not something dejima sets on their behalf.

web/test/gestures.test.js stays, rewritten for the behaviour that is back:
one notch per row of travel on both paths, nothing for travel shorter than
a row, and the direction. The module had no test file at all before, and
the reverted change is not why it wanted one.
2026-09-02 18:35:31 -07:00
branderson 53da70aae9 Point the shared-directory section at what reaching it remotely costs
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (static) (pull_request_target) Successful in 2m9s
tests / confined (audit) (pull_request_target) Successful in 4m11s
tests / confined (config) (pull_request_target) Successful in 6m46s
tests / confined (conversation) (pull_request_target) Successful in 51s
tests / confined (project) (pull_request_target) Successful in 2m52s
tests / confined (providers) (pull_request_target) Successful in 1m48s
tests / confined (proxy) (pull_request_target) Successful in 58s
tests / confined (remote) (pull_request_target) Successful in 2m19s
tests / confined (share) (pull_request_target) Successful in 1m36s
tests / privileged (pull_request_target) Successful in 3m30s
tests / runner-env (confined) (pull_request_target) Successful in 46s
remote-access.md's "Mounting the box's files" is how you get at a project's
share from another machine, and it already links back here for the
clear-it-out advice. The other direction was missing, so an operator
reading about ~/shared had no way to reach the warning that a desktop
indexes, thumbnails and previews everything a sandboxed session wrote
there, with nothing of dejima's in the path.
2026-09-02 18:35:30 -07:00
branderson 314b711b6c Say what a scroll notch is worth, next to the setting that changes it
The knob is meaningless without the reason it exists, which is that who
receives a notch decides what it costs: one row when the page scrolls its
own scrollback, five when stock tmux is in copy mode.
2026-09-02 18:35:30 -07:00
branderson 14a91be8f1 Name the credential proxy's own machinery after the proxy, not after Anthropic
SANDBOX_ANTHROPIC_PROXY_SOCKET, _PROXY_PID, _PROXY_DIR, _PROXY_PORT,
_TLS_CERT, _TLS_KEY and _TRANSPORT describe a unix socket, a forwarder
process, a self-signed cert and a choice between TLS and loopback. None of
that is Anthropic's; the terminator answers for whichever backend's
proxied_domain was selected, and openrouter goes through the same code.
They are SANDBOX_CREDENTIAL_* now, and /etc/dejima-anthropic-tls inside the
container is /etc/dejima-credential-tls for the same reason.

Three keep the old name, and that is the interesting half. BASE_URL,
AUTH_TOKEN and API_KEY each carry a value into the ANTHROPIC_ variable
beside it in docker-compose.yml, which is what Claude Code reads. Renaming
those would name them after the thing they are not. Said at the
declaration so nobody finishes the rename by mistake.

SANDBOX_ANTHROPIC_TRANSPORT keeps its old name in the two places it is the
operator's rather than dejima's: install.sh seeds [credentials] transport
from it on a first install, and _warn_legacy_env names it as retired. Both
read what is in someone's shell profile, and that string did not change.
The rename removes the collision those had with dejima's own variable of
the same name.

A stub in config.bats filtered docker's environment to ^SANDBOX_ANTHROPIC,
so the renamed socket variable stopped appearing in what two credential
tests assert on. It takes both prefixes now.
2026-09-02 18:35:30 -07:00
branderson b497353fc6 Make a scroll notch cost more finger travel where a notch is worth more
The terminal page sent one wheel notch per row of finger travel. That
tracks the finger exactly while the page is scrolling its own scrollback,
because a notch moves one row there. It does not when an application is
reading the mouse: stock tmux binds copy-mode's WheelUpPane to
`send -X -N 5 scroll-up`, and a full-screen application picks its own
figure. So a small movement of a thumb threw the screen several rows, and
putting a particular line at the bottom took several tries.

[remote-access] ttyd_scroll_step is how many rows the finger travels per
notch, default 5, and it multiplies only on the path where a notch is
worth more than a row. Applying it to both would make the scrollback
sluggish to fix something that is not wrong there.

The odd one out among the -t knobs: unset does not mean "send nothing".
The page has to pick some travel per notch either way, so the schema
carries a real default and the page is told it rather than keeping a
second copy in step. ?scroll=N overrides per client, which matters here
more than for the others because the right number depends on what is
running in the session rather than on the host.

A step of zero or less is refused twice - by the schema's 1-40 bound and
again in the page - because scrollBy consumes accumulated travel in a
while loop keyed on it, so zero spins forever on the first drag.

gestures.js had no test file. It has one now, driving the real touch
handlers through a stub element: the scrollback path unchanged at any
step, an application path at 5, the old behaviour still reachable at 1,
and travel shorter than one step sending nothing rather than rounding up.
Removing the multiplier fails three of them.

Also checked in chromium against the built page, with a stub WebSocket
speaking ttyd's frames: a 20-row drag with SGR reporting on sends 4
notches by default and 20 at scrollStep=1, a server value arrives through
SET_PREFERENCES, and ?scroll=2 beats a server value of 10.
2026-09-02 18:35:30 -07:00
branderson af480bbf95 Say what the read_write refusal actually covers
The list in configuration.md named ~/.claude and stopped, which was the
same list bin/dejima had. It also left out the epitaph store and the share
root, both of which have been guarded for a while.
2026-09-02 18:35:30 -07:00
branderson 476c0e3e82 Guard the selected harness's own config, not the one harness named in a list
[mounts] read_write refused a writable mount overlapping $HOME/.claude, by
name. Cline keeps its configuration in ~/.agents and ~/.cline and its state
under ~/.local/share/dejima/cline, none of which were in that list - so
read_write = ~/.agents was accepted, handing a session write access to the
hooks and skills the operator's own machine runs next time. That is the
persistence this refusal exists to stop, and the docs already claimed it
was stopped.

The list is derived now. Every host path the selected harness's plugin
names, in all three declaration tiers, plus every `path`-typed key in its
manifest - so the state directories are covered without this knowing what
a given harness calls them, and a harness landing later is guarded on the
day it lands rather than when someone remembers.

Read-only tiers count too. A read-only mount protects the host's file from
the session that has it; it says nothing about a writable mount an
operator points at the same file, and what matters here is only that the
harness reads it on the host.

A staged or seeded entry contributes its host source AND $HOME/<rel>, the
path it is installed at. The two differ when the operator keeps the file
elsewhere, and the rel is named whether or not the file exists, which the
source is not - emit_seed_from drops an absent source. That is what still
covers ~/.claude/plugins, which is reached only through a seed
destination and which the literal used to cover.

The three loads move up to where the guard is, because a guard that runs
after the mount it would refuse is not one. They were already idempotent;
their consumers further down now read the arrays rather than reloading.

Tested by launch rather than by reading the list: one case per way a path
reaches it, under both shipped harnesses, plus a mount outside the
harness's config that must still be accepted - refusing everything would
pass the first test on its own. Each of the three arms was removed in
turn and each failed on a different path.
2026-09-02 18:35:30 -07:00
branderson 237653320f One table per config section, in the order an operator fills them in
tests / confined (config) (pull_request_target) Successful in 5m3s
tests / confined (audit) (pull_request_target) Successful in 4m14s
tests / confined (conversation) (pull_request_target) Successful in 1m3s
tests / confined (project) (pull_request_target) Successful in 3m5s
tests / confined (providers) (pull_request_target) Successful in 1m54s
tests / confined (proxy) (pull_request_target) Successful in 1m8s
tests / confined (remote) (pull_request_target) Successful in 2m11s
tests / confined (share) (pull_request_target) Successful in 1m34s
tests / confined (shellcheck) (pull_request_target) Successful in 1m9s
tests / runner-env (confined) (pull_request_target) Successful in 45s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (static) (pull_request_target) Successful in 2m39s
tests / privileged (pull_request_target) Successful in 3m29s
Seventy rows in one table, in the order keys were added. [harness.cline]
was split across two places with the backends interleaved between them,
[ui] sat inside [core], [logging] was split around [epitaph], and nothing
distinguished a key you must set from one with a default.

It is a table per section now, each under a heading naming that section so
a row can say `harness` rather than `[core] harness`. Sections are ordered
by what a launch depends on: the three [core] selections first, then the
harness they select, then its backend, then the credential proxy, then who
the session acts as, then the forge, then the surfaces that are off until
switched on.

`required` in the Default column marks what an operator supplies. Exactly
one key is required unconditionally, and the intro says so - every other
one is conditional on a selection, and the section it sits under is what
names the condition.

[identity.<name>] gains rows at all. Its keys are computed - three of
dejima's own plus each forge's identity_keys - so they were in the schema,
settable, and documented only in a prose section further down.

The test that pins the reference against the schema follows the structure
rather than dictating it: a bare key under a heading naming its section
counts, as does a fully qualified one in the tables that cover several
sections at once. It now requires a table ROW in both cases. Matching the
key anywhere in the file let it pass with the key documented only in a
paragraph three sections away - which is the discoverability problem it
exists to catch, not an acceptable answer to it. Dropping a row for
[harness.cline] context_window passed before that and fails now.
2026-09-02 18:27:34 -07:00
branderson 4edf40f2fa Merge pull request 'Stop reporting the guards as off because they are no longer on the host' (#310) from guards-check into development
Reviewed-on: #310
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 18:25:20 -07:00
branderson def800d1be Merge pull request 'Let a model be named the way the endpoint that serves it names one' (#311) from model-id into development
Reviewed-on: #311
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 18:21:50 -07:00
branderson 103ef3f9c5 Let a model be named the way the endpoint that serves it names one
tests / confined (conversation) (pull_request_target) Successful in 51s
tests / confined (proxy) (pull_request_target) Successful in 53s
tests / confined (providers) (pull_request_target) Successful in 1m34s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (project) (pull_request_target) Successful in 2m23s
tests / confined (audit) (pull_request_target) Successful in 2m52s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (share) (pull_request_target) Successful in 6m22s
tests / confined (remote) (pull_request_target) Successful in 1m59s
tests / confined (static) (pull_request_target) Successful in 2m31s
tests / privileged (pull_request_target) Successful in 3m22s
tests / confined (config) (pull_request_target) Successful in 3m37s
[harness.cline] model was typed `identifier`, which is [A-Za-z0-9._-].
Every hosted catalogue namespaces by vendor with a slash and several
qualify a variant with a colon, so `z-ai/glm-5.3` was refused and not one
OpenRouter id could be configured at all - the backend this harness exists
to reach was unusable, and the launch said only "invalid value" without
saying which character it objected to.

A `modelid` type instead: a leading alphanumeric, then the identifier set
plus `/` and `:`. Still narrow, and for the reason `path` is rather than
for shell safety - the value is written with jq --arg, so JSON escaping is
not the concern. What it protects is that a newline or a tab would split a
record every reader of this config expects to be one line.

Every existing test said `model = m`, which is a bare identifier and
passes under either type. That is why nothing caught it. The new ones
configure real catalogue ids and check what lands in providers.json, not
just that the validator was happy - reverting the type fails them with the
exact error reported, and widening the type to accept anything fails the
half that keeps a space out.
2026-09-02 18:08:52 -07:00
branderson 3f3ddb782b Stop reporting the guards as off because they are no longer on the host
tests / confined (conversation) (pull_request_target) Successful in 49s
tests / confined (proxy) (pull_request_target) Successful in 1m0s
tests / confined (share) (pull_request_target) Successful in 1m32s
tests / confined (providers) (pull_request_target) Successful in 1m45s
tests / confined (shellcheck) (pull_request_target) Successful in 1m6s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (remote) (pull_request_target) Successful in 2m8s
tests / confined (project) (pull_request_target) Successful in 2m31s
tests / confined (audit) (pull_request_target) Successful in 3m0s
tests / confined (static) (pull_request_target) Successful in 2m34s
tests / privileged (pull_request_target) Successful in 3m26s
tests / confined (config) (pull_request_target) Successful in 3m36s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
Moving the hook registrations into dejima's own settings overlay left
harness_guards_registered reading the operator's ~/.claude/settings.json
and naming everything it did not find there. Nothing writes there any
more, so on a host that never ran the old installer every launch printed

  not registered for claude-code, so not in force this session:
    the session briefing
    push-guard
    merge-guard
    bash-deny
  Run ./install.sh to register them.

with all four in force, in the overlay, at their baked paths. Following
the instruction makes it permanent: install.sh's upgrade cleanup strips
exactly the registrations the check is looking for.

The check is deleted rather than repointed. What the overlay carries is
composed fresh each launch from the manifest, so reading it back would be
asking dejima whether it did what it just did; which guards a harness
cannot host at all is the manifest's, and harness_report_guards already
says it. The hook stays in the plugin interface - what a harness needs
wired on the host is a real question a future one may answer differently.

Three tests went with it, and their being green is why this shipped. All
three asserted the notice against the operator's settings.json - one that
it fires when nothing is registered there, one that it names a single
hand-dropped guard, one that it stays quiet when all four are present.
Every one of them still passed after the move, because the notice did
still fire; what changed was that firing had become wrong. Tests that
pinned where the guards used to live outlived the guards.

What replaces them asks the question the launch actually answers: with
settings.json present and dejima nowhere in it - the state a correct
install now leaves - a launch must not claim the guards are off, and the
overlay must carry all four. The second half is not optional: without it
the first passes on a launch that registered nothing anywhere.
2026-09-02 17:39:22 -07:00
branderson ae0f31079e Merge pull request 'Answer the microVM question in the threat model, where it can be contradicted' (#307) from why-not-vm into development
Reviewed-on: #307
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 17:06:10 -07:00
branderson 1e35c5c90a Merge pull request 'Say harness where the doc means harness, and backend where it means backend' (#306) from docs-prose into development
Reviewed-on: #306
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 16:47:46 -07:00
branderson bd0d5f41d0 Merge pull request 'Register the sandbox's hooks in its own settings layer, not the operator's' (#304) from hooks-overlay into development
Reviewed-on: #304
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 16:46:31 -07:00
branderson 698020693d Cut the microVM section to what a reader deciding whether to trust it needs
tests / confined (audit) (pull_request_target) Successful in 4m29s
tests / confined (config) (pull_request_target) Successful in 6m28s
tests / confined (conversation) (pull_request_target) Successful in 51s
tests / confined (project) (pull_request_target) Successful in 3m2s
tests / confined (providers) (pull_request_target) Successful in 1m56s
tests / confined (proxy) (pull_request_target) Successful in 1m4s
tests / confined (remote) (pull_request_target) Successful in 2m25s
tests / confined (share) (pull_request_target) Successful in 1m35s
tests / confined (shellcheck) (pull_request_target) Successful in 1m14s
tests / privileged (pull_request_target) Successful in 3m46s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Successful in 2m12s
It was the longest section in the file and buried its answer. Most readers
bounce before the end of a section, and this one's audience is someone
wondering whether the container choice was considered - not someone planning a
port. So the whole argument is now in the first two hundred words, and the
detail that only serves a port is gone: what virtiofs would cost per mount,
what a vsock forwarder would be for, which of Docker's verbs the tooling uses,
and gVisor's odds against the browser in the image.

What survives is what the question is actually about. A microVM is the stronger
boundary and this does not dispute it. Escape is the least likely of the six
and the only one needing an exploit. There is already a VM here, hardened from
outside the guest, which is where the effort went. And the three things that
would reopen the decision, so it reads as a judgement rather than a door held
shut.

#104 and #305 are links now rather than bare numbers: a reader who wants the
detail this section stopped carrying should be one click from it.

The README callout goes in the stack section rather than the docs table,
because that is where a reader is standing when the question occurs to them,
and an answer nobody finds gets asked as a question instead.
2026-09-02 14:13:10 -07:00
branderson 5d16b273be Answer the microVM question in the threat model, where it can be contradicted
tests / confined (conversation) (pull_request_target) Successful in 51s
tests / confined (share) (pull_request_target) Successful in 1m29s
tests / runner-env (confined) (pull_request_target) Successful in 45s
tests / confined (static) (pull_request_target) Successful in 2m38s
tests / privileged (pull_request_target) Successful in 3m29s
tests / confined (config) (pull_request_target) Successful in 3m48s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (proxy) (pull_request_target) Successful in 59s
tests / confined (providers) (pull_request_target) Successful in 1m44s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / confined (remote) (pull_request_target) Successful in 2m10s
tests / confined (project) (pull_request_target) Successful in 2m34s
tests / confined (audit) (pull_request_target) Successful in 3m5s
The most frequently asked question about this project, and the docs answered
only half of it. "Which deployment this describes" concedes the field's
tiering and says escape moves a layer; it does not say why the effort went
below the container rather than into a stronger one, so the answer lived in
conversations rather than anywhere a reader or a sweep could reach it.

Written as an argument that can lose. It concedes the ordering outright rather
than defending a container as the better primitive, names what gets harder
under a VM as well as the one thing that gets easier, and ends with four things
that would change the answer - Kata as the cheap path, gVisor as a spike,
dropping the trusted-project assumption, and a live escape clearing this
configuration's floor.

The point the field's framing usually misses is that there is already a VM
here. The choice is not one boundary against two: it is an outer VM enforced
from the hypervisor, against that plus an inner one managed by software running
inside the blast radius of the row it is meant to bound.

static.bats pins the concession rather than the prose, because the failure mode
is a later revision quietly upgrading it into a claim that a container is as
strong - which would be false, and would read as reasonable.
2026-09-02 12:50:55 -07:00
branderson 90e32b8936 Say harness where the doc means harness, and backend where it means backend
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 58s
tests / confined (share) (pull_request_target) Successful in 1m31s
tests / confined (providers) (pull_request_target) Successful in 1m42s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m9s
tests / confined (project) (pull_request_target) Successful in 2m32s
tests / confined (audit) (pull_request_target) Successful in 3m4s
tests / confined (static) (pull_request_target) Successful in 2m35s
tests / privileged (pull_request_target) Successful in 3m33s
tests / confined (config) (pull_request_target) Successful in 3m48s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
The README and docs still stated general properties in Claude Code's
vocabulary, from before there was a second harness or a backend axis. Some
of it was only narrow; the rest had become wrong.

Wrong, and fixed here:

The exit-time audit does not watch ~/.claude/projects. That surface was
retired when the cross-project finding was, because since #104 a session
gets its own slugs and nothing else, so the only writes visible there are
other sessions doing their own work. The README still listed it, and
omitted .vscode/ and the host-execution surfaces the harness declares.

configuration.md described Cline's model configuration in terms of a
[harness.cline] base_url that no longer exists - where the model is moved
to the backend - and said a hosted API cannot be proxied, which openrouter
is. It also told an operator to resume a Cline session on the host with
CLINE_DATA_DIR, three paragraphs above the section saying that is not a
supported path (#301), and opened Cline's parity section with "almost
nothing carries over" while the table of what carries over sat below it.

development.md said the credential proxy was Claude Code's shape
throughout. It is arguments now, which is what lets Cline reach a proxied
backend without a branch of its own.

security.md described the push and merge guards as regex over a command
string. They shadow git and the forge CLI on PATH and read argv; only
deny-dangerous-bash.sh is regex.

The README claimed hooks refuse destructive commands, flat. That needs a
hook the harness can host, and Cline hosts none - the launch already says
so, and now the README does too.

Narrow rather than wrong, and widened:

"One project's session cannot reach another's" documented one harness's
mechanism for a property both have, so it now says what the property is
and then how each harness reaches it. The Cline half is a data directory
per workspace rather than a slice of yours, which is why it clears R4 more
cleanly rather than less.

The credential is a model credential, not an Anthropic OAuth one; the
allowlisted endpoint is the selected backend's, not api.anthropic.com; the
briefing reaches a session by one of three routes rather than through a
SessionStart hook. The ~/.claude mount inventory stays Claude Code's and
now says so, with a pointer to Cline's.

Notifications are gated on a harness declaring the notify guard, which
remote-access.md did not mention - a remote-enabled Cline session gets
attach and the web terminal and no push.
2026-09-02 12:27:18 -07:00
branderson bcb360a29b Say that install.sh writes nothing, rather than that it writes one hook
tests / confined (audit) (pull_request_target) Successful in 2m57s
tests / confined (config) (pull_request_target) Successful in 3m36s
tests / confined (conversation) (pull_request_target) Successful in 54s
tests / confined (project) (pull_request_target) Successful in 2m28s
tests / confined (providers) (pull_request_target) Successful in 1m37s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (remote) (pull_request_target) Successful in 2m4s
tests / confined (share) (pull_request_target) Successful in 1m28s
tests / confined (static) (pull_request_target) Successful in 2m29s
tests / confined (shellcheck) (pull_request_target) Successful in 1m6s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / privileged (pull_request_target) Successful in 3m36s
The commit that took deny-dangerous-bash.sh off the host left three places
saying otherwise: the README's install step and development.md both named
it as the one hook install.sh still writes, and install.sh's own messages
reported that no guard hooks were registered when it can no longer
register any. The block those messages sit in still needs the harness
name, but for the upgrade cleanup rather than for registration.

security.md described the tamper it guards against as unlinking one of
install.sh's hook symlinks. The tamper is unchanged and the directory is
still mounted read-only for it, but the symlinks in it are now the
operator's own.
2026-09-02 11:50:38 -07:00
branderson 707ffaaa69 Make the placeholder files in the container, not on the operator's machine
tests / confined (conversation) (pull_request_target) Successful in 54s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (share) (pull_request_target) Successful in 1m31s
tests / confined (providers) (pull_request_target) Successful in 1m43s
tests / confined (shellcheck) (pull_request_target) Successful in 1m9s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (remote) (pull_request_target) Successful in 2m11s
tests / confined (project) (pull_request_target) Successful in 2m31s
tests / confined (audit) (pull_request_target) Successful in 3m1s
tests / confined (static) (pull_request_target) Successful in 2m34s
tests / privileged (pull_request_target) Successful in 3m33s
tests / confined (config) (pull_request_target) Successful in 3m45s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
harness_prepare_config created an empty settings.json and CLAUDE.md in
~/.claude when the host had none, so that staging had something to copy.
A file the operator does not have is not one dejima should make on their
machine for its own convenience, and the file is needed in the container
rather than there.

emit_staged_config takes an `ensure` flag now, and the entrypoint creates
what it names - root-owned, read-only, refusing a path that could climb
out of $HOME, and leaving an existing file alone so a second launch does
not blank what was staged from the operator's own.

Two kinds, because empty is not always a valid file. settings.json asks
for `{}`: an empty one is not valid JSON, and Claude Code discards a
settings.json it cannot parse wholesale, taking every permission, hook
and plugin with it - the exact failure the validation above it exists to
catch. statusline.sh asks for neither, because a placeholder there would
be installed executable and run as the status line.

That distinction was not something I reasoned out. The test asserting the
old behaviour said in its own comment that an empty settings.json is one
Claude Code would refuse to parse, which is the only reason the first
version of this - writing an empty file for both - did not ship.

Also adds a Model backend maturity section to docs/providers.md. The
harnesses and forges each have one saying how much use they have actually
had; four backends in a table without one invites the reading that they
are equally exercised. anthropic is the one with the miles on it,
anthropic-api-key and openrouter are confirmed working rather than worn
in, and self-hosted runs no proxy at all so there is least of dejima
between the session and the endpoint. A test derives the list from what
ships, so a backend added later fails it until someone says.
2026-09-02 10:41:04 -07:00
branderson 6b8eda6ff4 Wait for the child before calling it hung
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m30s
tests / confined (providers) (pull_request_target) Successful in 1m38s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (static) (pull_request_target) Successful in 2m30s
tests / privileged (pull_request_target) Successful in 3m22s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 2m7s
tests / confined (project) (pull_request_target) Successful in 2m27s
tests / confined (audit) (pull_request_target) Successful in 2m57s
tests / confined (config) (pull_request_target) Successful in 3m39s
The pty fixture ended its read loop when the child stopped WRITING and
then required it to have already exited. Those are not the same moment:
dejima runs its exit audit and stops its shipper after the last thing it
prints, so a child that was merely busy got killed and reported as a
hang - and took SIGPIPE on a pty nobody was reading any more, which is
where the "printf: write error: Broken pipe" in the failures came from.

It waits up to a minute for the child to finish now, and only kills after
that. An unanswered prompt still ends in a kill rather than a hung suite,
which is what the kill was for.

This is why --no-pause failed intermittently on the gate and once in six
isolated runs. It was never load: it was a race between a child
finishing and a fixture deciding it never would.

Recorded because the first attempt made it worse in a useful way. Adding
the grace period without importing `time` turned an intermittent failure
into a consistent traceback, which is how the real cause became visible
at all - an intermittent test that fails for two different reasons hides
the second one behind the first.
2026-09-02 09:55:18 -07:00
branderson 2758c998ed Write nothing to the harness's config, including the last hook
tests / confined (conversation) (pull_request_target) Successful in 49s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (share) (pull_request_target) Successful in 1m30s
tests / confined (providers) (pull_request_target) Successful in 1m41s
tests / confined (shellcheck) (pull_request_target) Successful in 1m6s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (remote) (pull_request_target) Successful in 2m8s
tests / confined (project) (pull_request_target) Successful in 2m31s
tests / confined (audit) (pull_request_target) Successful in 2m58s
tests / confined (static) (pull_request_target) Successful in 2m33s
tests / privileged (pull_request_target) Successful in 3m34s
tests / confined (config) (pull_request_target) Successful in 3m39s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
deny-dangerous-bash.sh was the one exception: baked nowhere, registered
in the operator's ~/.claude/settings.json, and deliberately ungated so it
applied to their own sessions too. That was dejima deciding something
about a machine outside its sandbox, which is not dejima's to decide.

It is baked and overlay-registered with the others now, so it still
blocks what it blocked inside a container and stops existing anywhere
else. An operator who wants it on their host sessions can register it
themselves - it is a hook in this repo and always was.

install.sh consequently writes nothing to ~/.claude at all: no symlink,
no registration, and no settings.json brought into existence to hold
neither. harness_register_guards registers nothing and says so; the hook
is kept because "what a harness needs on the host" is a real question a
future one may answer differently, and for Claude Code the answer is now
nothing. The upgrade cleanup takes back this hook as well.

One bug found by a test rather than by reading. Removing install.sh's
`mkdir -p ~/.claude/hooks` left nothing creating ~/.claude, and
harness_prepare_config writes a settings.json and a CLAUDE.md there - so
on a host where Claude Code had never run, a launch failed reporting a
settings.json it had just failed to create. It creates the directory it
writes into now.
2026-09-02 09:32:04 -07:00
branderson b10f5ff89f Register the sandbox's hooks in its own settings layer, not the operator's
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (share) (pull_request_target) Successful in 1m28s
tests / confined (providers) (pull_request_target) Successful in 1m44s
tests / confined (shellcheck) (pull_request_target) Successful in 1m11s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m9s
tests / confined (project) (pull_request_target) Successful in 2m30s
tests / confined (audit) (pull_request_target) Successful in 3m0s
tests / confined (static) (pull_request_target) Successful in 2m36s
tests / privileged (pull_request_target) Successful in 3m27s
tests / confined (config) (pull_request_target) Successful in 3m44s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
Fixes #303.

install.sh symlinked dejima's hooks into ~/.claude/hooks and registered
them in ~/.claude/settings.json. Five of six no-op outside a sandbox, so
nothing misbehaved - but dejima was editing a config that is not its own
to make its own sandbox work, and "harmless because six gates say so" is
a worse claim than not doing it.

Claude Code merges the --settings overlay with the operator's settings
rather than replacing it, which is what makes the alternative possible.
The codebase already relied on that: harness_register_notify has been
writing a Stop hook into the overlay all along.

So sandbox-context.sh, git-push-guard.sh, merge-guard.sh and
dejima-notify.sh are baked into the image at /usr/local/lib/dejima/hooks
and registered per launch into the overlay by absolute path. Baked rather
than staged under $HOME: a session owns its home directory, and a guard
whose script it can unlink is not one. That is the property the read-only
~/.claude/hooks mount used to provide.

deny-dangerous-bash.sh stays where it was. It is the one hook meant to
apply to the operator's own sessions, and it already yields to an
existing install.

An upgrade takes back what an older install.sh wrote - the symlinks and
the registrations - matched by name and only when the symlink points into
this repo, so an operator's own file at one of those names is left alone.
Without that, the claim would hold only for a fresh install.

The operator's own ~/.claude/hooks is still mounted read-only, so their
hooks reach a session exactly as before.
2026-09-02 02:59:28 -07:00
branderson dda28a3864 Merge pull request 'Bring Cline's host-side user config in, the way Claude Code's already is' (#300) from cline-configs into development
Reviewed-on: #300
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 02:04:32 -07:00
branderson 4202e1fea1 Say that a Cline session cannot be resumed outside dejima
tests / confined (conversation) (pull_request_target) Successful in 51s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m28s
tests / confined (providers) (pull_request_target) Successful in 1m37s
tests / confined (shellcheck) (pull_request_target) Successful in 1m4s
tests / confined (audit) (pull_request_target) Successful in 2m52s
tests / confined (static) (pull_request_target) Successful in 2m31s
tests / privileged (pull_request_target) Successful in 3m23s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 2m3s
tests / confined (project) (pull_request_target) Successful in 2m22s
tests / confined (config) (pull_request_target) Successful in 3m35s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
Session persistence per project already works: this project's Cline state
lives on the host in a directory of its own and survives the container, so
launching dejima again picks up where the last session left off. Importing
or exporting sessions across that boundary was assessed and is not being
built (#301), so the behaviour is documented instead.

The briefing was telling agents the opposite. Core's closing carried "They
can continue this exact session outside the container by running on the
host:", which is Claude Code's fact stated as everyone's - and Cline's
fragment answered it with a CLINE_DATA_DIR incantation for driving a
host-side Cline against a directory that is dejima's rather than Cline's
own. A Cline agent reading that would send its operator somewhere not
supported.

The sentence moves into the harness fragments, where the fact belongs and
where the comment above the assembly already said it belonged. Claude Code
keeps the offer and the command. Cline says it cannot be continued outside
dejima, tells the agent not to offer it, and says what continuing does
look like - launching dejima again - because an agent told nothing will
invent something.

--harness's briefing test keyed on CLINE_DATA_DIR to tell the two apart.
It keys on what each says about resuming now, in both directions, so a
briefing carrying both would fail either way rather than passing one.

harness_state_dir is now read by no shipped fragment. Left in place: it is
documented briefing vocabulary rather than a dead manifest key, and
removing it would take cline.sh's harness_briefing_state_dir with it.
Worth a decision rather than a silent deletion.
2026-09-02 01:07:18 -07:00
branderson 785c9d34d7 Stage global rules into the directory cline actually reads
tests / confined (conversation) (pull_request_target) Successful in 1m23s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (share) (pull_request_target) Successful in 1m58s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (shellcheck) (pull_request_target) Successful in 1m11s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (remote) (pull_request_target) Successful in 2m38s
tests / confined (project) (pull_request_target) Successful in 3m0s
tests / privileged (pull_request_target) Successful in 3m23s
tests / confined (audit) (pull_request_target) Successful in 3m32s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (static) (pull_request_target) Successful in 2m35s
tests / confined (config) (pull_request_target) Successful in 4m13s
cline loads global rules from Documents/Cline/Rules and nowhere else.
~/.agents/rules reads like the obvious companion to ~/.agents/skills and
is not loaded - planting a file in each and asking `cline config` which
it had returned only the Documents one, while Skills(1) in the same run
confirmed ~/.agents/skills IS read.

So ~/.agents/rules is supported by staging its files INTO the directory
that is read, rather than by mounting it where nothing would look at it.
Host Documents/Cline/Rules is staged the same way. Dotfiles stay the
place an operator keeps rules; the Documents path is where they have to
arrive.

File by file rather than as a directory mount, because dejima writes its
own briefing into that directory and a read-only mount over it would
leave nowhere to put the briefing.

Two collisions have fixed answers. A name in both host locations is taken
from Documents/Cline/Rules: that is cline's own location for rules and
where it writes when they are edited through cline, and staging both
would be two bind mounts at one container path. A host file named
00-dejima.md is skipped - the briefing's name is read from the manifest
rather than repeated here, and entrypoint.sh already writes the briefing
after the staged files, so staging one it overwrites is only noise.

Only .md and .txt, which is what cline reads out of a rules directory.
2026-09-01 23:59:51 -07:00
branderson 585d1c3208 Bring Cline's host-side user config in, the way Claude Code's already is
tests / confined (conversation) (pull_request_target) Successful in 1m12s
tests / confined (proxy) (pull_request_target) Successful in 1m20s
tests / confined (share) (pull_request_target) Successful in 2m2s
tests / confined (providers) (pull_request_target) Successful in 2m19s
tests / confined (shellcheck) (pull_request_target) Successful in 1m14s
tests / runner-env (confined) (pull_request_target) Successful in 50s
tests / confined (remote) (pull_request_target) Successful in 2m50s
tests / privileged (pull_request_target) Successful in 3m13s
tests / confined (project) (pull_request_target) Successful in 3m17s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
tests / confined (config) (pull_request_target) Successful in 4m42s
tests / confined (audit) (pull_request_target) Successful in 3m51s
tests / confined (static) (pull_request_target) Successful in 2m53s
Cline declared no config directories at all, so none of the operator's own
configuration reached a session: no skills, no hooks, no AGENTS.md. The
mechanisms this needs already existed for claude-code, so this is three
hook implementations rather than new machinery.

Read-only mounts for ~/.agents/skills, ~/.agents/hooks and
~/.cline/hooks. Read-only is the point rather than caution: everything
there is either an instruction the agent follows or a program cline runs,
and a session that could rewrite them would be choosing what happens on
the HOST the next time cline starts outside a container - persistence for
a prompt injection, in files nobody re-reads before the next run. Scanned
for symlink targets, because on a Linux host these are routinely links
into a dotfiles checkout.

~/.agents/AGENTS.md is staged: a root-owned read-only copy. It is
instructions the agent follows, so writing the host's would choose what
the next session is told - and staging also survives it being a symlink,
which a file mount does not.

~/.cline/settings.json and cline_mcp_settings.json are seeded writable
and discarded with the container. cline rewrites both as it runs, so a
read-only copy would fail the write rather than the persistence - and the
persistence is the part that matters, because cline_mcp_settings.json
names MCP servers, which are commands cline executes.

~/.agents/rules is deliberately absent. It reads like the obvious
companion to ~/.agents/skills and cline does not load it: planting a file
there and one in ~/Documents/Cline/Rules and asking `cline config` which
it had returned only the second. Mounting a directory the harness never
reads would be a promise dejima cannot keep, so the test asserts its
absence.

Also raises the pty fixture's idle bound. Sixty seconds was enough on an
idle box and not enough under the full gate, where it killed a child that
was merely slow and reported it as a hang - the same shape as #295.
2026-09-01 23:19:44 -07:00
branderson cb31e463f9 Merge pull request 'Say where a moved key went, and stop appending a section that already exists' (#299) from startup-ux into development
Reviewed-on: #299
Reviewed-by: branderson <brad@branderson.io>
2026-09-01 20:25:28 -07:00
branderson 3e995a477a Add --no-pause, and stop offering a flag that no longer exists
tests / confined (audit) (pull_request_target) Successful in 3m7s
tests / confined (config) (pull_request_target) Successful in 3m50s
tests / confined (conversation) (pull_request_target) Successful in 51s
tests / confined (project) (pull_request_target) Successful in 2m35s
tests / confined (providers) (pull_request_target) Successful in 1m47s
tests / confined (remote) (pull_request_target) Successful in 2m14s
tests / confined (share) (pull_request_target) Successful in 1m33s
tests / confined (static) (pull_request_target) Successful in 2m49s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / privileged (pull_request_target) Successful in 3m22s
tests / runner-env (confined) (pull_request_target) Successful in 45s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (proxy) (pull_request_target) Successful in 56s
Starting several sessions from a tmuxinator layout gives every one of
them a terminal, so the pause fires on every one, and the operator wants
them all at a prompt rather than eight keys to press. --no-pause skips
the wait and still prints the notice: skipping the wait is not skipping
the telling.

A flag rather than a config key, deliberately. It describes the terminal
in front of one launch, not a standing preference - someone who wants
eight sessions to start unattended today has said nothing about the
launch they run by hand tomorrow.

Making room for it in --help found a real bug. --base-url was listed
there and had not been parsed since the provider axis moved the model's
address onto the backend and deleted the flag. Passing it answered
"unknown option", which is the worst kind of documentation: it reads as a
bug in dejima rather than in the text. Removing it also frees the line
the help page's own budget needed, which is how the constraint earned its
keep.

So a test now cross-checks the two: every flag --help offers must be one
bin/dejima parses. It has to match a flag anywhere in a case pattern,
because --ro is the second alternative of `--extra-readonly|--ro)`.

docs/configuration.md gains a short section on what dejima says before it
starts - the pause, when it fires, and that the shared-directory list is
sampled - since none of that was written down anywhere.
2026-09-01 19:46:14 -07:00
branderson ef6939f5d3 Answer the new pause where a test drives a terminal, and never hang on one
tests / confined (conversation) (pull_request_target) Successful in 56s
tests / confined (proxy) (pull_request_target) Successful in 54s
tests / confined (providers) (pull_request_target) Successful in 1m40s
tests / confined (remote) (pull_request_target) Successful in 2m8s
tests / confined (share) (pull_request_target) Successful in 1m33s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / confined (audit) (pull_request_target) Successful in 3m9s
tests / confined (static) (pull_request_target) Successful in 2m42s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (project) (pull_request_target) Successful in 2m37s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / privileged (pull_request_target) Successful in 3m43s
tests / confined (config) (pull_request_target) Successful in 3m56s
Adding a prompt before the build changed what every pty-driven launch
test is typing into. Two of them were driving a different prompt and had
their first keystroke eaten by this one.

test/fixtures/answer-prompt.py answers the pause automatically. It is
ambient - every launch fixture sets [core] ssh_key, which is a
deprecation, so it fires on all of them - and it is not the prompt any of
those tests is about. static.bats's tty helper does the same with a
leading newline, in the helper rather than in six callers, so each
caller's keys still mean what they say.

And the fixture no longer hangs. It waited on the child unconditionally,
so an unanswered prompt - the likeliest way for it to end badly - blocked
waitpid forever and took the whole suite with it, silently. It kills the
child and says which it was. That is what actually happened here: the
gate ran for twenty minutes on one stuck test before I looked.
2026-09-01 19:21:07 -07:00
branderson 72599e5c2d Give the startup notices somewhere to be read, and report a bad config once
Reported from a real launch, where the notices were sandwiched between a
ninety-entry file list and the first thousand lines of a Docker build.

The orphaned-share list is a sample now. It is a standing condition an
operator clears once, and printing all of it on every launch pushed
everything about THIS run off the top of the screen. Eight entries and a
count of the rest.

And a notice earns a pause. Every line after it is the image build, so a
notice that lands there scrolls away before it can be read - the same
reason the exit audit prints its findings last. It fires only when
something was said, and only with a terminal attached: an unattended
launch must not block on a keystroke nobody will press. The share list
deliberately does not raise it, because pausing every launch until a
standing condition is cleared teaches the operator to press Enter without
looking, which is the failure the pause exists to avoid.

Separately, and pre-existing: an invalid config reported its reason once
per PARSING CONTEXT. The early validation runs suppressed and, on failure,
dropped the cache so the next caller would parse and report - but the next
caller is usually a subshell, whose cache dies with it. One duplicated
line was merely odd; with a hint under it the same message printed twenty
lines. Re-parsed in this shell instead, so the reason is printed once and
the failed status is cached where subshells inherit it. Deliberately not
an exit: flags are not parsed at that point, and someone with a broken
config is exactly who needs --help.

The pause has a positive test as well as the unattended one. Without it
the guard test passes just as well when the feature does not exist, which
is not hypothetical: an accidental `git checkout` wiped both this and the
share sampling mid-session, and only the sampling test noticed.
2026-09-01 18:37:40 -07:00
branderson 63e79d81cc Say where a moved key went, write into the section that exists, and
give the startup notices room

Three things reported from a real launch on a fresh container.

A key dejima itself moved reported only "unknown key". base_url left the
harness for the backend in the provider axis, and an operator who had it
got a message that is true and useless: it does not distinguish a typo
from a move, and the answer is in a document they have no reason to think
they need. Unknown keys dejima once accepted now say where they went, and
show the two sections to write. Kept deliberately to keys dejima moved -
a key nobody ever accepted is a typo, and guessing at what it meant is
worse than saying nothing.

The assumed_backend migration appended its own [harness.<name>] section
even when the operator already had one, leaving the same header twice. It
parses, and it reads as a mistake dejima made in someone else's file. It
writes into the existing section now, and appends only when there is
none. Inserting right after the header is safe here specifically because
the key is UNSET: there is no later assignment for a line near the top to
lose to, which is what makes editing a config file dangerous in general.
The file keeps its own permissions rather than a fresh temp file's.

And the startup notices ran together as one block: an environment
deprecation, an identity deprecation, dejima editing your config, and the
guards that are not registered, with nothing between them. Each notice
owns the blank line above it now - never below, so two firing on the same
launch cannot double up - and the migration says what it wrote with the
line picked out, because that is the one notice an operator may want to
go and look at.

The migration test pinned the old wording; it asserts the announcement
and the written line instead, and now also that exactly one section
exists, which is the reported bug.
2026-09-01 18:37:40 -07:00
branderson 2c14b10888 Merge pull request 'Stop documenting a manifest key this release deleted' (#298) from release-docs-drift into development
Reviewed-on: #298
Reviewed-by: branderson <brad@branderson.io>
2026-09-01 18:20:02 -07:00
branderson 4706a27ab2 Merge pull request 'Report the config keys a provider declares, and the backends axis' (#297) from release-pass-keys into development
Reviewed-on: #297
Reviewed-by: branderson <brad@branderson.io>
2026-09-01 18:19:36 -07:00
branderson 8350f68071 Stop documenting a manifest key this release deleted
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m27s
tests / confined (providers) (pull_request_target) Successful in 1m40s
tests / confined (shellcheck) (pull_request_target) Successful in 1m6s
tests / runner-env (confined) (pull_request_target) Successful in 44s
tests / confined (remote) (pull_request_target) Successful in 2m9s
tests / confined (project) (pull_request_target) Successful in 2m29s
tests / confined (audit) (pull_request_target) Successful in 2m59s
tests / confined (static) (pull_request_target) Successful in 2m35s
tests / privileged (pull_request_target) Successful in 3m17s
tests / confined (config) (pull_request_target) Successful in 3m41s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
Found by the release pass's documentation review, which asks whether any
document describes something that no longer exists.

proxied_api_domain was a HARNESS manifest key. The provider axis moved it
to the backend as proxied_domain, because a harness reaches whichever
endpoint it is configured for - leaving the name on the harness would
have the terminator answering for one API while the session talked to
another. The key is gone from the schema and from both harness manifests,
and docs/providers.md still had a row describing it, and
docs/development.md named it twice as something to find in
harnesses/<name>/harness.conf.

A reader following either would go looking for a key that cannot be set,
and conclude the feature was missing rather than moved.

Two smaller leftovers from the same rename: a refusal's comment cited
anthropic-console, which the two-backend consolidation removed, and a
static test was named for the provider's declared domain when the axis it
asserts on is the backend's.
2026-09-01 03:07:16 -07:00
branderson 705809116a Report the config keys a provider declares, and the backends axis
tests / confined (conversation) (pull_request_target) Successful in 54s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m27s
tests / confined (providers) (pull_request_target) Successful in 1m41s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (remote) (pull_request_target) Successful in 2m6s
tests / confined (project) (pull_request_target) Successful in 2m28s
tests / confined (audit) (pull_request_target) Successful in 2m56s
tests / privileged (pull_request_target) Successful in 3m16s
tests / confined (static) (pull_request_target) Successful in 2m33s
tests / confined (config) (pull_request_target) Successful in 3m42s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
Found by running the release pass over the provider-axis release: it
listed seven keys a rollback would refuse, and the real number is
twenty-two.

Two blind spots, and both matter most for the newest work.

The axis loop was harnesses, forges, workflows. `backends` was added by
the release being checked, so the one axis whose sections are new was the
one nothing looked at - which is the general shape of a hand-maintained
list: it is updated by whoever remembers.

And a provider's own `keys` were not diffed at all. Those are config keys
an operator writes and an older dejima refuses, but they live in a
manifest rather than in core's schema, so the schema diff cannot see
them. `[harness.claude-code] backend` is one: adopting it is exactly what
makes the rollback refuse, and nothing said so.

The point of that section is that the rollback which looks like a safety
net is not one. A list that understates it by two thirds is worse than no
list, because it is read as complete.
2026-09-01 03:00:17 -07:00
branderson ff90448c79 Merge pull request 'Budget the gate's concurrency, and stop timing the machine in three tests' (#296) from flaky-tests into development
Reviewed-on: #296
Reviewed-by: branderson <brad@branderson.io>
2026-09-01 02:38:45 -07:00
branderson 17a51bd1c3 Budget the gate's concurrency, and stop timing the machine in three tests
tests / confined (config) (pull_request_target) Successful in 3m46s
tests / confined (conversation) (pull_request_target) Successful in 50s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (providers) (pull_request_target) Successful in 1m41s
tests / confined (shellcheck) (pull_request_target) Successful in 1m6s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (project) (pull_request_target) Successful in 2m31s
tests / privileged (pull_request_target) Successful in 3m20s
tests / confined (remote) (pull_request_target) Successful in 2m7s
tests / confined (audit) (pull_request_target) Successful in 3m3s
tests / confined (static) (pull_request_target) Successful in 2m34s
tests / confined (share) (pull_request_target) Successful in 1m29s
Fixes #295.

test/run-confined.sh runs every selected suite at once, each asking bats
for `nproc` tests in parallel. What lands on the machine is the product:
nine suites times ten cores is ninety concurrent tests on ten cores. The
oversubscription was deliberate and helps - these tests fork more than
they compute - but it is a property of the whole run rather than of each
suite. It is budgeted once now, at roughly twice the cores, and divided
across the selection. The announcement prints both numbers.

That is most of the fix, because the failures were all the same shape: a
test waits on a bounded deadline - a pty reaching raw mode, a shipper
polling, an audit finishing - and a saturated machine blows through it.
None of them was failing for the thing it checks.

Two tests were also wrong on their own merits.

audit's "rotation gives up instead of spinning forever" asserted a
LIVENESS property with a five-second latency bound, so a correct run on a
loaded machine reported a spin that had not happened. The subject is
forever, not fast, and any finite bound catches forever - so the bound is
generous now, and it additionally asserts what giving up looks like: an
unwritable directory means every planted report is still there. Without
that it said only that the command exited, which a rotation silently
doing nothing also satisfies.

conversation's #284 regression test read the collector's log the instant
the shipper exited. `wait` returns when the SHIPPER is gone, which says
nothing about the other end having written what it was sent, so a loaded
machine lost that race - and it failed as "the tail was lost", which is
this test's bug signature exactly. It waits for the line now, the same
way it already waits for the first one.

Both fixes are mutation-checked and neither weakens what it tests:
removing rotation's `|| break` still fails on the bound, and removing the
#284 warm-up still reports the tail lost.

Before: three gate runs on development, three failures, five distinct
tests between them. After: three consecutive clean runs.
2026-09-01 02:30:06 -07:00
branderson 7ebae027fa Merge pull request 'Declare the model provider in a manifest, not in core' (#280) from provider-axis into development
Reviewed-on: #280
Reviewed-by: branderson <brad@branderson.io>
2026-09-01 01:45:11 -07:00
branderson c2ade350ed Merge pull request 'Keep both Anthropic API-key logins on the host, and say which backend fits' (#288) from anthropic-api-key into provider-axis
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (share) (pull_request_target) Successful in 1m52s
tests / confined (proxy) (pull_request_target) Successful in 1m20s
tests / confined (providers) (pull_request_target) Successful in 2m6s
tests / confined (shellcheck) (pull_request_target) Successful in 1m7s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (remote) (pull_request_target) Successful in 2m10s
tests / confined (project) (pull_request_target) Successful in 2m52s
tests / confined (audit) (pull_request_target) Successful in 3m23s
tests / privileged (pull_request_target) Successful in 3m27s
tests / confined (static) (pull_request_target) Successful in 2m30s
tests / confined (config) (pull_request_target) Successful in 4m8s
Reviewed-on: #288
Reviewed-by: branderson <brad@branderson.io>
2026-09-01 00:27:17 -07:00
branderson e54db2b299 Merge pull request 'Declare GNU parallel, so a sandbox can run the suites the way CI does' (#294) from parallel-dep into development
Reviewed-on: #294
Reviewed-by: branderson <brad@branderson.io>
2026-08-31 23:15:17 -07:00
branderson 7b3809e395 Declare GNU parallel, so a sandbox can run the suites the way CI does
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (audit) (pull_request_target) Successful in 2m48s
tests / confined (static) (pull_request_target) Successful in 2m27s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (share) (pull_request_target) Successful in 1m16s
tests / confined (providers) (pull_request_target) Successful in 1m31s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 1m53s
tests / confined (project) (pull_request_target) Successful in 2m8s
tests / confined (config) (pull_request_target) Successful in 2m55s
tests / privileged (pull_request_target) Successful in 3m20s
tests / confined (conversation) (pull_request_target) Successful in 51s
CI runs every suite with `--jobs $(nproc) --no-parallelize-across-files`,
and `bats --jobs` needs GNU parallel to do it. Without it a sandbox can
run the suites only serially, which is not how they are run - and #284
was a race that only appeared under `--jobs`, so serial-only is a lane
that cannot see a whole class of bug.

Declared rather than added to the base image, for the same reason bats
is: a project that never runs bats needs neither.

The failure mode is why this is worth a comment rather than a bare line.
bats exits with "Cannot execute N jobs without GNU parallel" and prints
no test output at all, so a suite that never ran emits no `not ok` line -
and the obvious `| grep '^not ok'` check reports it as passing. That
happened during the #288 work: a suite was reported green having not
executed. .dejima.md now says to require both a zero exit status and a
non-zero count of `ok` lines, with the snippet.

Debian's `parallel` is GNU parallel; moreutils ships a different binary
of the same name and conflicts with it. The image installs no moreutils,
so there is nothing to resolve, but the next person to look at this
should not have to work that out.
2026-08-31 22:54:28 -07:00
branderson 4afd0c62b3 Merge pull request 'Ask about credential-shaped values instead of assuming they are safe' (#293) from credential-scan into anthropic-api-key
tests / confined (conversation) (pull_request_target) Successful in 49s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (share) (pull_request_target) Successful in 1m29s
tests / confined (providers) (pull_request_target) Successful in 1m41s
tests / confined (remote) (pull_request_target) Successful in 2m8s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (project) (pull_request_target) Successful in 2m30s
tests / confined (static) (pull_request_target) Successful in 2m35s
tests / privileged (pull_request_target) Successful in 3m19s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (audit) (pull_request_target) Successful in 2m59s
tests / confined (config) (pull_request_target) Successful in 3m43s
Reviewed-on: #293
Reviewed-by: branderson <brad@branderson.io>
2026-08-31 22:49:18 -07:00
branderson d57d38566f State what the credential scan does not catch
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (conversation) (pull_request_target) Successful in 48s
tests / confined (share) (pull_request_target) Successful in 1m29s
tests / confined (providers) (pull_request_target) Successful in 1m40s
tests / confined (remote) (pull_request_target) Successful in 2m9s
tests / confined (shellcheck) (pull_request_target) Successful in 1m7s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (project) (pull_request_target) Successful in 2m31s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (audit) (pull_request_target) Successful in 3m4s
tests / confined (static) (pull_request_target) Successful in 2m37s
tests / privileged (pull_request_target) Successful in 3m21s
tests / confined (config) (pull_request_target) Successful in 3m47s
A best-effort control that reads as a guarantee is worse than none: an
operator who believes nothing can cross stops looking.

docs/security.md gains the limits, four of them, each a hole someone
could otherwise walk into.

Whitespace is the one worth stating plainly, because it is a trade rather
than an oversight. Both heuristic rules require one token from a
restricted alphabet, since the alternative flags every sentence in a
config file - English prose scores 4.0-4.5 bits per character by the same
measure a random key does. People do put spaces in passphrases they chose
themselves, and one of those goes straight through. It fits these files,
which hold credentials issued to a machine rather than typed by a person,
and it would not fit a file that held the other kind.

Also stated: short or low-entropy secrets in fields not named like one;
formats it cannot parse, which are copied unchecked and say so; and that
it only covers files dejima stages, not read-write mounts or the
workspace.

A static test pins all four, because they are exactly the sentences a
later compression pass reads as hedging and cuts.
2026-08-31 22:16:03 -07:00
branderson bb33c62721 Stop flagging prose and account fields as credentials
Reported from a real launch: cachedGrowthBookFeatures.tengu_flint_harbor
_prompt.prompt, a cached feature-flag payload, offered as a credential.

Measured rather than guessed at afterwards. Against this machine's real
~/.claude.json the scanner produced 22 findings and not one was a
credential. Two independent faults, both mine.

Entropy alone does not separate secrets from text. English prose scores
4.0-4.5 bits per character by character-frequency Shannon, comfortably
over the 3.5 threshold, so every sentence in a config file was a finding.
What actually separates them is shape: a secret is one token from a
restricted alphabet with no whitespace in it. Both value rules now
require that first, and the entropy bar moved to 4.0.

And matching credential words as substrings put `auth` inside
`oauthAccount`, so every field of the signed-in account's description was
flagged, along with a feature flag named tengu_mcp_local_oauth_blocked
_hosts. Names are split into words - camelCase, snake_case and
kebab-case alike - and matched whole. Scope is the leaf and its immediate
parent: the parent is what makes `credential.helper` a credential, and
anything further up is how a subtree named after an oauth feature flag
became a subtree of credentials.

Same real file now produces zero findings, and a full launch against it
asks nothing. The canaries still land: a bare API key, a JWT, a hex
session secret, a base64 blob, a github token, and both gitconfig shapes.
Prose, slugs, paths and model names stay quiet.

Both halves are mutation-checked: removing the charset gate or restoring
substring matching fails the new test.
2026-08-31 22:16:03 -07:00
branderson 041e38bf4f Make the prompt readable, and scrubbing a one-launch answer
Review feedback, all of it right.

The report pushed five short labels out of line to accommodate "looks
like". Every label is padded to that width now, so the values line up.

"Scrub it from the copy" described dejima's implementation rather than
what the operator gets. It says "keep it out of the container this
launch" now, and the four answers read in parallel.

And scrubbing is one launch only. Removing a field the harness turns out
to need breaks every launch after, with the cause several layers from the
symptom, so the safe answer must not also be the sticky one. A standing
scrub is still honoured from the decisions file, where writing it is a
deliberate act - and it is the only way a scrubbed field survives an
unattended launch, so the non-interactive message still offers it, with
what it costs.

The best-effort qualifier was on the wrong half. FINDING a credential is
best effort: this recognises common shapes and can miss an unusual one.
REMOVING one it has found was not reliable, and now is - walk_ini read
line by line, so a value continued across lines parsed as a key that does
not exist (`password=...` under `helper = !echo \`), and removing one
line of it left the rest behind with a dangling backslash that changes
what git parses next. Both reading and removal work on whole logical
lines now.

Two test fixes: the pty writes CRLF, so an id lifted from that output
carried a \r into a tab-separated line that then silently failed to
match; and the persistence test asserted scrub sticks, which is exactly
what stopped being true.
2026-08-31 22:16:02 -07:00
branderson 0634730d60 Ask about credential-shaped values instead of assuming they are safe
Fixes #291.

The two leaks found by hand were both a host file copied wholesale
holding a credential nobody had classified as one, and both fixes are
denylists: they encode last week's knowledge. A harness's authentication
is not a stable interface, so the next field added leaks silently unless
something is looking.

bin/dejima-credential-scan classifies values in the files dejima stages -
vendor prefixes, credential-shaped names, high entropy - and reports
findings. It decides nothing, which is what lets the same classifier run
in a test with no terminal.

A value never leaves it, and neither does a real path. In a gitconfig the
userinfo sits in the section header, so `url.https://me:PAT@github.com/`
is a path that IS a credential. Findings carry a redacted path for a
human and an opaque id for matching, and --scrub takes ids back.

bin/dejima asks, with the four answers the operator asked for: scrub it
from the copy every time, allow it every time, allow it once, or refuse.
Answers are keyed on the field rather than the value, so a rotated
credential does not re-ask. With nothing attached to answer, the launch
stops rather than carrying it in - and says how to record a decision
without a terminal.

The report names file, field, shape, harness, backend and id: enough to
send to whoever ships the harness, and useless to anyone who intercepts
it.

Two bugs of mine worth recording. The prompt sat inside a `while ... done
<<< "$findings"` loop, which redirects stdin - so `[ -t 0 ]` was false
however the launch started and it would never have asked. Interactivity
is decided before the loop now. And the judged-safe list matched paths
exactly, so `customApiKeyResponses.approved` did not cover
`...approved[0]`; it covers a subtree now, while decisions stay exact -
broadening those would let one answer cover fields nobody saw.
2026-08-31 22:16:02 -07:00
branderson dab367231c Two Anthropic backends, not three
tests / confined (conversation) (pull_request_target) Successful in 49s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (providers) (pull_request_target) Successful in 1m45s
tests / confined (audit) (pull_request_target) Successful in 3m5s
tests / confined (share) (pull_request_target) Successful in 1m28s
tests / confined (shellcheck) (pull_request_target) Successful in 1m7s
tests / runner-env (confined) (pull_request_target) Successful in 44s
tests / confined (remote) (pull_request_target) Successful in 2m9s
tests / confined (project) (pull_request_target) Successful in 2m30s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
tests / confined (static) (pull_request_target) Successful in 2m38s
tests / privileged (pull_request_target) Successful in 3m22s
tests / confined (config) (pull_request_target) Successful in 3m36s
anthropic-api-key and anthropic-console differed in exactly two lines:
credential_field, and the credential_file default. They were never two
backends - they were one arrangement with the key in a different place,
and "anthropic-console" named a storage location rather than anything an
operator chooses.

The seam that does exist is the wire arrangement. A subscription login is
a Bearer token that expires and is refreshed, with an anthropic-beta
header and x-api-key stripped; an API key is x-api-key, bare, with
Authorization stripped. That is a different backend. Where the key sits
is configuration.

So credential_field moves off the manifest and into the backend's own
keys, defaulted to primaryApiKey beside a credential_file defaulted to
~/.claude.json. Claude Code's console login then needs nothing
configured, and a key held in a file of your own is the same backend with
both cleared - an empty credential_field means the whole file is the key.

The jsonfield type moves to dejima-config-lib.sh, since a keyspec default
is validated by the config file's own validator. The manifest validator
already falls through to it, so this is one definition rather than two.

The two backends now map onto the two things Anthropic sells, subscription
billing and API billing, and a federated login (#290) would be a third
mechanism rather than a fourth location.

The three endpoint values are still duplicated across the two manifests,
which is what a test now holds: upstream, proxied_domain and api_style
describe Anthropic's API rather than any credential, and must not drift.
Cheaper to assert than to deduplicate.
2026-08-31 22:15:53 -07:00
branderson b2c1a0ea89 Keep credentials out of the gitconfig copy, and explain a federated host
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (share) (pull_request_target) Successful in 1m25s
tests / confined (shellcheck) (pull_request_target) Successful in 1m3s
tests / confined (project) (pull_request_target) Successful in 2m22s
tests / confined (audit) (pull_request_target) Successful in 2m53s
tests / confined (static) (pull_request_target) Successful in 2m28s
tests / confined (providers) (pull_request_target) Successful in 1m40s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 2m3s
tests / privileged (pull_request_target) Successful in 3m22s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (config) (pull_request_target) Successful in 3m20s
Two things found by asking what else looks like the ~/.claude.json leak.

~/.gitconfig is the other host file copied wholesale into a container,
and it can hold credentials in three ordinary shapes: a credential.helper
that prints a token, an http.<url>.extraHeader carrying an Authorization
line, and userinfo embedded in a url.<...>.insteadOf key, where the
secret is the key name. None of them is part of what the sandbox is
granted - the session gets the ssh key and forge token dejima manages,
and gh auth setup-git writes its own helper inside the container. The
copy drops them by name and says so; the operator's own file is untouched
and a rewrite with no credential in it survives.

And Claude Code can sign in with no stored credential at all, through
workload identity federation: ANTHROPIC_FEDERATION_RULE_ID and an OIDC
identity token, exchanged for an access token by Claude Code itself. No
backend serves it (#290). On such a host every backend finds nothing, so
"no backend fits" is true and useless - it reads as dejima failing to see
a login that plainly works. Detected and named instead. Guidance only; no
credential handling changes.

The general problem is that both of this week's fixes are denylists, and
a harness's authentication is not a stable interface. Filed as #291.

Refs #171, #289, #290, #291.
2026-08-31 19:21:32 -07:00
branderson ddf0d61efc Name the backend that fits, instead of leaving the operator to guess
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (conversation) (pull_request_target) Successful in 49s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (share) (pull_request_target) Successful in 1m25s
tests / confined (providers) (pull_request_target) Successful in 1m41s
tests / confined (shellcheck) (pull_request_target) Successful in 1m3s
tests / confined (static) (pull_request_target) Successful in 2m32s
tests / privileged (pull_request_target) Successful in 3m20s
tests / confined (config) (pull_request_target) Successful in 3m24s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 2m4s
tests / confined (project) (pull_request_target) Successful in 2m25s
tests / confined (audit) (pull_request_target) Successful in 2m55s
Reported from a real setup: an operator with a Claude Code console login
tried backend = anthropic, then anthropic-api-key, and neither refusal
mentioned anthropic-console. Three dead ends, and the way out was only
in the docs.

Every refusal now checks which shipped backends could serve a credential
that is actually on this host, and names them with the line to write.
Derived from the manifests and the filesystem rather than listed, so a
backend added later is suggested the day it lands, and filtered by what
the selected harness can speak - naming a backend it has no client for
just trades one refusal for another.

The wrong-shape refusal also puts the fix ahead of the downgrade. It
offered proxy = off first, which is the answer least likely to be right.

And the placeholder key is pre-approved in the staged ~/.claude.json.
Claude Code asks about any ANTHROPIC_API_KEY it did not set itself, and
recommends declining - which on an unattended session leaves it with no
credential. The approval is the last 20 characters of the key, derived
from the value rather than written as a literal. If that record format
changes, the prompt comes back and nothing else: a courtesy, not a
control.

Verified on the reporter's machine before this commit: with
anthropic-console selected, the model answers, billing shows as API
usage, ANTHROPIC_API_KEY holds only the placeholder, and the container's
~/.claude.json has no primaryApiKey.

Refs #171, #289.
2026-08-31 17:43:12 -07:00
branderson bce3daf61e Serve Claude Code's console login, and switch the proxy per backend
tests / confined (conversation) (pull_request_target) Successful in 55s
tests / confined (proxy) (pull_request_target) Successful in 58s
tests / confined (share) (pull_request_target) Successful in 1m27s
tests / confined (shellcheck) (pull_request_target) Successful in 1m2s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (project) (pull_request_target) Successful in 2m27s
tests / confined (audit) (pull_request_target) Successful in 2m58s
tests / confined (config) (pull_request_target) Successful in 3m19s
tests / runner-env (privileged) (pull_request_target) Successful in 36s
tests / confined (providers) (pull_request_target) Successful in 1m41s
tests / confined (remote) (pull_request_target) Successful in 2m5s
tests / confined (static) (pull_request_target) Successful in 2m29s
tests / privileged (pull_request_target) Successful in 3m19s
Two coupled pieces, both from a real desktop launch.

A key is not always a whole file. Claude Code signed in with a console
API token keeps its key as a top-level primaryApiKey in ~/.claude.json,
beside unrelated app state, and leaves .credentials.json as `{}`. So the
static credential kind can now name a field: credential_field on the
manifest, --credential-field on the proxy, walked segment by segment in
Python. backends/anthropic-console declares it. anthropic-api-key stays
field-less, for a key held in a file of your own - two arrangements, two
directories, which is what the axis is for.

The gate learned about it too. _credential_usable checks the named field
is actually present, so a ~/.claude.json without one is refused at launch
rather than starting a proxy whose first request fails.

And the proxy switch is per backend. [credentials] proxy had the wrong
blast radius: one backend the proxy cannot serve meant disabling
withholding for every backend, including ones it serves perfectly.
[backend.<name>] proxy now falls back to it. Core appends that key to
every backend section rather than each manifest declaring it - whether to
withhold a credential is dejima's question, not the backend author's. It
ships no default, because unset is a third state that on/off cannot
carry.

The two meet at ~/.claude.json. Its staged copy has the credential
stripped, which is what keeps a console login's key on the host - but
with the proxy deliberately off for that backend, stripping it would
leave the session with no credential and nothing to mount, since the copy
is a throwaway. So the downgrade re-seeds the copy verbatim and says what
it handed over. The safe form is still what every other path gets.

Refs #171, #289.
2026-08-31 17:17:03 -07:00
branderson 3ab5c41068 Strip the console API key from the staged ~/.claude.json
tests / confined (conversation) (pull_request_target) Successful in 56s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (share) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 1m38s
tests / confined (remote) (pull_request_target) Successful in 1m59s
tests / confined (project) (pull_request_target) Successful in 2m19s
tests / confined (audit) (pull_request_target) Successful in 2m52s
tests / confined (config) (pull_request_target) Successful in 3m17s
tests / confined (static) (pull_request_target) Successful in 2m50s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
tests / privileged (pull_request_target) Successful in 3m21s
Found on a real desktop launch, and it explains the whole report.

A Claude Code signed in with a console API token keeps the key as a
top-level primaryApiKey in ~/.claude.json. Its .credentials.json stays
`{}`. dejima stages ~/.claude.json as a throwaway copy so a session gets
the operator's mcpServers and project state - and copied it with cp, so
that key crossed into every container, whatever backend was selected and
whether or not the credential proxy was running.

The proxy withheld one file while dejima copied another. That is also why
the reported session worked with an empty .credentials.json mounted and
an empty ANTHROPIC_API_KEY: it was authenticating from the copy.

Seeded through jq now, dropping primaryApiKey and keeping everything the
copy exists for. customApiKeyResponses stays - it holds hashes of keys
the operator approved, not the keys, and dropping it re-prompts inside
the sandbox for a decision already made on the host.

A ~/.claude.json jq cannot parse is a refusal rather than a fallback to
cp, because falling back restores this exact leak at the moment least
likely to be noticed.

Refs #171, #289.
2026-08-31 16:28:31 -07:00
branderson c306c32457 Refuse a credential the selected backend cannot serve
tests / confined (conversation) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m24s
tests / confined (shellcheck) (pull_request_target) Successful in 1m4s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m2s
tests / confined (audit) (pull_request_target) Successful in 2m55s
tests / confined (static) (pull_request_target) Successful in 2m29s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (providers) (pull_request_target) Successful in 1m38s
tests / confined (project) (pull_request_target) Successful in 2m21s
tests / confined (config) (pull_request_target) Successful in 3m15s
tests / privileged (pull_request_target) Successful in 3m21s
Found on a real desktop launch, not by a test. A Claude Code signed in
with a console API token has no claudeAiOauth block, so the anthropic
backend could not serve it - and dejima skipped the credential proxy and
bind-mounted the real credential file into the container, read-write,
saying nothing at all.

The session worked, which is what made it invisible. Both the UI and an
empty ANTHROPIC_API_KEY look identical to a correctly proxied launch; the
only outward sign was the mount.

That fallthrough is the right answer when the operator asked for it with
proxy = off, and the wrong one when they asked for proxy = on. So it is
now a refusal that names the backend, the file, what shape was expected
and the two ways forward. docs/security.md calls the mounted arrangement
a kill switch rather than an option, and a kill switch that can engage
itself is not one.

Scoped to a credential that exists and cannot be served. An absent one
mounts nothing and downgrades nothing - that is someone who has not
logged in on the host yet, and it still launches.

This does not make that setup work; it stops it failing open. The
anthropic-api-key backend added here reads a bare key file, and Claude
Code stores a console token inside .credentials.json instead, so serving
it needs the static kind to be able to name a field. Refs #171.
2026-08-31 15:40:12 -07:00
branderson 2e1b96c104 Keep an Anthropic API key on the host too
tests / confined (conversation) (pull_request_target) Successful in 53s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m25s
tests / confined (shellcheck) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 2m5s
tests / confined (project) (pull_request_target) Successful in 2m26s
tests / confined (providers) (pull_request_target) Successful in 1m40s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (audit) (pull_request_target) Successful in 3m2s
tests / confined (static) (pull_request_target) Successful in 2m34s
tests / confined (config) (pull_request_target) Successful in 3m23s
tests / privileged (pull_request_target) Successful in 3m34s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
API billing is what most organisations put their people on; a
subscription login is the narrower case. Without a backend for it, an API
key has no claudeAiOauth block to protect, so bin/dejima skips the
credential proxy and mounts the operator's real key into the container -
leaving the arrangement most people use as the one where a long-lived,
account-wide credential sits where a compromised session can read it.

An Anthropic API key is a static credential against the same endpoint,
presented as x-api-key with no scheme prefix. The proxy already injects
whatever header a backend names in whatever shape it names, so this needs
no new machinery, only somewhere to say it. strip_headers = authorization
matters: a placeholder must not travel beside the injected key, and here
the wrong answer is the placeholder.

The container side is one variable. An API-billing session authenticates
from ANTHROPIC_API_KEY rather than a credential file, so there is nothing
to fake - it gets the placeholder and no credential file at all.

Not verified here: that Claude Code in the container authenticates from
ANTHROPIC_API_KEY through the terminator. That needs a real launch with a
real key. Everything above is the host side.
2026-08-31 14:23:22 -07:00
branderson e01cc71b7d Resolve the backend's credential_file, and pin that the proxy starts
tests / confined (conversation) (pull_request_target) Successful in 53s
tests / confined (share) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 1m40s
tests / runner-env (confined) (pull_request_target) Successful in 45s
tests / confined (remote) (pull_request_target) Successful in 2m7s
tests / confined (static) (pull_request_target) Successful in 2m37s
tests / privileged (pull_request_target) Successful in 3m23s
tests / confined (config) (pull_request_target) Successful in 3m25s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (proxy) (pull_request_target) Successful in 57s
tests / confined (shellcheck) (pull_request_target) Successful in 1m10s
tests / confined (project) (pull_request_target) Successful in 2m30s
tests / confined (audit) (pull_request_target) Successful in 3m4s
The rename missed one call: the guard around the credential_file lookup
still asked config_type for a "provider.<name>" section, which no longer
exists. It answered no on every launch, so DEJIMA_BACKEND_CREDENTIAL_FILE
stayed empty, _credential_usable refused, and the proxy never started -
falling through to mounting the operator's real credential into the
container, which is the one arrangement dejima exists to avoid.

Nothing failed. Every suite passed with the proxy silently out of the
path, because what was being asserted was that a misconfiguration is
refused, never that a correct one gets as far as starting a proxy.

So the regression test is end to end and asserts the positive: a shipped
subscription-login launch reaches a proxy socket, its real credential
file is not among docker's mounts, and the real token appears nowhere in
the environment or argv. Reverting the one-word fix fails it.
2026-08-31 14:18:09 -07:00
branderson 8315468776 Call the model API a backend, not a provider
tests / confined (conversation) (pull_request_target) Successful in 54s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (remote) (pull_request_target) Successful in 2m2s
tests / confined (project) (pull_request_target) Successful in 2m21s
tests / confined (audit) (pull_request_target) Successful in 2m56s
tests / confined (static) (pull_request_target) Successful in 2m32s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (share) (pull_request_target) Successful in 1m25s
tests / confined (providers) (pull_request_target) Successful in 1m37s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / confined (config) (pull_request_target) Successful in 3m19s
tests / privileged (pull_request_target) Successful in 3m30s
This repo already calls the forge/workflow/harness axes "providers", so
using the same word for the model API a session talks to left one word
meaning two things in the same sentence. The axis mechanism keeps the
name; the model-API axis becomes "backend".

  providers/<name>/provider.conf  ->  backends/<name>/backend.conf
  [harness.<name>] provider       ->  backend
  [provider.<name>] <key>         ->  [backend.<name>] <key>
  --provider                      ->  --backend
  assumed_provider                ->  assumed_backend
  SANDBOX_PROVIDER_PROXIED_DOMAIN ->  SANDBOX_BACKEND_PROXIED_DOMAIN

A harness keeps its own vocabulary: cline writes a providers.json with a
lastUsedProvider key, and bin/harness/cline.sh translates.

Three things found while sweeping, fixed here rather than left:

backends/ was missing from the audit's TCB list. A backend.conf names the
upstream every request is forwarded to and the host path the credential is
read from, so an edit there redirects where a real credential is sent -
the same class as tools/*/tool.conf, which is listed.

docs/providers.md documented upstream and proxied_domain as required and
credential as static|anthropic-oauth, all three stale since credential =
none landed. Its axis tables also never gained the fourth axis.

The new axis was left out of _provider_warm_dirs, so its directory
resolution was re-paid in every subshell that read a manifest.
2026-08-31 14:06:37 -07:00
branderson 4aa984717c Merge pull request 'Add OpenRouter, and make Cline's endpoint one decision' (#287) from cline-openrouter into provider-axis
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (share) (pull_request_target) Successful in 1m24s
tests / confined (config) (pull_request_target) Successful in 3m16s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (providers) (pull_request_target) Successful in 1m35s
tests / confined (shellcheck) (pull_request_target) Successful in 1m3s
tests / runner-env (confined) (pull_request_target) Successful in 39s
tests / confined (remote) (pull_request_target) Successful in 2m0s
tests / confined (project) (pull_request_target) Successful in 2m21s
tests / confined (audit) (pull_request_target) Successful in 2m52s
tests / confined (static) (pull_request_target) Successful in 2m30s
tests / privileged (pull_request_target) Successful in 3m18s
Reviewed-on: #287
Reviewed-by: branderson <brad@branderson.io>
2026-08-31 02:49:56 -07:00
branderson ab457fdbbe Let the fake Loki bind its own port, and wait for the shipper
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m25s
tests / confined (conversation) (pull_request_target) Successful in 1m32s
tests / confined (providers) (pull_request_target) Successful in 1m39s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 2m3s
tests / confined (shellcheck) (pull_request_target) Successful in 1m1s
tests / confined (project) (pull_request_target) Successful in 2m26s
tests / confined (audit) (pull_request_target) Successful in 2m56s
tests / confined (config) (pull_request_target) Successful in 3m20s
tests / privileged (pull_request_target) Successful in 3m21s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (static) (pull_request_target) Successful in 2m33s
2026-08-31 01:00:18 -07:00
branderson 31bd7c3cfe Cache only successes, and make the probe fixture fail loudly
tests / confined (conversation) (pull_request_target) Failing after 52s
tests / confined (proxy) (pull_request_target) Successful in 52s
tests / confined (share) (pull_request_target) Successful in 1m23s
tests / confined (providers) (pull_request_target) Successful in 1m36s
tests / confined (shellcheck) (pull_request_target) Successful in 1m4s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m1s
tests / confined (project) (pull_request_target) Successful in 2m21s
tests / confined (audit) (pull_request_target) Successful in 2m52s
tests / confined (static) (pull_request_target) Successful in 2m30s
tests / confined (config) (pull_request_target) Successful in 3m15s
tests / privileged (pull_request_target) Successful in 3m25s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
2026-08-31 00:52:28 -07:00
branderson 18666c1bea Add a provider/harness matrix, checked against the manifests
tests / confined (conversation) (pull_request_target) Failing after 51s
tests / confined (proxy) (pull_request_target) Successful in 56s
tests / confined (share) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 1m36s
tests / confined (shellcheck) (pull_request_target) Successful in 1m3s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (remote) (pull_request_target) Successful in 2m2s
tests / confined (project) (pull_request_target) Successful in 2m25s
tests / confined (static) (pull_request_target) Successful in 2m32s
tests / confined (config) (pull_request_target) Successful in 3m20s
tests / confined (audit) (pull_request_target) Successful in 2m58s
tests / privileged (pull_request_target) Successful in 3m23s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
2026-08-31 00:16:21 -07:00
branderson 61add62e98 Make a self-served endpoint a provider too, so base_url stays put
tests / confined (conversation) (pull_request_target) Successful in 50s
tests / confined (proxy) (pull_request_target) Successful in 54s
tests / confined (share) (pull_request_target) Successful in 1m25s
tests / confined (providers) (pull_request_target) Successful in 1m33s
tests / confined (shellcheck) (pull_request_target) Successful in 1m4s
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / confined (remote) (pull_request_target) Successful in 2m3s
tests / confined (project) (pull_request_target) Successful in 2m24s
tests / confined (audit) (pull_request_target) Successful in 2m55s
tests / confined (static) (pull_request_target) Successful in 2m32s
tests / confined (config) (pull_request_target) Successful in 3m18s
tests / privileged (pull_request_target) Successful in 3m23s
tests / runner-env (privileged) (pull_request_target) Successful in 39s
2026-08-31 00:02:14 -07:00
branderson 7ba8a2fe7d Add OpenRouter, and make Cline's endpoint one decision
tests / confined (conversation) (pull_request_target) Successful in 52s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (share) (pull_request_target) Successful in 1m25s
tests / confined (providers) (pull_request_target) Successful in 1m33s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / confined (remote) (pull_request_target) Successful in 2m1s
tests / confined (project) (pull_request_target) Successful in 2m23s
tests / confined (audit) (pull_request_target) Successful in 2m56s
tests / confined (static) (pull_request_target) Successful in 2m28s
tests / confined (config) (pull_request_target) Successful in 3m19s
tests / privileged (pull_request_target) Successful in 3m25s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
2026-08-30 23:12:58 -07:00
branderson a9e75313e9 Record an unset provider in the config rather than defaulting it
tests / confined (conversation) (pull_request_target) Successful in 56s
tests / confined (proxy) (pull_request_target) Successful in 1m0s
tests / confined (share) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 1m34s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / runner-env (confined) (pull_request_target) Successful in 46s
tests / confined (remote) (pull_request_target) Successful in 2m2s
tests / confined (project) (pull_request_target) Successful in 2m22s
tests / confined (audit) (pull_request_target) Successful in 3m0s
tests / confined (config) (pull_request_target) Successful in 3m17s
tests / confined (static) (pull_request_target) Successful in 2m31s
tests / privileged (pull_request_target) Successful in 3m25s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
2026-08-30 21:55:05 -07:00
branderson 6eada984e0 Give the provider no default, and document what each arrangement exposes 2026-08-30 21:55:05 -07:00
branderson 20cb52defb Select the provider per harness, with a --provider flag 2026-08-30 21:55:05 -07:00
branderson fff6f02a55 Declare the model provider in a manifest, not in core 2026-08-30 21:55:05 -07:00
branderson 1809c3415b Merge pull request 'Resolve a harness's key schema before anything can signal us' (#286) from shipper-fix into development
Reviewed-on: #286
Reviewed-by: branderson <brad@branderson.io>
2026-08-30 21:53:54 -07:00
branderson 6ad65da574 Resolve a harness's key schema before anything can signal us
tests / confined (conversation) (pull_request_target) Successful in 51s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (share) (pull_request_target) Successful in 1m17s
tests / confined (providers) (pull_request_target) Successful in 1m31s
tests / confined (shellcheck) (pull_request_target) Successful in 1m4s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (remote) (pull_request_target) Successful in 1m54s
tests / confined (project) (pull_request_target) Successful in 2m11s
tests / confined (audit) (pull_request_target) Successful in 2m54s
tests / confined (config) (pull_request_target) Successful in 2m59s
tests / confined (static) (pull_request_target) Successful in 2m34s
tests / privileged (pull_request_target) Successful in 3m22s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
2026-08-30 21:39:57 -07:00
branderson 8e992ae0bf Merge pull request 'Give each project its own share directory, and mount only that at ~/shared' (#285) from share-per-project into development
Reviewed-on: #285
Reviewed-by: branderson <brad@branderson.io>
2026-08-30 21:25:32 -07:00
branderson b1374b21ea Say where ~/shared is now, in the briefing and the four docs that name it
tests / confined (conversation) (pull_request_target) Successful in 55s
tests / confined (proxy) (pull_request_target) Successful in 55s
tests / confined (providers) (pull_request_target) Successful in 1m32s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / confined (shellcheck) (pull_request_target) Successful in 1m9s
tests / confined (remote) (pull_request_target) Successful in 1m55s
tests / confined (project) (pull_request_target) Successful in 2m33s
tests / confined (static) (pull_request_target) Successful in 2m42s
tests / confined (audit) (pull_request_target) Successful in 2m56s
tests / confined (config) (pull_request_target) Successful in 3m2s
tests / privileged (pull_request_target) Successful in 4m0s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
The briefing told the session ~/shared was a persistent host directory and
nothing about who else could read it, which was accurate when the answer was
everyone. It now says the directory is this project's own and that every
session against this project shares it.

configuration.md gains the layout, the dejima-share commands, and what happened
to files left at share_dir's top level. security.md gains a bullet in the
section that already lists what is sliced per project, because the share
directory was the remaining way around that slicing. remote-access.md's sshfs
example mounts projects/ - one directory per project, no loose files - and says
a single project's directory is mountable on its own.

The 'clear it out periodically' warning stays and gets sharper: the split
bounds which sessions can read what you left, and bounds nothing about how
long.
2026-08-30 21:16:39 -07:00
branderson 6a2b36740e Cover the share split, and wire the new suite into both runners
share.bats drives the real launch with a stubbed docker and asserts on the
SANDBOX_SHARE_DIR compose was actually handed, rather than on --print-config: a
version that resolved the path correctly and then mounted something else would
pass the second.

What it pins is the part that can regress silently - that two projects get two
directories, that dejima-share path names the directory the launch mounts, that
the launch keeps saying what the split left behind until it is gone, that adopt
moves nothing without a yes and never overwrites, and that neither [mounts]
readonly nor read_write can hand back the root.
2026-08-30 21:16:31 -07:00
branderson bc994ccf80 Add dejima-share: where a project's ~/shared is, and what is in it
Splitting share_dir per project makes the inbound direction the hard one - an
operator dropping a file in for the next launch needs to know which directory
that launch will mount. `dejima-share path` answers it and creates the
directory, so a file copied there is waiting.

`list` is the retention story, which is that there is not one: dejima removes
nothing from share_dir and never has, so this prints each project's size and
the date of its last write and says plainly that clearing it out is yours. The
per-project split bounds which sessions can read what you left and bounds
nothing about how long.

`adopt` moves what the split left loose at share_dir's top level into one
project's share, after showing it and asking once, the way dejima-project
approve does. It never overwrites: a name already in the destination is left
loose and reported, because both files are the operator's and this cannot know
which one they meant.

Nothing here deletes anything by itself. The boundary is the mount bin/dejima
sets up; this is the operator's view of the other side of it.
2026-08-30 21:16:31 -07:00
branderson 5f2b51d2ce Mount one project's share directory at ~/shared, not the whole of share_dir
`[core] share_dir` was one directory mounted into every container, so a
session saw the handoff files of every session before it and of every session
running beside it, across projects. That is the boundary ~/.claude is already
sliced per project to hold, with the share directory as the way around it.

A launch now mounts <share_dir>/projects/<slug>, keyed by the same workspace
slug the audit and epitaph stores use. What the container sees is still
~/shared, so nothing an agent has been told changes.

The layout lives beside ws_slug because two callers have to agree on it: what
bin/dejima mounts and what dejima-share tells an operator to put a file in.

<share_dir>/projects joins the guarded stores. Per-project only holds while
nothing else hands the whole of it over, and a session's own share arrives at
~/shared without going through [mounts] at all.

Files an operator left at share_dir's top level are still there and are now
mounted nowhere. Moving them is not this change's business, so every launch
says which ones they are until they are gone - a file handed to a session
yesterday and silently not arriving today is the failure worth avoiding here.
2026-08-30 21:16:19 -07:00
branderson e8ea09a347 Merge pull request 'A fast local test lane: parallel within each suite, and change-aware selection' (#283) from fast-test-lane into development
Reviewed-on: #283
2026-08-30 20:38:26 -07:00
branderson b232636960 Document the two lanes where the suite instructions already are
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / runner-env (confined) (pull_request_target) Successful in 37s
tests / confined (providers) (pull_request_target) Successful in 2m2s
tests / confined (shellcheck) (pull_request_target) Successful in 58s
tests / confined (remote) (pull_request_target) Successful in 3m2s
tests / confined (project) (pull_request_target) Successful in 3m49s
tests / privileged (pull_request_target) Successful in 3m47s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (static) (pull_request_target) Successful in 5m46s
tests / confined (audit) (pull_request_target) Successful in 7m8s
tests / confined (config) (pull_request_target) Successful in 7m25s
.dejima.md's per-file table is gone: it lived as prose an agent had to apply by
hand, and it now lives in suites_for() where it runs. What replaces it is the
distinction the table could not carry - which lane is the gate, and that a
green --changed run is evidence about the suites it names and nothing about the
ones it skipped.
2026-08-30 20:29:20 -07:00
branderson 839715605c Run the tests within a suite concurrently, and select suites from the diff
Two lanes, and each run says which one it was. The default is still every
confined suite - the pull-request gate, unchanged in what it covers. --changed
maps the working diff through suites_for() and runs only what it selects,
saying in as many words that it is not the gate.

The map is the table .dejima.md carried as prose, moved here so that it runs.
A path matching no rule selects every suite, because a path nobody has mapped
is a path nobody has decided about. A path given as an argument stands in for
one git would have reported, so --changed --list answers "what would this
change run?" without depending on what happens to be uncommitted.

Within each file, bats' own --jobs. It needs flock rather than GNU parallel,
and --no-parallelize-across-files is what tells bats that - it otherwise
refuses --jobs outright when GNU parallel is absent, which is every dejima
sandbox. Without flock the script falls back to one at a time and says so. The
CI matrix gets the same flag, where flock comes from util-linux, which Debian
marks Essential.

static.bats pins what makes two lanes safe: the map names only suites that
exist, an unmapped path selects everything, and each lane identifies itself.
2026-08-30 20:29:15 -07:00
branderson 19f5e6e452 State two timing preconditions as conditions, not as sleeps
Both surfaced once the suites started running their own tests concurrently:
they held on an idle machine and failed on a loaded one, which is the pattern
.dejima.md already records twice.

conversation.bats slept a second to mean "the shipper is up with the line in
its batch". Under load the startup that precedes the SIGTERM trap takes longer
than that, so the kill landed before there was a trap and the process died
flushing nothing. The nap between polls is a child `sleep`, so the state is
observable; await_napping waits for it.

The Ctrl-Z harness read the terminal flags at a fixed moment after `fg`. fg
returns when the job is continued and the client sets raw mode some scheduling
later, so on a loaded machine the check read the shell's own canonical mode and
called it a failure to restore. await_raw waits for raw mode the way
await_reclaim already waits for the helper.
2026-08-30 20:29:06 -07:00
branderson b23ea0edb2 Cut the fork cost of every provider-library load
Sourcing bin/dejima-provider-lib.sh cost ~235ms, of which ~180ms was the
source-time warm-up: provider_list and provider_dir reached provider_root and
_provider_axis_dir through $( ), and provider_dir resolved the axis directory
again for every provider, so warming eight directories was around forty forks.

Both helpers gain an assign-into-a-variable form beside the printing one, the
way _config_type_into already sits beside config_type. readlink answers are
memoized per path, and the warm-up resolves every path in one readlink - it
takes several operands and prints one line each - dropping the batch on a
mismatched count rather than caching a pairing it cannot trust.

What is cached is one path's resolution, not a verdict: provider_dir still runs
the containment comparison and the -d check on every call.

This is paid by every bin/dejima invocation and by every test that drives the
library in a fresh shell, which is most of them.
2026-08-30 20:28:59 -07:00
branderson c83e54e35d Merge pull request 'Make the credential proxy provider-generic, with Anthropic as one caller' (#279) from generic-proxy into development
Reviewed-on: #279
Reviewed-by: branderson <brad@branderson.io>
2026-08-30 14:06:34 -07:00
branderson f2cfd0d868 Make the credential proxy provider-generic, with Anthropic as one caller
tests / confined (proxy) (pull_request_target) Successful in 1m16s
tests / confined (conversation) (pull_request_target) Successful in 1m22s
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (shellcheck) (pull_request_target) Successful in 56s
tests / confined (providers) (pull_request_target) Successful in 2m34s
tests / privileged (pull_request_target) Successful in 3m29s
tests / confined (remote) (pull_request_target) Successful in 3m41s
tests / runner-env (privileged) (pull_request_target) Successful in 37s
tests / confined (project) (pull_request_target) Successful in 5m4s
tests / confined (config) (pull_request_target) Successful in 9m30s
tests / confined (static) (pull_request_target) Successful in 5m34s
tests / confined (audit) (pull_request_target) Successful in 8m10s
2026-08-29 17:31:35 -07:00
branderson 817959acb9 Merge pull request 'Match both image spellings, so the reap stops matching nothing' (#278) from runtime-reap into development
Reviewed-on: #278
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 16:43:53 -07:00
85 changed files with 16776 additions and 3294 deletions
+82 -39
View File
@@ -24,45 +24,75 @@ relaunch so the derived image is rebuilt), not installing it in-session.
If it is missing and you cannot get a rebuild, say the suites were not run
rather than reporting a change as tested.
## Run the suite for what you changed, not all of them
**`parallel` is declared for the same reason**, because CI runs every suite
with `--jobs $(nproc) --no-parallelize-across-files` and `bats --jobs` needs
GNU parallel to do it. Without it bats exits with `Cannot execute N jobs
without GNU parallel` and prints no test output at all.
The suites are independent and cover different areas. Running all of them for
every edit is the single biggest drag on iteration here — reach for the whole
set before opening a PR, not between edits.
That failure is quiet in the worst way, so **check the exit status, not the
output**. A suite that never ran emits no `not ok` line, so the obvious
`| grep '^not ok'` check reports it as passing — which is how a change here
was once reported green against a suite that had not executed. Requiring both
a zero exit status and a non-zero count of `ok` lines catches it:
| You changed | Run |
| --- | --- |
| `bin/dejima-audit` | `test/audit.bats` |
| `bin/dejima-config-lib.sh`, `install.sh` | `test/config.bats` |
| `bin/dejima-provider-lib.sh`, `forges/`, `workflows/`, `harnesses/` | `test/providers.bats` |
| `bin/dejima-attach`, `-ttyd`, `-notify-watch`, `web/`, `systemd/` | `test/remote.bats` |
| `bin/dejima-project-lib.sh`, `-dep`, `-project`, `-allow` | `test/project.bats` |
| `bin/dejima-update-check` | `test/config.bats` |
| `bin/dejima-tui-lib.sh` | `test/remote.bats` and `test/project.bats` (both tools use it) |
| `bin/dejima-tty-suspend` | `test/static.bats` |
| `tools/` | `test/providers.bats` |
| `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js` | `test/proxy.bats` |
| `docker/`, `hooks/`, `README.md`, `docs/` | `test/static.bats` |
| anything under `docker/` that has to actually boot | `test/runtime.bats` (needs Docker) |
```sh
out="$(bats --jobs "$(nproc)" --no-parallelize-across-files test/config.bats 2>&1)"
rc=$?
n="$(grep -cE '^ok ' <<< "$out")"
[ "$rc" -eq 0 ] && [ "$n" -gt 0 ] || { echo "$out"; exit 1; }
```
When the whole set genuinely is wanted, run it concurrently rather than one
file after another:
## Two lanes: one for iterating, one for the pull request
Between edits:
```sh
bash test/run-confined.sh --changed
```
It reads `git diff` — committed, uncommitted and untracked — maps each path to
the suites that cover it, and runs only those. `--list` prints the selection
without running anything, and a path given as an argument stands in for one git
would have reported, so `--changed --list docker/Dockerfile` answers "what would
this change run?" directly.
Before opening a pull request, and for any claim that a change is tested:
```sh
bash test/run-confined.sh
```
The suites share no state, so they run together safely: about seven minutes
instead of about twenty-five. It reports what each suite ran as well as what
failed, because a suite that cannot start - a syntax error, or duplicate test
names, which bats refuses outright - exits nonzero having run nothing, and
that reads as "no failures" to anything counting only failures.
That is the gate: every confined suite, no selection. Each run prints which lane
it was, and `--changed` says in as many words that it is not the gate. **A green
`--changed` run is not a tested change** — it is evidence about the suites it
names and nothing about the ones it skipped.
Two things that are not in that table because they defeat it: `bin/dejima`
itself and `bin/dejima-config-lib.sh` are read by nearly every other suite, so a
change to either is one of the cases where the whole set genuinely applies. And
a change to a shipped doc or a `docker/` file usually needs `static.bats` *as
well as* whatever else it touched — that suite asserts on files it does not own.
The map lives in `suites_for()` in `test/run-confined.sh` rather than in a table
here, so that it runs instead of being remembered, and so an agent cannot map a
change to the wrong suite by hand. A path matching no rule selects every suite:
a path nobody has mapped is a path nobody has decided about. When you add a
file that a specific suite covers, add the rule — `static.bats` checks the map
names only suites that exist, but nothing can tell that a rule is too narrow.
Both lanes run the tests within each file concurrently as well as the files
themselves, which is most of where the wall clock went. It is bats' own
`--jobs`, and it needs `flock` rather than GNU parallel; without `flock` the
script falls back to one at a time and says so.
**The concurrency is budgeted for the whole run, not per suite.** Every
selected suite runs at once, so what lands on the machine is suites × jobs;
asking each for `nproc` made that ten times the cores on a ten-core box. The
script targets roughly twice the cores in total and divides, and prints both
numbers. That is #295: several tests wait on a bounded deadline — a pty
reaching raw mode, a shipper polling, an audit finishing — and a saturated
machine blows through it, so they failed for the load rather than for the
thing they check. If you add a test that waits for something, assume the
machine is busy.
Each run reports what every suite ran as well as what failed, because a suite
that cannot start — a syntax error, or duplicate test names, which bats refuses
outright — exits nonzero having run nothing, and that reads as "no failures" to
anything counting only failures.
**CI runs `test/runtime.bats` on every pull request**, as a separate
`privileged` job in `.gitea/workflows/tests.yml`. So being unable to run it
@@ -70,13 +100,23 @@ locally is not a reason to stop, to hedge, or to leave a change unfinished. Run
the confined suites, open the PR, and say plainly in the PR body that the
privileged half was not run locally.
**But do not read "CI covers it" as "it passed."** That job is pinned to
`runs-on: dejima-privileged`, a label only the one configured self-hosted runner
carries — it needs the host network namespace, the Docker socket, and a shared
volume, none of which a workflow can request. If that runner is offline the job
**queues instead of failing**, and the PR sits with the runtime half neither
green nor red. Check the PR's actual checks before treating it as verified, and
if the job is still queued, say so rather than implying it ran.
**Green means it ran.** The forge does not show a check green until every job
in it has finished and passed, and `runtime.bats` is one of those jobs — so a
green PR is one where the privileged half executed. There is no state in which
the checks are green and that suite was skipped.
What a queued run looks like is yellow or grey, not green. That job is pinned
to `runs-on: dejima-privileged`, a label only the one configured self-hosted
runner carries — it needs the host network namespace, the Docker socket and a
shared volume, none of which a workflow can request — so if that runner is
offline the job **queues instead of failing**, and the PR sits unfinished
rather than passing.
So: read the colour, and say what it says. Green is verified end to end and
should be reported that way. Yellow or grey is a run that has not happened yet,
and reporting it as passing is the thing to avoid. Hedging a green one is the
opposite error and is the more common one here — it reads as doubt about a
result that is not in doubt.
If a change touches what gets mounted, whether the firewall runs, or which
credentials cross the container boundary, that runtime run is the only thing
@@ -132,7 +172,7 @@ only holds where nothing else set it. Both passed in CI and failed exactly where
the code was being worked on:
- `proxy.bats`'s cert check meant "no cert configured" by not setting
`SANDBOX_ANTHROPIC_TLS_CERT` — but `entrypoint.sh` exports it into every
`SANDBOX_CREDENTIAL_TLS_CERT` — but `entrypoint.sh` exports it into every
tls-transport session, so inside a sandbox the guard under test never fired.
- `remote.bats`'s default-port check asserted on 7681, which is the port a real
`dejima-ttyd` front door holds, so it failed on any host running the feature.
@@ -223,7 +263,10 @@ to do as a follow-on to finishing a branch. It has its own pass - suites,
security review, documentation review, and the manual checks nothing here can
run - in [docs/release.md](docs/release.md), and `test/release-pass.sh` runs
what can be run and prints the rest. If you are asked to prepare a release,
start there rather than opening the pull request first.
start there rather than opening the pull request first. When the ask is to
run the pass, the pass is the whole of it: the report is the deliverable, the
release pull request is not part of it, and you never open one unasked.
Prompt the user to create it - the report is what they decide on.
`gitea-pr` prints the base it is opening against, every time, and reads
`git config gitea.prBase` before falling back to the repository's default
+11
View File
@@ -29,6 +29,17 @@ npm:cline
apt:tmux
apt:bats
# What `bats --jobs` needs. CI runs every suite with
# `--jobs $(nproc) --no-parallelize-across-files`, so without this a sandbox
# cannot run the suites the way they are actually run - and the failure is
# quiet in the worst way: bats exits with "Cannot execute N jobs without GNU
# parallel" and produces no test output at all, so a check that greps for
# `not ok` reads a suite that never ran as a suite that passed.
#
# Debian's `parallel` IS GNU parallel. moreutils ships a different binary of
# the same name and conflicts with it; bats needs this one.
apt:parallel
# The other half of what CI runs. The CI job installs it itself, so without
# this line a sandbox is the one place the gate cannot be run at all - and a
# base image that happens to carry it is not a guarantee, as one that stopped
+48 -4
View File
@@ -80,6 +80,7 @@ jobs:
matrix:
suite:
- static
- launch
- project
- remote
- audit
@@ -87,6 +88,7 @@ jobs:
- config
- providers
- conversation
- share
name: confined (${{ matrix.suite }})
container:
# Debian bookworm plus a node binary - node is what actions/checkout
@@ -163,9 +165,16 @@ jobs:
# they fail on the terminal rather than on anything they mean to check.
# xterm-256color ships in ncurses-base, which is Essential - nothing to
# install. Set on the command rather than in env: so it survives su.
#
# --jobs runs the tests within the file concurrently. bats needs GNU
# parallel only to parallelize ACROSS files; within one file it uses its
# own flock-based semaphore, and flock is in util-linux, which Debian
# marks Essential - so nothing here has to install anything for it.
# --no-parallelize-across-files is what tells bats that, since it
# otherwise refuses --jobs outright when GNU parallel is absent.
- run: |
chown -R node:node .
su node -c "TERM=xterm-256color bats test/${{ matrix.suite }}.bats"
su node -c "TERM=xterm-256color bats --jobs $(nproc) --no-parallelize-across-files test/${{ matrix.suite }}.bats"
shellcheck:
# Its own job rather than a matrix entry: it installs a different package,
@@ -319,9 +328,30 @@ jobs:
echo "resolves for this architecture." >&2
exit 1
}
# compose, from the same place and for a reason of the same kind: it
# is a plugin, so without it `docker compose` is not a command at
# all. Every launch on a real host goes through one, and nothing in
# CI did - so the compose file was the one input to a launch that no
# job had ever parsed.
#
# Its assets are named for `uname -m`, like the CLI tarball above and
# unlike buildx, which uses the Debian architecture name.
curl -fsSL -o /usr/local/lib/docker/cli-plugins/docker-compose \
"https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-$(uname -m)"
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
# Checked for the same reason buildx is: an error page chmods just as
# well as a binary, and the symptom would be a test that skips rather
# than one that fails.
docker compose version || {
echo "The compose plugin did not load, so the compose-file test will skip" >&2
echo "instead of running. Check the download URL above still resolves for" >&2
echo "this architecture." >&2
exit 1
}
env:
DOCKER_CLI_VERSION: 27.5.1
BUILDX_VERSION: 0.36.0
COMPOSE_VERSION: 5.5.1
# Fails here, with the fix in the message, rather than thirty tests deep
# in a suite that cannot work. Both of these are runner configuration,
@@ -372,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.
@@ -438,11 +468,25 @@ jobs:
- name: Build the sandbox image
run: docker build -t localhost/dejima:latest -f docker/Dockerfile .
- run: bats test/runtime.bats
# -T prints each test's duration. This job is the slow one and the only
# one nobody can run locally, so without it the cost of a test is
# guesswork - and the guess is wrong, because the expensive tests here are
# the ones that build an image rather than the ones that assert the most.
# Serial execution (see below) makes the numbers straightforwardly
# additive.
#
# Not --jobs, unlike the confined half. One container is shared across
# this file and a handful of tests mutate it - signalling dnsmasq,
# reloading the address sets - so running tests concurrently would have
# them racing over state the others read. Parallelising this suite means
# first separating the tests that only read the shared container from the
# ones that change it, which is a larger change than a flag.
- run: bats -T test/runtime.bats
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
+1 -1
View File
@@ -1,7 +1,7 @@
.claude
web/node_modules
# Python bytecode. bin/dejima-anthropic-proxy is a python program, so anything
# Python bytecode. bin/dejima-credential-proxy is a python program, so anything
# that imports or compiles it leaves these beside it.
__pycache__/
*.pyc
+115 -68
View File
@@ -45,18 +45,18 @@ starts.
- **An egress allowlist.** DNS and IP, IPv4 and IPv6. Anything not on the
list does not connect, and the agent is told so.
- **Credentials that stay on the host.** Your Anthropic OAuth credential
never enters the container: a host-side proxy holds it and the container
gets a placeholder. Your SSH key doesn't either; the container sees an
agent socket, never the key.
- **Credentials that stay on the host.** Your model credential never enters
the container, whichever backend you selected: a host-side proxy holds it
and injects it per request, and the container gets a placeholder. Your SSH
key doesn't either; the container sees an agent socket, never the key.
- **Per-project declaration and approval.** A project declares the domains
and dependencies it needs in `.dejima/`. Declaring is not granting: the
approval is a separate decision you make on the host, and the approval
store is never mounted where a session could reach it.
- **One project's session cannot reach another's.** `~/.claude` is not
mounted whole. A session gets its own project's transcripts, memory and edit
history and nothing else, so it cannot read what you did elsewhere or write
into another project's memory.
- **One project's session cannot reach another's.** Your harness's state
directory is not mounted whole. A session gets its own project's
transcripts, memory and edit history and nothing else, so it cannot read
what you did elsewhere or write into another project's memory.
- **CI hardening, because push access is code execution elsewhere.** A session
opens a pull request, the workflow fires, and your runner executes that
commit before you've read a line of it. The container bounds what a session
@@ -64,12 +64,17 @@ starts.
[docs/ci.md](docs/ci.md) for what hardening a runner takes, and
[dejima-ci-hardener](https://gitbox.branderson.io/dejima/dejima-ci-hardener)
for a tool that does it.
- **Guard rails inside the session.** Hooks refuse obviously destructive
commands, pushes to anywhere but the allowed remote, and merging a PR from
inside the sandbox. These are fast-fail guards against an overeager agent,
not a boundary against a hostile one: the container is the boundary.
- **Guard rails inside the session.** Pushes to anywhere but the allowed
remote, and merging a PR from inside the sandbox, are refused whichever
harness is selected - they shadow `git` and the forge CLI on `PATH`. Refusing
an obviously destructive command needs a hook the harness can host, so a
harness without one goes without it, and the launch says so rather than
leaving you to assume. All of these are fast-fail guards against an
overeager agent, not a boundary against a hostile one: the container is the
boundary.
- **An exit-time audit** of the host surfaces a session could have written to,
so a change to your git config or `~/.claude` does not go unnoticed.
so a change to your git config, to the workspace's own agent configuration,
or to a file your harness runs on the host does not go unnoticed.
**What it gives an agent to work with:**
@@ -83,9 +88,10 @@ starts.
name - and dejima loads it alongside a briefing on where the session is and
why the limits are there. An agent that knows it is in a sandbox asks for a
domain rather than working around the firewall.
- **Config parity with your host.** `CLAUDE.md`, settings, hooks, plugins and
git identity carry over; uid, home directory and project path match on both
sides, so a session's transcript is readable from the host afterward.
- **Config parity with your host.** Your harness's own global instructions,
settings, hooks, skills, rules and plugins carry over, and so does your git
identity; uid, home directory and project path match on both sides, so a
session's transcript is readable from the host afterward.
- **Remote access, opt-in and off by default.** Attach to a live session from
a terminal or a browser, with a push notification when it wants you.
- **What a long session worked out, kept.** Each time the agent compacts, the
@@ -145,6 +151,10 @@ what each one buys, what goes wrong without it, and where each one's boundary
ends. None of them is required to run dejima; what they change is what the
layer below the container costs, and whether anyone finds out.
**→ [Why a container, and not a microVM?](docs/threat-model.md#why-a-container-and-not-a-microvm)** -
the most common question about this design, and why the effort went into the
layers below the container rather than into a harder one.
## Setup
This repo only holds the tooling; no project lives here. Point it at a project
@@ -165,18 +175,27 @@ ones you want.
[Sign in on the host first](#sign-in-on-the-host-first) - and skip it
entirely for a harness pointed at a model you serve yourself, which needs
no credential.
2. **`./install.sh`**, which symlinks the CLIs into `~/.local/bin`, writes a
starter `~/.config/dejima/config` if none exists, and registers whatever
guard hooks the harness you selected can host. Idempotent, so re-run it
after pulling; it never overwrites an existing config.
2. **`./install.sh`**, which symlinks the CLIs into `~/.local/bin` and writes
a starter `~/.config/dejima/config` if none exists. It writes nothing to
your harness's own config: the sandbox's guards are baked into the image and
registered per launch into a settings layer of dejima's own, so installing
dejima does not change how your harness behaves outside a sandbox.
Idempotent, so re-run it after pulling; it never overwrites an existing
config.
3. **Put `~/.local/bin` on your `PATH`** if it isn't already.
4. **Set `[core] harness`.** It has no default and a launch is refused without
it, which is what stops a later release changing your selection for you.
That is enough to run `dejima`, and no push credential is needed to get this
far. The rest of the starter config is commented out at its defaults, which
means `[core] forge = none` and `[core] workflow = local-only`: the agent works
in the sandbox and commits locally, and nothing is pushed anywhere.
far. Everything else runs at its default, which means `[core] forge = none` and
`[core] workflow = local-only`: the agent works in the sandbox and commits
locally, and nothing is pushed anywhere.
**Those defaults are pinned to this machine, once.** The first launch writes
them to `~/.config/dejima/defaults.conf`, which your own config overrides, so
a later release cannot move a setting you never chose. `./install.sh` adds keys
a release introduced and names any shipped default that has moved, without
taking it.
**→ [docs/configuration.md](docs/configuration.md#configuration-reference)** -
every key, its default, and what it is for. `dejima --print-config` prints the
@@ -293,8 +312,8 @@ works if the host has a credential to hold. Authenticating from inside is not
the fix: the container is where the credential deliberately never goes, and the
egress allowlist is not built around completing a sign-in flow there. The same
applies to the account state your harness keeps alongside the credential, read
fresh from the host at every launch (see "Throwaway ~/.claude.json" in
[docs/security.md](docs/security.md)).
fresh from the host at every launch (Claude Code's is "Throwaway
~/.claude.json" in [docs/security.md](docs/security.md)).
So: run your harness once on the host, sign in, confirm it answers, and then
point dejima at a project. When a credential expires, refresh it the same way,
@@ -305,19 +324,33 @@ There is no credential to hold or expire; what that setup needs instead is for
the endpoint to be reachable from inside the container, which is an allowlist
question rather than a sign-in one.
## Provider axes: forge, workflow, harness
## Provider axes: forge, workflow, harness, model backend
The forge integration, the push/PR workflow, and the agent harness aren't
hardcoded into dejima: each is a directory under `forges/`, `workflows/` or
`harnesses/`, selected independently by `[core] forge`, `[core] workflow` and
`[core] harness`.
The forge integration, the push/PR workflow, the agent harness and the model
API aren't hardcoded into dejima: each is a directory under `forges/`,
`workflows/`, `harnesses/` or `backends/`, selected independently by
`[core] forge`, `[core] workflow`, `[core] harness` and
`[harness.<name>] backend`.
- **Forges:** `none`, `gitea`, `github`
- **Workflows:** `local-only`, `branch-push`, `worktree-pr`
- **Harnesses:** `claude-code`, `cline`
- **Model backends:** `anthropic`, `anthropic-api-key`, `openrouter`, `self-hosted`
Which of those last two pair up is the backend's API dialect against the
harness's, checked at launch rather than maintained as a list:
| Backend | `claude-code` | `cline` | Credential |
| --- | --- | --- | --- |
| `anthropic` | yes | yes | OAuth, held on the host |
| `anthropic-api-key` | yes | yes | a static key, held on the host |
| `openrouter` | no | yes | a static key, held on the host |
| `self-hosted` | no | yes | none, and so no proxy at all |
**→ [docs/providers.md](docs/providers.md)** - the manifest contract, key by
key, and the shipped providers as worked examples.
key, what each pairing needs configured, and the shipped providers as worked
examples. The model API is called a *backend* throughout, to keep it apart
from the axes themselves.
## Using this responsibly
@@ -365,17 +398,21 @@ 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
selected forge needs, the harness's own update and model endpoints, and
`api.anthropic.com` when the credential proxy is off - plus whatever a project
has declared and had approved.
selected forge needs, the harness's own update endpoints, and the selected
backend's API endpoint when the credential proxy is off - plus whatever a
project has declared and had approved. With the proxy on that last name is
deliberately absent: the session reaches the API through the in-container
terminator rather than by resolving it.
DNS is allowlisted as well as IPs: a local resolver only forwards lookups for
names already on that same allowlist, so an unapproved domain does not resolve
@@ -393,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
@@ -424,25 +461,35 @@ exact command to hand you.
tarball dependencies, the symlink refusal that protects the approval store,
and the complete `dejima-allow`/`dejima-dep`/`dejima-project` reference.
## Config parity with your host Claude Code setup
## Config parity with your host harness setup
Global `CLAUDE.md`, settings, hooks, skills, plugins and git identity all carry
over, and your existing login stays effective, with the credential itself never
entering the container. `~/.claude` is not shared as a whole - only an
enumerated subset does, so a session transcript is readable from the host
afterward but a credential or cache isn't. The container's own user matches
yours exactly, so `$HOME`-relative paths resolve the same on both sides.
Your harness's own global instructions, settings, hooks, skills, rules and
plugins carry over, along with your git identity, and your existing login stays
effective with the credential itself never entering the container. The
harness's config directory is not shared as a whole - only an enumerated subset
is, so a session transcript is readable from the host afterward but a
credential or cache isn't. The container's own user matches yours exactly, so
`$HOME`-relative paths resolve the same on both sides.
Which files those are is each harness's own question, answered in its plugin:
`~/.claude` for Claude Code, `~/.agents` and `~/.cline` for Cline.
**→ [docs/configuration.md](docs/configuration.md)** - exactly what carries
over and what doesn't, and why single-file mounts couldn't be trusted here.
over and what doesn't, per harness, and why single-file mounts couldn't be
trusted here.
## Shared scratch space
`~/shared` is a read-write bind mount of `[core] share_dir`, separate from the
project mount, for passing files in and out of a session without touching the
checkout. It persists across sessions since it's a real directory, not a
container volume. **Clear it out between sessions:** anything left there is
readable by the next session, and by every session after that.
`~/shared` is a read-write bind mount of this project's directory under
`[core] share_dir`, separate from the project mount, for passing files in and
out of a session without touching the checkout. One directory per project, so a
session sees its own project's handoff files and no other project's.
`dejima-share path` prints the host directory to drop a file into before a
launch; `dejima-share list` shows what every project is holding.
It persists across sessions since it's a real directory, not a container
volume. **Clear it out between sessions:** anything left there is readable by
the next session against that project, and by every session after that.
`~/shared`, the project mount, and anything in `[mounts] read_write` are the
only writable host paths in the container. Everything else a session writes
@@ -486,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
@@ -506,9 +556,9 @@ what a forwarded personal agent changes, and the persistent alternative.
Before each run, dejima snapshots the host surfaces a session could write to
and persist through: `.git/config` keys that run a command on the next `git`,
`.git/hooks/`, the workspace's `.claude/`, `.mcp.json`, `.envrc`, and writes
under `~/.claude/projects`. After the container exits it diffs them and tells
you what changed.
`.git/hooks/`, the workspace's `.claude/` and `.vscode/`, `.mcp.json`, `.envrc`,
and any host-side file the selected harness declares as one it runs by itself.
After the container exits it diffs them and tells you what changed.
It is **advisory, not a block**, firing after the session has ended, so a
finding is something to clean up before your next `git` command or session in
@@ -647,7 +697,7 @@ and how to run them.
| [research-review.md](docs/research-review.md) | Checking the threat model against the field's, and the standing questions that are the agenda |
| [configuration.md](docs/configuration.md) | Per-project declaration, dependencies, config parity |
| [tools.md](docs/tools.md) | The optional tools a session can be given, and what hardening each one carries |
| [providers.md](docs/providers.md) | Forge, workflow and harness axes, and writing your own |
| [providers.md](docs/providers.md) | Forge, workflow, harness and model-backend axes, and writing your own |
| [ci.md](docs/ci.md) | What push access grants on your CI host, and how to bound it |
| [remote-access.md](docs/remote-access.md) | Picking and verifying a bind address, and the SSH and web front doors |
| [development.md](docs/development.md) | The file map, the test suites, running them |
@@ -665,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.
+41
View File
@@ -0,0 +1,41 @@
# Anthropic on API billing: a key rather than a subscription login. This is
# what most organisations put their people on.
#
# One backend for both places the key can live, because they are one
# arrangement. Claude Code's own console login creates a key and stores it as
# primaryApiKey in ~/.claude.json - the defaults below - and a key you hold in
# a file of your own is the same static credential read from somewhere else:
#
# [backend.anthropic-api-key]
# credential_file = /path/to/your/key
# credential_field =
#
# An empty credential_field means the whole file is the key.
#
# For a subscription login, see anthropic. That one is a genuinely different
# arrangement rather than a different location - a Bearer token that expires
# and is refreshed, against the same endpoint.
upstream = https://api.anthropic.com
proxied_domain = api.anthropic.com
api_style = anthropic
credential = static
# Anthropic's own header, and the key goes in bare - no scheme prefix. This is
# the pair the subscription backend does NOT use, which is the whole
# difference between the two: an OAuth token is a Bearer, an API key is
# x-api-key.
auth_header = x-api-key
auth_format = {token}
# The other slot, stripped so a placeholder cannot travel beside the injected
# key. Two answers to the same question and which one the API prefers is not
# dejima's to guess - and here the wrong answer is the placeholder.
strip_headers = authorization
# Defaulted to where Claude Code puts it, so the common case needs no
# configuration. dejima strips this field from the copy of ~/.claude.json it
# stages, so the container gets the surrounding config and none of the key.
keys = credential_file:path=~/.claude.json credential_field:?jsonfield=primaryApiKey
requires = credential_file
+45
View File
@@ -0,0 +1,45 @@
# Anthropic, on a subscription login: the OAuth credential Claude Code writes
# on the host.
#
# The refreshing kind. The token expires, so the proxy renews it against the
# endpoint below and writes the rotated one back to the host file - which is
# what keeps a host-side login working rather than being signed out by a
# sandbox run. For a console key on API billing, see anthropic-api-key.
upstream = https://api.anthropic.com
# What the in-container TLS terminator answers for, and what /etc/hosts pins to
# loopback. On the backend rather than the harness, because it is a property
# of the endpoint: the same harness reaches a different one when a different
# backend is selected, and the terminator has to follow the endpoint.
proxied_domain = api.anthropic.com
# The Messages API, which is not OpenAI-compatible. A harness pointed here has
# to speak it; one that cannot is refused at launch rather than sending
# well-formed requests in the wrong shape.
api_style = anthropic
credential = anthropic-oauth
oauth_endpoint = https://platform.claude.com/v1/oauth/token
oauth_client_id = 9d1c250a-e61b-44d9-88ed-5944d1962f5e
auth_header = Authorization
auth_format = Bearer {token}
# x-api-key must not travel beside the injected Authorization: two answers to
# the same question, and which one the API prefers is not dejima's to guess.
# The container only ever holds a placeholder, so what this prevents is a
# placeholder being the one that gets read.
strip_headers = x-api-key
# The API rejects an OAuth token without it. Merged into whatever the client
# already sent rather than replacing it, and left completely alone when the
# value is already there - see the proxy's comment on why re-joining a list
# that needs no change is itself a fingerprint.
ensure_headers = anthropic-beta=oauth-2025-04-20
# Claude Code's own credential file, which is where the OAuth blob lives. A
# path, never a secret: the file is read host-side per request and is not
# mounted into the container.
keys = credential_file:path=~/.claude/.credentials.json
requires = credential_file
+34
View File
@@ -0,0 +1,34 @@
# OpenRouter, an OpenAI-compatible gateway in front of many models.
#
# A hosted endpoint behind a static API key. The key stays on the host and is
# read per request, exactly as the Anthropic OAuth token is.
upstream = https://openrouter.ai/api/v1
# The name the in-container terminator answers for, and what /etc/hosts pins to
# loopback. The host alone: the path above is where requests are FORWARDED, and
# a hostname is what a certificate and a DNS pin are about.
proxied_domain = openrouter.ai
api_style = openai
# A key, not a login. Nothing to refresh and nothing to write back, so a 401
# here means the key is wrong rather than stale - which is why the proxy does
# not retry it.
credential = static
auth_header = Authorization
auth_format = Bearer {token}
# Both spellings an OpenAI-compatible client might reach for, so a placeholder
# cannot travel beside the injected Authorization. The container only ever
# holds a placeholder; what this prevents is the placeholder being the one the
# endpoint reads.
strip_headers = x-api-key api-key
# No default, and no assumed value: a key file's location is the operator's,
# and guessing at one would be dejima pointing at a file it has no reason to
# think exists. `requires` makes an unset one a refusal at launch rather than
# an authentication failure several layers down.
keys = credential_file:?path
requires = credential_file
+27
View File
@@ -0,0 +1,27 @@
# A model the operator serves: on the host, or somewhere on the sandbox's own
# network.
#
# The third arrangement, and the only one with no credential anywhere in it.
# Nothing to withhold means no proxy, no terminator and nothing to steal, which
# makes this the strongest posture dejima offers - see docs/security.md.
#
# WHERE it is belongs to the operator rather than to this file, which is why
# base_url is a config key and not a manifest constant. A local endpoint is
# also the one an operator is most likely to keep configured while using
# something else: it lives in [backend.self-hosted] and stays there when the
# selected backend changes.
credential = none
# No upstream and no proxied_domain: both describe an endpoint dejima stands in
# front of, and it stands in front of nothing here.
# OpenAI-compatible, which is what a self-served model almost always speaks -
# llama.cpp, vLLM, Ollama and LM Studio all expose that shape.
api_style = openai
# No default. Refusing an unset one at launch beats a session starting against
# an endpoint dejima picked, and there is no address worth guessing: whatever
# is at 127.0.0.1:8080 on somebody else's machine is not this operator's model.
keys = base_url:?url
requires = base_url
+1482 -67
View File
File diff suppressed because it is too large Load Diff
+126 -55
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,60 +268,131 @@ if [ "$session_wanted" -eq 1 ]; then
[ -n "$container" ] || container="${containers%%$'\t'*}"
fi
# 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 "$container" bash -c '
domain="$1"
entry="server=/${domain}/127.0.0.11"
grep -qxF "$entry" /etc/dnsmasq-sandbox-servers.conf 2>/dev/null || echo "$entry" >> /etc/dnsmasq-sandbox-servers.conf
kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"
' -- "$domain"
echo "Resolving $domain inside $container..."
ips=$(docker exec "$container" dig +short A "$domain" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || true)
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 "$container" 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
# 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.
#
# Both are derived from whichever one the caller named, rather than
# recorded anywhere: Docker already knows the pairing, because joining a
# namespace is how the pair is built.
#
# Naming either half has to work, and not as a convenience. A session that
# is asked for its own container id reads /etc/hostname, and Docker names a
# container that JOINED another's network namespace after the container it
# joined - so the id in a briefing, the one an agent hands its operator, is
# the filter container's. Nothing about the pair is worth making an
# operator hold in their head to type one command.
mode="$(docker inspect -f '{{.HostConfig.NetworkMode}}' "$container" 2>/dev/null)" || mode=""
case "$mode" in
container:*)
# A session container: it names its own filter.
filter="${mode#container:}"
;;
*)
# Then it is a filter container, or not part of a pair at all.
# `docker exec` takes a name or an id; NetworkMode reports the id,
# so the search below compares ids.
filter_id="$(docker inspect -f '{{.Id}}' "$container" 2>/dev/null)" || filter_id=""
session=""
if [ -n "$filter_id" ]; then
while IFS=' ' read -r _id _mode; do
[ "$_mode" = "container:$filter_id" ] || continue
session="$_id"
break
done < <(docker ps -q 2>/dev/null | xargs -r docker inspect \
-f '{{.Id}} {{.HostConfig.NetworkMode}}' 2>/dev/null)
fi
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
if [ -z "$session" ]; then
echo "$container is neither a running session nor the filter container of" >&2
echo "one, so there is no allowlist to add this domain to." >&2
echo "" >&2
echo "A session's own id is in its startup context, or from 'cat /etc/hostname'" >&2
echo "inside it - either half of the pair is accepted here. If the session has" >&2
echo "already exited, record the domain for the next one instead:" >&2
echo "" >&2
echo " dejima-allow --project $domain" >&2
exit 1
fi
filter="$container"
container="$session"
;;
esac
# 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.
# Case-folded, like the class decision below and dejima_mark_internal:
# a live add that misses the exemption the operator configured leaves
# the name without it until the next launch, which then refuses to
# start on the same mismatch.
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 "$container" 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)."
-384
View File
@@ -1,384 +0,0 @@
#!/usr/bin/env python3
# Host-side credential-injecting proxy for sandboxed Claude Code sessions.
# The container only ever holds a placeholder token; this proxy (launched on
# the host by bin/dejima, reached via a bind-mounted unix socket and
# an in-container loopback forwarder) strips the placeholder and injects the
# real OAuth access token from ~/.claude/.credentials.json, so the credential
# itself never enters the container. It also owns token refresh: rotated
# tokens are written back to the real host file atomically, keeping
# host-side sessions logged in. See docs/security.md.
import argparse
import http.client
import json
import os
import socketserver
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler
from urllib.parse import urlsplit
OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
OAUTH_BETA = "oauth-2025-04-20"
# Hop-by-hop headers describe the connection to this proxy rather than the
# request, so they stop here (RFC 9110 7.6.1). Host is deliberately not in
# this set: it has to be rewritten for the upstream, and is replaced in place
# rather than dropped and re-added, which keeps the header block in the order
# the client wrote it - see _upstream.
HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "trailers", "transfer-encoding", "upgrade",
}
# Casing for the few headers this proxy has to supply when the client sent
# none of its own. Everything the client did send keeps the client's casing.
SUPPLIED_NAMES = {
"host": "Host",
"authorization": "Authorization",
"anthropic-beta": "anthropic-beta",
"content-length": "Content-Length",
}
class Credentials:
def __init__(self, path, oauth_endpoint, margin):
self.path = path
self.oauth_endpoint = oauth_endpoint
self.margin = margin
self.lock = threading.Lock()
def _read(self):
with open(self.path) as f:
return json.load(f)
def access_token(self):
oauth = self._read()["claudeAiOauth"]
if oauth["expiresAt"] / 1000 - time.time() < self.margin:
self.refresh(stale_token=oauth["accessToken"])
oauth = self._read()["claudeAiOauth"]
return oauth["accessToken"]
def refresh(self, stale_token):
with self.lock:
data = self._read()
oauth = data["claudeAiOauth"]
# A concurrent refresher (host-side Claude Code, another sandbox
# session, another thread here) may have replaced the token
# already - if the file no longer holds the stale one and isn't
# near expiry, use what's there instead of refreshing again.
if (oauth["accessToken"] != stale_token
and oauth["expiresAt"] / 1000 - time.time() >= self.margin):
return
u = urlsplit(self.oauth_endpoint)
cls = http.client.HTTPSConnection if u.scheme == "https" else http.client.HTTPConnection
conn = cls(u.netloc, timeout=30)
req = json.dumps({
"grant_type": "refresh_token",
"refresh_token": oauth["refreshToken"],
"client_id": OAUTH_CLIENT_ID,
})
conn.request("POST", u.path, body=req,
headers={"Content-Type": "application/json"})
resp = conn.getresponse()
payload = resp.read()
conn.close()
if resp.status != 200:
raise RuntimeError("token refresh failed: HTTP %d %.200s"
% (resp.status, payload))
fresh = json.loads(payload)
oauth["accessToken"] = fresh["access_token"]
if fresh.get("refresh_token"):
oauth["refreshToken"] = fresh["refresh_token"]
oauth["expiresAt"] = int((time.time() + fresh["expires_in"]) * 1000)
self._write(data)
def _write(self, data):
# Atomic and permission-preserving: host-side Claude Code reads this
# same file and must never observe a partial write or a looser mode.
# Staged in a dedicated subdirectory rather than ~/.claude directly,
# which keeps the plaintext window out of any directory the sandbox
# can see, and keeps the rename below same-filesystem (a subdirectory
# of the same parent) so atomicity is unaffected.
#
# A bind mount over the credential would not make this safe: the
# rename below is unaffected by the container's mount, but the
# container's mount is destroyed by the rename, which unlinks the
# dentry it is pinned to. ~/.claude is not mounted whole, so neither
# this directory nor the credential has a path into the container
# (#50, docs/security.md).
directory = os.path.join(os.path.dirname(self.path) or ".", ".credentials-proxy-tmp")
os.makedirs(directory, mode=0o700, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=directory)
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f)
f.flush()
os.fsync(f.fileno())
os.chmod(tmp, 0o600)
os.replace(tmp, self.path)
except BaseException:
os.unlink(tmp)
raise
class BodyTooLarge(Exception):
"""A request body over the cap. Its own type, not ValueError: _read_body
raises ValueError for a genuinely malformed request (a chunk length that
is not hex), and that must still surface as a fault rather than be
answered as if the session had merely sent too much."""
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_request(self, *args):
pass # per-request lines would interleave with the session's own UI
def log_error(self, fmt, *args):
sys.stderr.write("dejima-anthropic-proxy: " + fmt % args + "\n")
# Capped, because the client is the sandboxed session and this runs on the
# host as the operator's user. Every other session-controlled size in this
# repo is bounded (dejima-audit's STAGE_FILE_CAP and DIFF_BYTE_CAP,
# dejima-project's declare_entry, the briefing fragment limit), several
# with explicit reasoning about $TMPDIR being tmpfs; this was the one
# place that discipline was not applied, and a request body goes straight
# into host RAM.
#
# 32 MiB is far above any real Messages API request - the largest thing
# one carries is a conversation plus attachments - and far below anything
# that troubles a host. Refused rather than truncated: half a request
# forwarded upstream is a worse outcome than a rejected one.
MAX_BODY_BYTES = 32 * 1024 * 1024
def _read_body(self):
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
chunks = []
total = 0
while True:
size = int(self.rfile.readline().split(b";")[0], 16)
if size == 0:
self.rfile.readline()
return b"".join(chunks)
total += size
if total > self.MAX_BODY_BYTES:
raise BodyTooLarge("%d bytes" % self.MAX_BODY_BYTES)
chunks.append(self.rfile.read(size))
self.rfile.read(2)
length = int(self.headers.get("Content-Length", 0))
if length > self.MAX_BODY_BYTES:
raise BodyTooLarge("%d bytes" % self.MAX_BODY_BYTES)
return self.rfile.read(length)
def _fail(self, status, message, log_message=None):
# log_message (host stderr) may contain upstream response bytes or
# exception detail; message (sent to the container in the response
# body) never should - see docs/security.md.
self.log_error("%s", log_message if log_message is not None else message)
body = json.dumps(
{"type": "error", "error": {"type": "proxy_error", "message": message}}
).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _upstream(self, token, body):
u = self.server.upstream
cls = http.client.HTTPSConnection if u.scheme == "https" else http.client.HTTPConnection
conn = cls(u.netloc, timeout=600)
# Emitted header by header rather than handed to conn.request() as a
# dict, so what the API receives is the client's own header block -
# same names, same casing, same order, duplicates intact - with only
# the values that must change changed. The container half of this
# sandbox goes to some length to make the session indistinguishable
# from a stock install (no ANTHROPIC_* variables, no explicit base
# URL - see docs/security.md); a request rewritten into CPython's
# house style would put the fingerprint straight back on the wire,
# where it is visible to the API rather than only to the client.
#
# conn.request() does exactly that in three ways: a dict collapses
# repeated headers to the last one, dict order puts every header this
# proxy touches at the end of the block, and putrequest() supplies
# `Accept-Encoding: identity` - a CPython tell no browser or Node
# client sends - whenever the caller passed no accept-encoding. The
# skip_* flags below suppress that and the generated Host header,
# which is re-emitted in the client's own position instead.
conn.putrequest(self.command, self.path,
skip_host=True, skip_accept_encoding=True)
supplied = {
"host": u.netloc,
"authorization": "Bearer " + token,
"content-length": str(len(body)),
}
# anthropic-beta is left completely alone when the OAuth value is
# already among the client's own - which it is in the tls transport,
# where Claude Code believes it holds an OAuth credential. Re-joining
# a list that needs no change would normalize the client's spacing,
# which is the same class of tell as the casing.
betas = [b.strip() for v in self.headers.get_all("anthropic-beta", [])
for b in v.split(",")]
if OAUTH_BETA not in betas:
first = self.headers.get("anthropic-beta")
supplied["anthropic-beta"] = first + "," + OAUTH_BETA if first else OAUTH_BETA
seen = set()
for name, value in self.headers.items():
low = name.lower()
if low in HOP_BY_HOP or low == "x-api-key":
continue
if low in supplied:
# One replacement even for a repeated header, or the injected
# token would be sent as many times as the client sent its
# placeholder.
if low in seen:
continue
value = supplied[low]
seen.add(low)
conn.putheader(name, value)
for low, name in SUPPLIED_NAMES.items():
if low in supplied and low not in seen:
# Content-Length only when there is a body: a bodyless GET
# that carried none must not gain one. A chunked request has
# been de-framed by now, so it lands here and does gain one.
if low == "content-length" and not body:
continue
conn.putheader(name, supplied[low])
conn.endheaders(body)
return conn, conn.getresponse()
def _proxy(self):
try:
body = self._read_body()
except BodyTooLarge as e:
# 413 rather than a traceback and a 500: the session gets a status
# that says what to do about it, and the host log says what came
# in. Nothing is forwarded upstream.
return self._fail(413, "credential proxy: request body too large",
log_message="refused a request body: %s" % e)
try:
token = self.server.creds.access_token()
except Exception as e:
return self._fail(502, "credential proxy: could not load or refresh the "
"credential - see host terminal",
log_message="cannot load credentials: %s" % e)
try:
conn, resp = self._upstream(token, body)
if resp.status == 401:
# Upstream disagrees with expiresAt - refresh once and retry.
resp.read()
conn.close()
self.server.creds.refresh(stale_token=token)
token = self.server.creds.access_token()
conn, resp = self._upstream(token, body)
except Exception as e:
return self._fail(502, "credential proxy: upstream request failed - "
"see host terminal",
log_message="upstream request failed: %s" % e)
try:
self.send_response(resp.status)
length = resp.getheader("Content-Length")
for k, v in resp.getheaders():
if k.lower() in HOP_BY_HOP or k.lower() in ("content-length", "date", "server"):
continue
self.send_header(k, v)
if length is not None:
self.send_header("Content-Length", length)
else:
# Unknown length (chunked SSE): stream until upstream EOF,
# delimited by closing our side too.
self.close_connection = True
self.send_header("Connection", "close")
self.end_headers()
self.wfile.flush()
# Stream response body, flushing after each chunk for unbuffered delivery.
# Use read1() to get partial reads immediately rather than waiting for
# a full buffer or EOF, ensuring SSE events stream unbuffered. HTTPResponse.read1()
# transparently handles chunked transfer-encoding and content-length termination.
while True:
chunk = resp.read1(4096)
if not chunk:
break
self.wfile.write(chunk)
self.wfile.flush()
finally:
conn.close()
# HEAD and OPTIONS included: the method set this accepts should be the
# upstream API's, not whichever handlers happen to be defined here. A
# method with no do_* lands in BaseHTTPRequestHandler's 501, which the
# container sees as the API rejecting it and the host sees as a logged
# error - both misattributed to a request the proxy simply didn't route.
do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = do_OPTIONS = _proxy
class ProxyServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True
def __init__(self, socket_path, upstream, creds):
self.upstream = upstream
self.creds = creds
super().__init__(socket_path, Handler)
def get_request(self):
# BaseHTTPRequestHandler expects a (host, port) client_address;
# a unix-socket accept() returns '' - substitute a dummy pair.
request, _ = self.socket.accept()
return request, ("sandbox", 0)
def handle_error(self, request, client_address):
# The container side going away mid-response - the session
# interrupting a streaming reply, exiting with requests still in
# flight, or abandoning a request it no longer wants - reaches us as a
# broken pipe or a reset while writing the rest of the body. Nothing
# is left to deliver and nothing is wrong, but socketserver's default
# dumps a full traceback for it, onto the same terminal the session is
# drawing its UI on: this proxy runs on the host, sharing that
# terminal. Stay silent for exactly that case. Every other exception
# is a real fault and still gets its traceback.
if isinstance(sys.exc_info()[1], (BrokenPipeError, ConnectionResetError)):
return
super().handle_error(request, client_address)
def main():
p = argparse.ArgumentParser()
p.add_argument("--socket", required=True)
p.add_argument("--credentials", required=True)
p.add_argument("--upstream", default="https://api.anthropic.com")
p.add_argument("--oauth-endpoint", default="https://platform.claude.com/v1/oauth/token")
p.add_argument("--refresh-margin", type=int, default=300)
args = p.parse_args()
creds = Credentials(args.credentials, args.oauth_endpoint, args.refresh_margin)
try:
# A plain read, not access_token(): this is just a startup sanity
# check and must not trigger a refresh (that belongs to the first
# real request, so an external refresher gets a chance to win first).
creds._read()["claudeAiOauth"]["accessToken"]
except Exception as e:
sys.stderr.write(
"dejima-anthropic-proxy: unusable credentials file %s: %s\n"
% (args.credentials, e))
return 1
expires = creds._read()["claudeAiOauth"].get("refreshTokenExpiresAt")
if expires is not None and expires / 1000 < time.time():
sys.stderr.write(
"dejima-anthropic-proxy: the refresh token in %s has "
"expired - run `claude` on the host and log in again first\n"
% args.credentials)
return 1
if os.path.exists(args.socket):
os.unlink(args.socket)
server = ProxyServer(args.socket, urlsplit(args.upstream), creds)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
sys.exit(main())
+7 -3
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.
#
@@ -970,7 +970,11 @@ tcb_snapshot() {
# missed when that layer landed: a tool.conf names a tarball URL and
# the sha256 checked against it, and the host's image build installs
# what it points at as root.
for dir in bin hooks docker test forges workflows harnesses tools; do
#
# backends/ likewise: a backend.conf names the upstream every request
# is forwarded to and the host path the credential is read from, so an
# edit here redirects where a real credential is sent.
for dir in bin hooks docker test forges workflows harnesses tools backends; do
if [ -d "$ws/$dir" ]; then
while IFS= read -r -d '' f; do
stage_entry "tcb:${f#"$ws"/}" "$f"
+189 -14
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
@@ -165,6 +167,30 @@ _config_keys() {
_config_extra_keys
}
# A key that used to exist and now lives somewhere else.
#
# "unknown key" is true and useless on its own: an operator who wrote a key
# dejima once accepted has no way to tell a typo from a move, and the answer
# is in a document they have no reason to think they need. A rename that
# ships without this costs every operator who had the old key the same search.
#
# Keep this to keys that dejima ITSELF moved. A key nobody ever accepted is a
# typo, and guessing at what it was meant to be is worse than saying nothing.
_config_moved_hint() { # <section> <key>
local section="$1" key="$2"
case "$section.$key" in
harness.*.base_url|harness.*base_url)
printf ' Where the model is belongs to the backend now, not the harness.\n' >&2
printf ' For one you serve yourself:\n\n' >&2
printf ' [harness.%s]\n backend = self-hosted\n\n' \
"${section#harness.}" >&2
printf ' [backend.self-hosted]\n base_url = ...\n\n' >&2
printf ' A hosted API is a backend of its own instead, which carries its own\n' >&2
printf ' address - see docs/providers.md, "Model backends".\n' >&2
;;
esac
}
config_root() {
printf '%s' "${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}"
}
@@ -173,6 +199,17 @@ config_path() {
printf '%s/config' "$(config_root)"
}
# The pinned defaults, beside the operator's own file rather than under
# ~/.local/share. It explains the file next to it, and the answer to "why is
# this value what it is" belongs where someone would look for it.
#
# Machine-written, and the operator is told not to edit it - which is why the
# layer it produces is parsed leniently (see _config_parse_defaults) where
# theirs is not.
config_defaults_path() {
printf '%s/defaults.conf' "$(config_root)"
}
# ---------------------------------------------------------------------------
# Dependency specs
#
@@ -250,12 +287,11 @@ valid_tarball_spec() {
# anything that could split a header must not survive validation.
# ---------------------------------------------------------------------------
# A declared domain has to survive being written into a dnsmasq
# 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.
# A hostname, label by label. Here rather than in dejima-project-lib.sh as well,
# so there is one definition and no pair of them to drift apart. The matcher is
# a trust boundary: what it accepts 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.
@@ -264,9 +300,13 @@ valid_tarball_spec() {
# came from a matcher accepting a string the resolver read differently.
#
# Per label: 1-63 characters, starting and ending alphanumeric, hyphens in
# between. That also newly refuses a label with a leading or trailing hyphen,
# which is not a hostname either. A single label with no dot stays legal - an
# internal name resolved by the host's own resolver is a real case.
# between. That also refuses a label with a leading or trailing hyphen, which is
# not a hostname either.
#
# A single label stays legal here, because this also gates host-side consumers
# where an internal name resolved by the host's own resolver is a real case. A
# domain that becomes a permitted destination is narrower and has its own rule:
# valid_permitted_domain in dejima-project-lib.sh, which requires two labels.
valid_domain() {
local label='[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'
[[ "$1" =~ ^$label(\.$label)*$ ]]
@@ -371,6 +411,33 @@ _config_valid_value() {
identifier)
[[ "$value" =~ ^[A-Za-z0-9._-]+$ ]]
;;
modelid)
# A model's name at whatever endpoint serves it, which is not an
# identifier: every hosted catalogue namespaces by vendor with a
# slash (`z-ai/glm-5.3`) and several qualify a variant with a colon
# (`deepseek/deepseek-r1:free`, `llama3:8b`), and Anthropic
# brackets a context variant (`claude-opus-5[1m]`). Typed
# `identifier`, not one OpenRouter id could be configured at all,
# so the backend this harness exists to reach was unusable; the
# first fix for that missed the brackets, which is why the set
# below is the union of what real catalogues spell rather than
# what the last reported id needed.
#
# Still narrow, and for the same reason `path` is: this reaches an
# agent's configuration file. It is written there with jq --arg, so
# the JSON escaping is not what this is protecting - what it
# protects is that a value carrying a newline or a tab would split
# a record every reader of this config expects to be one line.
[[ "$value" =~ ^[A-Za-z0-9][][A-Za-z0-9._:/-]*$ ]]
;;
jsonfield)
# Which value inside a credentials file is the key: dot-separated
# object names, nothing else. No array indices and no jq syntax,
# because the credential proxy walks these segments itself and a
# value that could name an expression would be one that could name
# a computation.
[[ "$value" =~ ^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$ ]]
;;
text)
# A sentence rather than a token - a person's name, an email
# address, a one-line description. An identifier has no room for a
@@ -461,12 +528,21 @@ _config_trim_into() {
# place; nothing shipped does that today (install.sh writes the file but never
# reads a value back).
declare -gA _CFG_VAL=() _CFG_SEEN=()
# The pinned layer, in arrays of its own rather than pre-seeded into the two
# above. Keeping them apart is what lets config_is_set keep meaning "the
# operator wrote this": a base file naming every key would otherwise answer
# yes for all of them, and every caller of it means the operator.
declare -gA _CFG_BASE_VAL=() _CFG_BASE_SEEN=()
_CFG_BASE_STALE=0
_CFG_PARSED_PATH=""
_CFG_PARSE_STATUS=1
config_reset() {
_CFG_VAL=()
_CFG_SEEN=()
_CFG_BASE_VAL=()
_CFG_BASE_SEEN=()
_CFG_BASE_STALE=0
_CFG_PARSED_PATH=""
_CFG_PARSE_STATUS=1
}
@@ -477,6 +553,16 @@ config_validate() {
if [ "$_CFG_PARSED_PATH" = "$conf" ]; then
return "$_CFG_PARSE_STATUS"
fi
# Cleared here rather than by each parser, so that neither one's
# correctness rests on the other having tidied up first. Seeding the
# pinned layer into the operator's arrays would then be a bug that
# SHOWS, instead of one the following parse happens to wipe.
_CFG_VAL=(); _CFG_SEEN=()
_CFG_BASE_VAL=(); _CFG_BASE_SEEN=(); _CFG_BASE_STALE=0
# The pinned layer first, so a value read during the operator's own parse
# resolves the same way one read afterwards does. It cannot fail the
# validation: nothing an operator can fix lives in it.
_config_parse_defaults "$(config_defaults_path)"
# $? captured before anything else runs - an assignment would reset it to
# 0 and cache every malformed config as valid.
_config_parse "$conf"
@@ -487,8 +573,6 @@ config_validate() {
_config_parse() {
local conf="$1" line section="" key value type
_CFG_VAL=()
_CFG_SEEN=()
if [ ! -f "$conf" ]; then
echo "no dejima config at $conf" >&2
return 1
@@ -524,6 +608,7 @@ _config_parse() {
_config_trim_into value "${line#*=}"
if ! _config_type_into type "$section.$key"; then
echo "unknown key in $conf: [$section] $key" >&2
_config_moved_hint "$section" "$key"
return 1
fi
if ! _config_valid_value "$type" "$value"; then
@@ -552,6 +637,65 @@ _config_parse() {
# Expands a leading "~" to $HOME, per element for a list type. Only for path
# types: a tilde inside a URL is a legal path character and must survive.
# The pinned layer. Same grammar as the operator's file and a different
# answer to everything wrong with it: skip the line and carry on, rather than
# refuse the launch.
#
# That is not leniency for its own sake. An unknown key here is a key dejima
# RETIRED, on a machine whose defaults.conf predates the release that dropped
# it - the operator did not write it and is told not to edit the file, so
# refusing would leave them a launch that will not start and nothing to do
# about it. The schema answers for anything skipped, and the count is reported
# once with the command that rebuilds the file.
#
# Absent is not an error either. A checkout that has never been launched has
# no pinned layer, and the schema is the right answer for every key until
# there is one.
_config_parse_defaults() {
local conf="$1" line section="" key value type skipped=0
[ -f "$conf" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
_config_trim_into line "$line"
case "$line" in
''|'#'*|';'*) continue ;;
'['*']')
section="${line#\[}"
section="${section%\]}"
_config_trim_into section "$section"
if ! _config_section_known "$section"; then
# Its keys are skipped with it: an unknown section is one
# whose provider is gone, and every key under it names
# something that no longer exists.
section=""
skipped=$((skipped + 1))
fi
continue ;;
*=*) ;;
*) skipped=$((skipped + 1)); continue ;;
esac
[ -n "$section" ] || { skipped=$((skipped + 1)); continue; }
_config_trim_into key "${line%%=*}"
_config_trim_into value "${line#*=}"
if ! _config_type_into type "$section.$key"; then
skipped=$((skipped + 1)); continue
fi
if ! _config_valid_value "$type" "$value"; then
skipped=$((skipped + 1)); continue
fi
_CFG_BASE_VAL["$section.$key"]="$value"
_CFG_BASE_SEEN["$section.$key"]=1
done < "$conf"
_CFG_BASE_STALE="$skipped"
return 0
}
# How many lines of the pinned layer this process could not use. Read by the
# launch, which says so once with the command that rebuilds it.
config_defaults_stale() {
config_validate >/dev/null 2>&1 || true
printf '%s' "${_CFG_BASE_STALE:-0}"
}
_config_expand() {
local type="$1" value="$2" element out=""
case "$type" in
@@ -589,8 +733,14 @@ _config_get_from_file() {
return 1
fi
config_validate || return 1
# Operator, then what this machine pinned, then the schema. The schema is
# reachable only for a key the pinned layer has no line for - a fresh
# install, or one added since the last ./install.sh - which is the one
# case where dejima's current answer is the right one.
if [ -n "${_CFG_SEEN[$section.$want]+x}" ]; then
found="${_CFG_VAL[$section.$want]}"
elif [ -n "${_CFG_BASE_SEEN[$section.$want]+x}" ]; then
found="${_CFG_BASE_VAL[$section.$want]}"
else
found="$(config_default "$section.$want")"
fi
@@ -603,9 +753,18 @@ _config_get_from_file() {
config_get() { _config_get_from_file "$@"; }
# config_is_set <section> <key>
# Whether the key APPEARS in the config file, as distinct from having a
# value: a key with a schema default always has a value, so "has a value" is
# the wrong question for anything asking what the operator configured.
# Whether the key APPEARS in the OPERATOR'S config file, as distinct from
# having a value: a key with a schema default always has a value, so "has a
# value" is the wrong question for anything asking what the operator
# configured.
#
# The pinned layer is deliberately not consulted, and this is the whole reason
# it lives in arrays of its own. Every caller here means "did the operator
# write this" - the [core] ssh_key / [identity.default] ssh_key refusal fires
# only when both are present, identity settings take precedence over a forge's
# only when the identity names one, and the ttyd knobs treat unset as "the
# page decides from the pointer type", which no host-wide value can see. A
# defaults.conf naming every key would answer yes to all of them.
config_is_set() {
local section="$1" want="$2" _t
if ! _config_type_into _t "$section.$want"; then
@@ -617,6 +776,22 @@ config_is_set() {
[ -n "${_CFG_SEEN[$section.$want]+x}" ]
}
# Which layer answered for a key: user, default (this machine's pinned value)
# or schema (nothing has pinned it yet). What --print-config prints beside a
# value, and the fastest answer to "why is this what it is".
config_origin() {
local section="$1" want="$2" _t
_config_type_into _t "$section.$want" || return 1
config_validate || return 1
if [ -n "${_CFG_SEEN[$section.$want]+x}" ]; then
printf 'user'
elif [ -n "${_CFG_BASE_SEEN[$section.$want]+x}" ]; then
printf 'default'
else
printf 'schema'
fi
}
config_get_bool() {
local value
value="$(config_get "$1" "$2")" || return 1
+777
View File
@@ -0,0 +1,777 @@
#!/usr/bin/env python3
# Host-side credential-injecting proxy for sandboxed sessions.
#
# The container only ever holds a placeholder token; this proxy (launched on
# the host by bin/dejima, reached via a bind-mounted unix socket and an
# in-container loopback forwarder) strips the placeholder and injects the real
# credential, so the credential itself never enters the container. See
# docs/security.md.
#
# NOTHING HERE NAMES A BACKEND. Which upstream, which credential, how it is
# read, which header carries it and what shape that header takes are all
# arguments (#171). The Anthropic arrangement this started as is one set of
# those arguments and no longer a special case in the code, because the moment
# a second backend exists, a proxy that knows one backend's name has to grow
# a branch for every other.
#
# Two credential kinds, and the difference is refresh. A static key is read and
# sent. An OAuth credential expires, so it is refreshed against an endpoint and
# the rotated token written back to the host file atomically - which is what
# keeps a host-side session logged in rather than being signed out by a sandbox
# run. A backend says which kind it has; this decides nothing on its own.
import argparse
import http.client
import json
import os
import socketserver
import sys
import tempfile
import threading
import time
import zlib
from http.server import BaseHTTPRequestHandler
from urllib.parse import urlsplit
# Hop-by-hop headers describe the connection to this proxy rather than the
# request, so they stop here (RFC 9110 7.6.1). Host is deliberately not in
# this set: it has to be rewritten for the upstream, and is replaced in place
# rather than dropped and re-added, which keeps the header block in the order
# the client wrote it - see _upstream.
HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "trailers", "transfer-encoding", "upgrade",
}
# Casing for the headers this proxy has to supply when the client sent none of
# its own. Everything the client did send keeps the client's casing; these are
# only consulted for a header being added rather than replaced. The auth and
# ensure-header names are added to this at startup, in the spelling the
# backend declared, for the same reason.
SUPPLIED_NAMES = {
"host": "Host",
"content-length": "Content-Length",
}
# What an operator has to do when the stored credential can no longer be
# refreshed, in one place. Both the startup check and a refresh that the
# provider rejects reach this, and an operator who reads one and not the other
# would otherwise get the remedy from whichever happened to fire.
# Written to be read in a crowded terminal: the action first, in one short
# sentence, because this is competing with a wall of harness output and an
# operator scanning it should not have to reach the end of a clause to find the
# verb. What went wrong is the subordinate half - by the time this is printed,
# what to do about it is the only part that changes anything.
REAUTH_REMEDY = ("run `claude` on the host to log in again - the stored "
"credential expired and cannot be refreshed")
class CredentialNeedsOperator(RuntimeError):
"""A credential failure whose remedy is a command, not a diagnosis.
Carries two texts. The message is written here rather than derived from a
provider's response, which is what makes it safe to send to the container
as well as the host; `detail` is whatever the provider actually said and
stays host-side. The rule being respected is that upstream bytes and
exception detail do not cross into the session - not that the session must
be told nothing.
"""
def __init__(self, message, detail=None):
super().__init__(message)
self.detail = detail or message
class StaticCredential:
"""A key in a file, sent as-is. No expiry, so nothing to refresh."""
# Trailing newline stripped, because a key file is written by an editor or
# a `>` redirection as often as by a tool, and a newline inside a header
# value is not a cosmetic difference: it would either be rejected or split
# the header block. Refused rather than trimmed if anything is left that
# cannot appear in a header, since a key that needs sanitising is a key
# that was pasted wrong, and guessing at it would send a mangled
# credential upstream and report the backend's rejection as the fault.
# A field, when the key is one value inside a document rather than the
# whole file. Claude Code signed in with a console API token keeps its key
# as primaryApiKey in ~/.claude.json, beside unrelated app state, so a
# backend has to be able to say which value is the credential.
def __init__(self, path, field=None):
self.path = path
self.field = field
def token(self):
with open(self.path) as f:
raw = f.read()
if self.field is None:
value = raw.strip()
else:
try:
doc = json.loads(raw)
except ValueError as e:
raise RuntimeError("%s is not JSON: %s" % (self.path, e))
cur = doc
for seg in self.field.split("."):
if not isinstance(cur, dict) or seg not in cur:
raise RuntimeError(
"%s has no %s" % (self.path, self.field))
cur = cur[seg]
if not isinstance(cur, str):
raise RuntimeError(
"%s: %s is not a string" % (self.path, self.field))
value = cur.strip()
if not value:
raise RuntimeError("%s is empty" % self.path)
bad = [c for c in value if ord(c) < 0x20 or ord(c) == 0x7F]
if bad:
raise RuntimeError("%s contains a control character" % self.path)
return value
def refresh(self, stale_token):
# Nothing to refresh. A 401 from a static key means the key is wrong,
# and retrying with the same bytes would turn one rejection into two.
raise RuntimeError("this credential cannot be refreshed")
@property
def refreshable(self):
return False
def startup_check(self):
self.token()
class OAuthCredential:
def __init__(self, path, oauth_endpoint, client_id, margin):
self.path = path
self.oauth_endpoint = oauth_endpoint
self.client_id = client_id
self.margin = margin
self.lock = threading.Lock()
@property
def refreshable(self):
return True
def startup_check(self):
# A plain read, not token(): this is a startup sanity check and must
# not trigger a refresh (that belongs to the first real request, so an
# external refresher gets a chance to win first).
self._read()["claudeAiOauth"]["accessToken"]
expires = self._read()["claudeAiOauth"].get("refreshTokenExpiresAt")
if expires is not None and expires / 1000 < time.time():
# Concrete, not generic. This class reads Claude Code's own
# credential shape, so the remedy is a command the operator can run
# rather than an instruction to work out.
raise CredentialNeedsOperator(REAUTH_REMEDY)
def _read(self):
with open(self.path) as f:
return json.load(f)
def token(self):
oauth = self._read()["claudeAiOauth"]
if oauth["expiresAt"] / 1000 - time.time() < self.margin:
self.refresh(stale_token=oauth["accessToken"])
oauth = self._read()["claudeAiOauth"]
return oauth["accessToken"]
def refresh(self, stale_token):
with self.lock:
data = self._read()
oauth = data["claudeAiOauth"]
# A concurrent refresher (host-side Claude Code, another sandbox
# session, another thread here) may have replaced the token
# already - if the file no longer holds the stale one and isn't
# near expiry, use what's there instead of refreshing again.
if (oauth["accessToken"] != stale_token
and oauth["expiresAt"] / 1000 - time.time() >= self.margin):
return
u = urlsplit(self.oauth_endpoint)
cls = http.client.HTTPSConnection if u.scheme == "https" else http.client.HTTPConnection
conn = cls(u.netloc, timeout=30)
req = json.dumps({
"grant_type": "refresh_token",
"refresh_token": oauth["refreshToken"],
"client_id": self.client_id,
})
conn.request("POST", u.path, body=req,
headers={"Content-Type": "application/json"})
resp = conn.getresponse()
payload = resp.read()
conn.close()
if resp.status != 200:
# invalid_grant is the provider saying this refresh token is
# finished - not a transient failure, and not something waiting
# or retrying improves. It is what an overnight session meets in
# the morning, and the raw body says `invalid_grant` without
# saying that logging in on the host is the way out.
#
# The body is still logged in full: this only decides which
# remedy to name.
if b"invalid_grant" in payload:
raise CredentialNeedsOperator(
REAUTH_REMEDY,
detail="token refresh failed: HTTP %d %.200s"
% (resp.status, payload))
raise RuntimeError("token refresh failed: HTTP %d %.200s"
% (resp.status, payload))
fresh = json.loads(payload)
oauth["accessToken"] = fresh["access_token"]
if fresh.get("refresh_token"):
oauth["refreshToken"] = fresh["refresh_token"]
oauth["expiresAt"] = int((time.time() + fresh["expires_in"]) * 1000)
self._write(data)
def _write(self, data):
# Atomic and permission-preserving: host-side Claude Code reads this
# same file and must never observe a partial write or a looser mode.
# Staged in a dedicated subdirectory rather than ~/.claude directly,
# which keeps the plaintext window out of any directory the sandbox
# can see, and keeps the rename below same-filesystem (a subdirectory
# of the same parent) so atomicity is unaffected.
#
# A bind mount over the credential would not make this safe: the
# rename below is unaffected by the container's mount, but the
# container's mount is destroyed by the rename, which unlinks the
# dentry it is pinned to. ~/.claude is not mounted whole, so neither
# this directory nor the credential has a path into the container
# (#50, docs/security.md).
directory = os.path.join(os.path.dirname(self.path) or ".", ".credentials-proxy-tmp")
os.makedirs(directory, mode=0o700, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=directory)
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f)
f.flush()
os.fsync(f.fileno())
os.chmod(tmp, 0o600)
os.replace(tmp, self.path)
except BaseException:
os.unlink(tmp)
raise
class AuthShape:
"""How a backend wants its credential presented.
header/format are the injection; strip is what must not travel beside it;
ensure is a comma-list header that has to contain a value, used for the
Anthropic OAuth beta and general enough to need no name of its own here.
"""
def __init__(self, header, fmt, strip, ensure):
self.header = header
self.format = fmt
# The auth header is never also stripped: it is replaced, and listing
# it in both places would drop the injected value instead of sending
# it.
self.strip = frozenset(n.lower() for n in strip) - {header.lower()}
self.ensure = ensure
class BodyTooLarge(Exception):
"""A request body over the cap. Its own type, not ValueError: _read_body
raises ValueError for a genuinely malformed request (a chunk length that
is not hex), and that must still surface as a fault rather than be
answered as if the session had merely sent too much."""
# What an upstream error body says, as text fit to put on a terminal.
#
# Two things stand between the bytes and the log. The client's own
# Accept-Encoding is forwarded verbatim - a Node harness asks for gzip - so
# what comes back is compressed, and the first version wrote those bytes
# straight out: mojibake on the operator's terminal and a bash warning about
# a null byte in a command substitution. And whatever the encoding, this is
# upstream-controlled text landing where dejima-audit already refuses to put
# session-controlled text unescaped.
#
# So: decompress what the stdlib can, then keep only printable characters.
# The sanitising is the half that has to hold - it is not conditional on
# recognising the encoding, and an unknown one degrades to a note rather than
# to bytes.
def _brotli_decompress(raw):
"""A brotli decoder if the host has one, else None.
Optional on purpose. This runs on the host as the operator's user, and a
diagnostic is not worth a hard dependency on a module Debian ships
separately - so a host without it gets a note naming what to install
rather than a launch that refuses.
Not solved by asking the API for an encoding this can read: the request
the API sees is deliberately the harness's own header block, byte for
byte, and Accept-Encoding is part of what makes a session look like a
stock install. Rewriting it to make a log line readable trades a property
the sandbox is for against a convenience.
"""
for name in ("brotli", "brotlicffi"):
try:
mod = __import__(name)
except ImportError:
continue
try:
# A bounded prefix is a partial stream. The one-shot decompress
# wants a whole one, so this is the incremental decoder in both
# modules - and a prefix that ends mid-symbol yields what it can.
return mod.Decompressor().process(raw)
except Exception:
return None
return None
def _readable(raw, encoding):
enc = (encoding or "").strip().lower()
if enc in ("gzip", "x-gzip", "deflate"):
try:
# A bounded prefix is a partial stream, so decompressobj rather
# than gzip.decompress, which wants a whole member.
wbits = 16 + zlib.MAX_WBITS if enc != "deflate" else zlib.MAX_WBITS
raw = zlib.decompressobj(wbits).decompress(raw)
except zlib.error:
return "(%d bytes, %s, undecodable)" % (len(raw), enc)
elif enc == "br":
decoded = _brotli_decompress(raw)
if decoded is None:
return ("(%d bytes, br-encoded - no brotli module on this host, so "
"the message cannot be read here; pip install brotli, or "
"apt install python3-brotli)" % len(raw))
raw = decoded
elif enc:
# zstd and anything else. Saying which is more use than printing the
# bytes, and printing the bytes is what this exists to stop.
return "(%d bytes, %s-encoded)" % (len(raw), enc)
# Decodes only. Making the text fit for a terminal is _diag's, so that
# every producer is covered rather than this one path.
return raw.decode("utf-8", "replace").strip()
# One line, timestamped, appended. Falls back to stderr when the file cannot
# be written rather than losing the message: a diagnostic that disappears
# because the thing that reports problems had a problem is the worst kind.
def _diag(path, line):
# Sanitised at the sink, not at each producer. bin/dejima prints this file
# to the operator's terminal after a session ends, so it is a terminal-
# bound channel carrying text chosen by an upstream API and, through the
# request path and exception messages, by the session itself. That is the
# hazard docs/security.md describes for audit findings, which are
# hex-encoded for it.
#
# Nothing gets through today - CPython's request-line parser and
# http.client both refuse a control character in a URL, and both repr what
# they refuse - but that is two coincidences rather than a property, and
# neither of them covers a producer added later.
line = "".join(c if c.isprintable() or c == " " else " " for c in str(line))
stamped = "%s %s\n" % (time.strftime("%H:%M:%S"), line)
if path:
try:
with open(path, "a") as fh:
fh.write(stamped)
return
except OSError:
pass
sys.stderr.write(stamped)
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_request(self, *args):
pass # per-request lines would interleave with the session's own UI
def log_error(self, fmt, *args):
_diag(getattr(self.server, "diag_path", None),
"dejima-credential-proxy: " + fmt % args)
# How much of an upstream error body is worth keeping. An API's rejection
# is a short JSON object; anything longer is not one, and this runs on the
# host with the session on the other end of it.
MAX_DIAG_BODY = 2048
# Capped, because the client is the sandboxed session and this runs on the
# host as the operator's user. Every other session-controlled size in this
# repo is bounded (dejima-audit's STAGE_FILE_CAP and DIFF_BYTE_CAP,
# dejima-project's declare_entry, the briefing fragment limit), several
# with explicit reasoning about $TMPDIR being tmpfs; this was the one
# place that discipline was not applied, and a request body goes straight
# into host RAM.
#
# 32 MiB is far above any real Messages API request - the largest thing
# one carries is a conversation plus attachments - and far below anything
# that troubles a host. Refused rather than truncated: half a request
# forwarded upstream is a worse outcome than a rejected one.
MAX_BODY_BYTES = 32 * 1024 * 1024
def _read_body(self):
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
chunks = []
total = 0
while True:
size = int(self.rfile.readline().split(b";")[0], 16)
if size == 0:
self.rfile.readline()
return b"".join(chunks)
total += size
if total > self.MAX_BODY_BYTES:
raise BodyTooLarge("%d bytes" % self.MAX_BODY_BYTES)
chunks.append(self.rfile.read(size))
self.rfile.read(2)
length = int(self.headers.get("Content-Length", 0))
if length > self.MAX_BODY_BYTES:
raise BodyTooLarge("%d bytes" % self.MAX_BODY_BYTES)
return self.rfile.read(length)
def _fail(self, status, message, log_message=None):
# log_message (host stderr) may contain upstream response bytes or
# exception detail; message (sent to the container in the response
# body) never should - see docs/security.md.
self.log_error("%s", log_message if log_message is not None else message)
body = json.dumps(
{"type": "error", "error": {"type": "proxy_error", "message": message}}
).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _upstream(self, token, body):
u = self.server.upstream
cls = http.client.HTTPSConnection if u.scheme == "https" else http.client.HTTPConnection
conn = cls(u.netloc, timeout=600)
# Emitted header by header rather than handed to conn.request() as a
# dict, so what the API receives is the client's own header block -
# same names, same casing, same order, duplicates intact - with only
# the values that must change changed. The container half of this
# sandbox goes to some length to make the session indistinguishable
# from a stock install (no ANTHROPIC_* variables, no explicit base
# URL - see docs/security.md); a request rewritten into CPython's
# house style would put the fingerprint straight back on the wire,
# where it is visible to the API rather than only to the client.
#
# conn.request() does exactly that in three ways: a dict collapses
# repeated headers to the last one, dict order puts every header this
# proxy touches at the end of the block, and putrequest() supplies
# `Accept-Encoding: identity` - a CPython tell no browser or Node
# client sends - whenever the caller passed no accept-encoding. The
# skip_* flags below suppress that and the generated Host header,
# which is re-emitted in the client's own position instead.
conn.putrequest(self.command, self.path,
skip_host=True, skip_accept_encoding=True)
auth = self.server.auth
supplied = {
"host": u.netloc,
auth.header.lower(): auth.format.replace("{token}", token),
"content-length": str(len(body)),
}
# An ensure-header is left completely alone when its value is already
# among the client's own - as anthropic-beta's is in the tls transport,
# where the harness believes it holds an OAuth credential. Re-joining a
# list that needs no change would normalize the client's spacing, which
# is the same class of tell as the casing.
for name, wanted in auth.ensure:
low = name.lower()
present = [v.strip() for header in self.headers.get_all(low, [])
for v in header.split(",")]
if wanted in present:
continue
first = self.headers.get(low)
supplied[low] = first + "," + wanted if first else wanted
seen = set()
for name, value in self.headers.items():
low = name.lower()
# The strip set is the backend's, not a fixed list. It exists so a
# header the client used for a credential cannot travel alongside
# the one this proxy injects: x-api-key beside an Authorization is
# two answers to the same question, and which one an API prefers is
# not this program's to guess.
if low in HOP_BY_HOP or low in auth.strip:
continue
if low in supplied:
# One replacement even for a repeated header, or the injected
# token would be sent as many times as the client sent its
# placeholder.
if low in seen:
continue
value = supplied[low]
seen.add(low)
conn.putheader(name, value)
# Only names registered above are emitted, so a value put in `supplied`
# whose name is not in SUPPLIED_NAMES is computed and then silently
# dropped. That is deliberate - it is what keeps this pass from
# inventing a header the backend never declared - but it means the two
# have to be populated from the same place, which is why main() derives
# SUPPLIED_NAMES from the auth header and the ensure list rather than
# listing them.
for low, name in SUPPLIED_NAMES.items():
if low in supplied and low not in seen:
# Content-Length only when there is a body: a bodyless GET
# that carried none must not gain one. A chunked request has
# been de-framed by now, so it lands here and does gain one.
if low == "content-length" and not body:
continue
conn.putheader(name, supplied[low])
conn.endheaders(body)
return conn, conn.getresponse()
def _proxy(self):
try:
body = self._read_body()
except BodyTooLarge as e:
# 413 rather than a traceback and a 500: the session gets a status
# that says what to do about it, and the host log says what came
# in. Nothing is forwarded upstream.
return self._fail(413, "credential proxy: request body too large",
log_message="refused a request body: %s" % e)
try:
token = self.server.creds.token()
except CredentialNeedsOperator as e:
# This one names the remedy in the response as well. The session is
# where the failure is met, the host terminal is behind a window the
# agent cannot read, and "see host terminal" costs an operator the
# trip to find one sentence. Safe to send because the text is this
# file's own - see the class.
# Two lines, detail first. bin/dejima's exit audit prints the LAST
# line of this log when a session ends, so the one an operator reads
# without going looking is the one that says what to do. The
# provider's own words stay directly above it for anyone who does go
# looking.
self.log_error("%s", "cannot load credentials: %s" % e.detail)
return self._fail(502, "credential proxy: %s" % e, log_message=str(e))
except Exception as e:
return self._fail(502, "credential proxy: could not load or refresh the "
"credential - see host terminal",
log_message="cannot load credentials: %s" % e)
try:
conn, resp = self._upstream(token, body)
if resp.status == 401 and self.server.creds.refreshable:
# Upstream disagrees with expiresAt - refresh once and retry.
# Only for a credential that can be refreshed: retrying a
# static key sends the same bytes and turns one rejection into
# two, with the second one looking like a flapping upstream.
resp.read()
conn.close()
self.server.creds.refresh(stale_token=token)
token = self.server.creds.token()
conn, resp = self._upstream(token, body)
except CredentialNeedsOperator as e:
# The refresh on the 401 retry above can reach this, and it is not
# an upstream failure - reporting it as one sends whoever reads it
# looking at the provider's availability instead of at their own
# login. Same remedy as the load path, for the same reason.
self.log_error("%s", "cannot refresh credentials: %s" % e.detail)
return self._fail(502, "credential proxy: %s" % e, log_message=str(e))
except Exception as e:
return self._fail(502, "credential proxy: upstream request failed - "
"see host terminal",
log_message="upstream request failed: %s" % e)
try:
length = resp.getheader("Content-Length")
# An upstream rejection, said out loud. The proxy is the only
# thing that sees both the request the session made and the answer
# the API gave, and it used to forward a 4xx verbatim and record
# nothing - so a session that "spun for a bit and gave an error"
# left the operator with no way to find out why. It is not the
# container's job to explain: the harness shows whatever it shows.
#
# Read whole only when upstream declared a small length. A
# streaming or oversized body is logged by status alone rather
# than buffered here, which keeps this off the success path
# entirely - a 200 never gets read into memory.
early = None
if resp.status >= 400:
# Read a bounded prefix whatever the framing. The first
# version did this only where upstream declared a small
# Content-Length, and the first real rejection it caught -
# three 429s from a rate-limited account - recorded the status
# and nothing else, which is the half an operator cannot act
# on. read1 returns what has arrived rather than waiting for a
# full buffer, so this costs one chunk and is forwarded below
# before the stream continues normally.
early = resp.read1(self.MAX_DIAG_BODY)
detail = _readable(early, resp.getheader("content-encoding"))
# The headers carry more than the body does, which is not a
# guess: a real rate-limited call came back
# {"type":"rate_limit_error","message":"Error"} - the word
# Error and nothing else. request-id is what Anthropic support
# asks for, and retry-after is the only thing that says how
# long; neither is in the body, and a reader should not have to
# know to go looking for them.
#
# Deliberately not the account and workspace ids the same
# response carries. They identify the operator's org, this
# line gets pasted into bug reports, and request-id is enough
# to find the request from the other side.
extra = ""
for name in ("request-id", "retry-after"):
value = resp.getheader(name)
if value:
extra += " %s=%s" % (name, value)
self.log_error("upstream answered %s for %s%s%s",
resp.status, self.path, extra,
" " + detail if detail else "")
self.send_response(resp.status)
for k, v in resp.getheaders():
if k.lower() in HOP_BY_HOP or k.lower() in ("content-length", "date", "server"):
continue
self.send_header(k, v)
if length is not None:
self.send_header("Content-Length", length)
else:
# Unknown length (chunked SSE): stream until upstream EOF,
# delimited by closing our side too.
self.close_connection = True
self.send_header("Connection", "close")
self.end_headers()
self.wfile.flush()
if early:
# Already read for the log above; written out before the loop
# picks up where it left off, so the session receives the same
# bytes in the same order whether or not anything was logged.
self.wfile.write(early)
self.wfile.flush()
# Stream response body, flushing after each chunk for unbuffered delivery.
# Use read1() to get partial reads immediately rather than waiting for
# a full buffer or EOF, ensuring SSE events stream unbuffered. HTTPResponse.read1()
# transparently handles chunked transfer-encoding and content-length termination.
while True:
chunk = resp.read1(4096)
if not chunk:
break
self.wfile.write(chunk)
self.wfile.flush()
finally:
conn.close()
# HEAD and OPTIONS included: the method set this accepts should be the
# upstream API's, not whichever handlers happen to be defined here. A
# method with no do_* lands in BaseHTTPRequestHandler's 501, which the
# container sees as the API rejecting it and the host sees as a logged
# error - both misattributed to a request the proxy simply didn't route.
do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = do_OPTIONS = _proxy
class ProxyServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True
def __init__(self, socket_path, upstream, creds, auth, diag_path=None):
self.upstream = upstream
self.creds = creds
self.auth = auth
self.diag_path = diag_path
super().__init__(socket_path, Handler)
def get_request(self):
# BaseHTTPRequestHandler expects a (host, port) client_address;
# a unix-socket accept() returns '' - substitute a dummy pair.
request, _ = self.socket.accept()
return request, ("sandbox", 0)
def handle_error(self, request, client_address):
# The container side going away mid-response - the session
# interrupting a streaming reply, exiting with requests still in
# flight, or abandoning a request it no longer wants - reaches us as a
# broken pipe or a reset while writing the rest of the body. Nothing
# is left to deliver and nothing is wrong, but socketserver's default
# dumps a full traceback for it, onto the same terminal the session is
# drawing its UI on: this proxy runs on the host, sharing that
# terminal. Stay silent for exactly that case. Every other exception
# is a real fault and still gets its traceback.
if isinstance(sys.exc_info()[1], (BrokenPipeError, ConnectionResetError)):
return
super().handle_error(request, client_address)
def main():
p = argparse.ArgumentParser()
p.add_argument("--socket", required=True)
p.add_argument("--credentials", required=True)
# No default for any of these, deliberately. A default upstream or a
# default credential kind would be one backend's arrangement wearing the
# name of every backend's, which is the shape #171 was filed about: the
# caller says which backend this is, and this program has no opinion.
p.add_argument("--upstream", required=True)
p.add_argument("--credential-kind", required=True,
choices=("static", "anthropic-oauth"))
# Which value in the credentials file is the key, for the static kind.
# Absent means the whole file is the key.
p.add_argument("--credential-field", default=None)
p.add_argument("--auth-header", default="Authorization")
# "{token}" rather than a scheme name, so a backend that wants something
# other than "Bearer x" - a bare key, a prefix of its own - needs no code
# here.
p.add_argument("--auth-format", default="Bearer {token}")
# Repeatable, and replacing rather than adding to a default: a backend
# states what must not travel beside its credential, and inheriting
# another backend's list would be a guess.
p.add_argument("--strip-header", action="append", default=[],
metavar="NAME")
p.add_argument("--ensure-header", action="append", default=[],
metavar="NAME=VALUE")
p.add_argument("--oauth-endpoint")
p.add_argument("--oauth-client-id")
p.add_argument("--refresh-margin", type=int, default=300)
# Where diagnostics go. A file rather than stderr, because stderr here is
# the operator's terminal and the session owns it: a line written mid-run
# lands in the middle of whatever the harness is drawing. bin/dejima reads
# this back after the container exits, alongside the exit audit.
#
# Absent, they go to stderr, which is what a proxy run by hand wants.
p.add_argument("--log", default=None,
help="append diagnostics here instead of stderr")
args = p.parse_args()
if "{token}" not in args.auth_format:
sys.stderr.write("dejima-credential-proxy: --auth-format must contain "
"{token}, or the credential is never sent\n")
return 2
ensure = []
for item in args.ensure_header:
if "=" not in item:
sys.stderr.write("dejima-credential-proxy: --ensure-header wants "
"NAME=VALUE, got %r\n" % item)
return 2
name, _, value = item.partition("=")
if not name or not value:
sys.stderr.write("dejima-credential-proxy: --ensure-header wants "
"NAME=VALUE, got %r\n" % item)
return 2
ensure.append((name, value))
if args.credential_kind == "anthropic-oauth":
if not args.oauth_endpoint or not args.oauth_client_id:
sys.stderr.write("dejima-credential-proxy: anthropic-oauth needs "
"--oauth-endpoint and --oauth-client-id\n")
return 2
creds = OAuthCredential(args.credentials, args.oauth_endpoint,
args.oauth_client_id, args.refresh_margin)
else:
creds = StaticCredential(args.credentials, args.credential_field)
try:
creds.startup_check()
except Exception as e:
sys.stderr.write(
"dejima-credential-proxy: unusable credential %s: %s\n"
% (args.credentials, e))
return 1
auth = AuthShape(args.auth_header, args.auth_format,
args.strip_header, ensure)
# In the spelling the backend declared, for headers this has to add
# rather than replace. A client that sent its own keeps its own casing.
SUPPLIED_NAMES[auth.header.lower()] = auth.header
for name, _ in ensure:
SUPPLIED_NAMES.setdefault(name.lower(), name)
if os.path.exists(args.socket):
os.unlink(args.socket)
server = ProxyServer(args.socket, urlsplit(args.upstream), creds, auth,
diag_path=args.log)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
sys.exit(main())
+367
View File
@@ -0,0 +1,367 @@
#!/usr/bin/env python3
# Finds credential-shaped values in the host files dejima copies into a
# container, and prints what it found WITHOUT printing any value.
#
# This exists because the alternative does not scale. Two leaks were found by
# hand in one week - a console API key in ~/.claude.json, credential helpers
# in ~/.gitconfig - and both fixes were denylists: they encode exactly the
# knowledge dejima did not have the week before. A harness's authentication is
# not a stable interface, so the next field added leaks silently unless
# something is looking (#291).
#
# NOTHING HERE DECIDES ANYTHING. It classifies and reports; bin/dejima asks
# the operator and applies the answer. Keeping the judgement out of here is
# what lets the same classifier run in a test without a terminal.
#
# THE ONE INVARIANT: a credential value never leaves this program. Not in a
# finding, not in an error, not in a traceback. What a caller gets is a
# redacted path and a shape name - enough to report a bug to whoever ships the
# harness, and useless to anyone who intercepts it.
#
# Which is why the caller never sees a real path either. In a gitconfig the
# userinfo sits in the SECTION HEADER, so `url.https://me:PAT@github.com/` is
# a path that IS a credential. Findings carry a redacted path for a human and
# an opaque id for matching, and --scrub takes ids back - so real paths stay
# inside this program, which is the only place that has to hold them.
import argparse
import hashlib
import json
import math
import re
import sys
# Anything that looks like credentials in a URL, wherever it appears.
USERINFO = re.compile(r"(?P<scheme>[a-z][a-z0-9+.-]*://)(?P<user>[^/@\s:]*):"
r"[^/@\s]+@")
def redact(text):
"""A path an operator can recognise, with any embedded secret removed."""
return USERINFO.sub(lambda m: "%s%s:***@" % (m.group("scheme"),
m.group("user")), text)
def finding_id(label, path):
"""Stable across runs, and reveals nothing about what it identifies.
Keyed on the real path, so a decision survives the credential itself
rotating - the operator answered about a field, not about a value."""
h = hashlib.sha256(("%s\t%s" % (label, path)).encode("utf-8"))
return h.hexdigest()[:16]
# Vendor prefixes, which are worth naming separately from the heuristics: a
# match here is not a guess, and the shape name tells an operator exactly what
# they are looking at without showing it to them.
KNOWN_SHAPES = (
("anthropic-api-key", re.compile(r"sk-ant-")),
("openrouter-key", re.compile(r"sk-or-")),
("openai-key", re.compile(r"^sk-[A-Za-z0-9]{20,}$")),
("github-token", re.compile(r"(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{8,}|github_pat_")),
("slack-token", re.compile(r"xox[baprs]-")),
("aws-access-key-id", re.compile(r"^AKIA[0-9A-Z]{16}$")),
("google-api-key", re.compile(r"^AIza[0-9A-Za-z_-]{35}$")),
("jwt", re.compile(r"^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.")),
("private-key-pem", re.compile(r"^-----BEGIN [A-Z ]*PRIVATE KEY-----")),
("auth-header-value", re.compile(r"(?i)(^|:\s*)(basic|bearer)\s+\S{8,}")),
("url-userinfo", re.compile(r"^[a-z][a-z0-9+.-]*://[^/@\s]+:[^/@\s]+@")),
)
# Names that say "this is a secret", matched as WHOLE WORDS rather than as
# substrings. Substring matching put `auth` inside `oauthAccount` and flagged
# every field of the signed-in account's description, and `tengu_mcp_local_
# oauth_blocked_hosts` along with them.
SUSPECT_WORDS = frozenset((
"key", "apikey", "token", "secret", "secrets", "credential", "credentials",
"password", "passwd", "auth", "authorization", "bearer", "cookie",
"signature", "privatekey",
))
# Secrets are drawn from a restricted alphabet and contain no whitespace:
# base64, hex, base58, or a vendor's own. Prose is not, which is the whole
# discriminator - English text scores 4.0-4.5 bits per character by this
# measure, so entropy ALONE flags every sentence in a config file.
SECRETISH = re.compile(r"^[A-Za-z0-9+/=_.~-]+$")
def words(name):
"""Splits an identifier into lowercase words: camelCase, snake_case and
kebab-case all reduce to the same thing."""
name = re.sub(r"\[\d+\]$", "", name)
parts = re.split(r"[^A-Za-z0-9]+", name)
out = []
for part in parts:
out.extend(w.lower() for w in
re.findall(r"[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+", part))
return out
def suspect_name(path):
"""The leaf, plus its immediate parent.
The parent matters for a gitconfig, where `credential.helper` is a
credential because of the section it sits in. Anything further up is too
broad: a whole subtree named after a feature flag about oauth is not a
subtree of credentials."""
segments = path.split(".")
for seg in segments[-2:]:
if SUSPECT_WORDS & set(words(seg)):
return True
return False
# Shapes that look like secrets to an entropy test and are not. Checked before
# entropy, so a UUID machine id does not cost an operator a prompt.
NOT_SECRET = (
re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"), # uuid
re.compile(r"^[a-z]+://"), # url
re.compile(r"^/"), # absolute path
re.compile(r"^~?/"), # home path
re.compile(r"^\d{4}-\d{2}-\d{2}"), # timestamp
re.compile(r"^[0-9.]+$"), # version/number
)
MIN_ENTROPY_LEN = 32
MIN_ENTROPY_BITS = 4.0
def shannon(value):
if not value:
return 0.0
counts = {}
for ch in value:
counts[ch] = counts.get(ch, 0) + 1
n = float(len(value))
return -sum((c / n) * math.log(c / n, 2) for c in counts.values())
def classify(path, value):
"""Returns (verdict, shape) with verdict in credential|suspect|None.
Never returns any part of `value`.
"""
if not isinstance(value, str) or not value.strip():
return (None, None)
v = value.strip()
for shape, pattern in KNOWN_SHAPES:
if pattern.search(v):
return ("credential", shape)
if any(p.match(v) for p in NOT_SECRET):
return (None, None)
# Both remaining rules require a value that could BE a secret: one token,
# restricted alphabet, no whitespace. Without that, a field called
# "authDescription" holding a sentence is a finding.
if not SECRETISH.match(v):
return (None, None)
if suspect_name(path) and len(v) >= 8:
return ("suspect", "named-field")
if len(v) >= MIN_ENTROPY_LEN and shannon(v) >= MIN_ENTROPY_BITS:
return ("suspect", "high-entropy")
return (None, None)
def walk_json(doc, prefix=""):
if isinstance(doc, dict):
for k, v in doc.items():
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k))
elif isinstance(doc, list):
# Indexed, so a decision recorded against one element does not silently
# cover a different one later.
for i, v in enumerate(doc):
yield from walk_json(v, "%s[%d]" % (prefix, i))
else:
yield (prefix, doc)
def ini_logical_lines(text):
"""Yields (raw_lines, logical_text) with backslash continuations joined.
A gitconfig value may run across lines, and both reading and removing have
to treat the whole run as one thing. Reading it line by line finds a
credential under a key that does not exist - `password=...` on a
continuation of `helper = !echo \\` parses as its own key - and removing
one line of it leaves the rest behind."""
lines = text.splitlines(True)
i = 0
while i < len(lines):
raws = [lines[i]]
logical = lines[i].rstrip("\n")
while logical.rstrip().endswith("\\") and i + 1 < len(lines):
i += 1
raws.append(lines[i])
logical = logical.rstrip()[:-1] + lines[i].rstrip("\n")
yield (raws, logical.strip())
i += 1
def ini_entries(text):
"""Yields (raw_lines, kind, key, value) where kind is section|pair|other."""
section = ""
for raws, line in ini_logical_lines(text):
if not line or line.startswith("#") or line.startswith(";"):
yield (raws, "other", None, None)
continue
if line.startswith("[") and line.endswith("]"):
inner = line[1:-1].strip()
m = re.match(r'^(\S+)\s+"(.*)"$', inner)
if m:
section = "%s.%s" % (m.group(1), m.group(2))
# The subsection text is part of the key, and a URL with
# userinfo in it is a credential sitting in a section header.
yield (raws, "section", section, m.group(2))
else:
section = inner
yield (raws, "section", section, None)
continue
if "=" not in line:
yield (raws, "other", None, None)
continue
k, _, v = line.partition("=")
key = "%s.%s" % (section, k.strip()) if section else k.strip()
yield (raws, "pair", key, v.strip())
def walk_ini(text):
for _raws, kind, key, value in ini_entries(text):
if kind in ("section", "pair") and value is not None:
yield (key, value)
def delete_json_path(doc, path):
# Mirrors walk_json's construction exactly, including list indices.
parts = re.findall(r"[^.\[\]]+|\[\d+\]", path)
cur = doc
for part in parts[:-1]:
cur = cur[int(part[1:-1])] if part.startswith("[") else cur[part]
last = parts[-1]
if last.startswith("["):
del cur[int(last[1:-1])]
else:
del cur[last]
def drop_ini_paths(text, paths):
"""Removes whole sections for a section-header finding, whole logical
lines otherwise. Line-based rather than via a parser, so a file this
cannot fully model is changed only where it matched - but a match takes
every line of the value with it."""
drop = set(paths)
out = []
in_dropped_section = False
for raws, kind, key, _value in ini_entries(text):
if kind == "section":
# A finding on the header itself takes the section with it: the
# secret is the header, and the keys under it belong to it.
in_dropped_section = key in drop
if in_dropped_section:
continue
out.extend(raws)
continue
if in_dropped_section:
continue
if kind == "pair" and key in drop:
continue
out.extend(raws)
return "".join(out)
def load_list(path):
if not path:
return []
out = []
try:
with open(path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
out.append(line)
except OSError:
pass
return out
def main():
p = argparse.ArgumentParser()
p.add_argument("--file", required=True)
p.add_argument("--format", required=True, choices=("json", "ini"))
# What this file is called where an operator would recognise it, rather
# than the temp path of the copy being scanned.
p.add_argument("--label", required=True)
# Paths judged safe, and paths already decided. Both are plain lists of
# "<label>\t<path>" so bin/dejima can build them without a parser.
p.add_argument("--known-safe", default=None)
p.add_argument("--decided", default=None)
# Ids to remove from the file, in place. The caller never had the real
# paths, so this is the only way it can ask for one to go.
p.add_argument("--scrub", default=None)
args = p.parse_args()
try:
with open(args.file) as f:
text = f.read()
except OSError as e:
# The filename, never the contents.
print("cannot read %s: %s" % (args.label, e.strerror), file=sys.stderr)
return 2
if args.format == "json":
try:
pairs = list(walk_json(json.loads(text)))
except ValueError:
print("%s is not readable as JSON" % args.label, file=sys.stderr)
return 2
else:
pairs = list(walk_ini(text))
if args.scrub:
wanted = set(load_list(args.scrub))
drop = [path for path, value in pairs
if finding_id(args.label, path) in wanted]
if not drop:
return 0
if args.format == "json":
doc = json.loads(text)
for path in drop:
delete_json_path(doc, path)
out = json.dumps(doc, indent=2) + "\n"
else:
out = drop_ini_paths(text, drop)
with open(args.file, "w") as f:
f.write(out)
return 0
# Two different keys, deliberately.
#
# A judged-safe entry is a "<label>\t<path>" written by hand, and it
# covers the subtree beneath it - `customApiKeyResponses.approved` has to
# answer for `...approved[0]` and every sibling, or an array of the same
# judged-safe thing asks once per element.
#
# A decision is an opaque id and matches ONE path exactly. Broadening
# those the same way would let one answer about one field quietly cover
# fields the operator never saw.
safe = [e for e in load_list(args.known_safe) if "\t" in e]
decided = set(load_list(args.decided))
def judged_safe(entry):
for s_entry in safe:
if entry == s_entry or entry.startswith(s_entry + ".") \
or entry.startswith(s_entry + "["):
return True
return False
for path, value in pairs:
fid = finding_id(args.label, path)
if fid in decided or judged_safe("%s\t%s" % (args.label, path)):
continue
verdict, shape = classify(path, value)
if verdict:
# verdict, id, label, redacted path, shape. No value, and no
# length either - a length is a hint about a secret.
print("%s\t%s\t%s\t%s\t%s"
% (verdict, fid, args.label, redact(path), shape))
return 0
if __name__ == "__main__":
sys.exit(main())
+37
View File
@@ -0,0 +1,37 @@
# Findings judged safe, so the scanner does not ask about them.
#
# Every line is "<label>\t<path>", and every line needs a reason. This file is
# the allowlist half of #291: the scanner refuses to be quiet by default, and
# adding a line here is the deliberate act that makes it quiet about one
# thing. A line with no reason is a line nobody reviewed.
#
# Judge on what the value IS, not on whether leaking it would matter. "It is
# only an email address" is a decision about consequence; this file is for
# decisions about identity.
#
# An entry covers the subtree beneath it, so `a.b` answers for `a.b[0]` and
# `a.b.c`. That is what makes an array of the same judged-safe thing one line
# rather than one per element - and it is why a broad entry is a broad grant.
# Name the narrowest path that covers what you meant.
# Hashes of API keys the operator approved on the host, not the keys. Claude
# Code stores the last 20 characters, so it is a fragment rather than a
# credential - and dropping it re-prompts inside the sandbox for a decision
# already made outside it.
~/.claude.json customApiKeyResponses.approved
~/.claude.json customApiKeyResponses.rejected
# Installation identifiers. Not credentials: they authenticate nothing, and
# they are why a session's own telemetry and cache lookups match the host's.
~/.claude.json userID
~/.claude.json machineID
# The signed-in account's own description - address, organisation, roles.
# Identifying, and deliberately kept: a session's UI reflects the real account,
# which is what makes it recognisable as yours. It grants nothing on its own.
~/.claude.json oauthAccount.emailAddress
~/.claude.json oauthAccount.accountUuid
~/.claude.json oauthAccount.organizationUuid
~/.claude.json oauthAccount.organizationName
~/.claude.json oauthAccount.organizationRole
~/.claude.json oauthAccount.workspaceRole
+43 -2
View File
@@ -128,6 +128,30 @@ harness_plugin_load() {
fi
# shellcheck disable=SC1090
source "$plugin"
# Resolve this harness's key schema now, in the caller's own shell.
#
# A plugin reads its settings through harness_config_get, and its callers
# reach it from inside subshells - a process substitution, a pipeline. A
# subshell inherits this shell's variables, so warming the cache here means
# those never re-derive it; without this they each start cold, because a
# cache filled in a subshell dies with it.
#
# That is a correctness fix and not only a speed one (#284).
# dejima-conversation-ship re-resolves its transcript paths from a SIGTERM
# handler, and bash reports 128+n for commands completing while it is
# delivering a signal - measured, _config_valid_value returned 0 and its
# caller read 143. So a perfectly valid manifest was rejected inside the
# trap, "unknown config key" was printed, and the tail of the session's
# transcript went unshipped. Resolved before any signal is possible, the
# handler answers from cache and has nothing left to fail at.
#
# Failure is ignored on purpose: an unreadable manifest is the caller's to
# report when it asks for a value, in its own words, not this function's to
# turn into a plugin-load error.
if declare -F _provider_keys_field >/dev/null 2>&1; then
_provider_keys_field harness "$name" >/dev/null 2>&1 || true
fi
}
# Calls one hook of the loaded plugin, if it defines it. Absence is normal:
@@ -368,9 +392,26 @@ harness_config_dirs_load() {
DEJIMA_STAGED_CONFIG=()
# host path, and the path under $HOME it is installed at.
# `ensure` asks for a placeholder at that path in the container when the host
# has none, so a harness that needs a file to exist gets one without dejima
# creating it on the operator's machine.
#
# Two kinds, because empty is not always a valid file. `ensure` writes nothing;
# `ensure-json` writes `{}`, and settings.json needs that one - an empty file
# is not valid JSON, and Claude Code discards a settings.json it cannot parse
# wholesale, taking every permission, hook and plugin the operator configured
# with it.
#
# Opt-in per file rather than implied: an empty statusline.sh would be
# installed executable and run as the status line, so that one stays absent.
emit_staged_config() {
local src="$1" rel="$2"
local src="$1" rel="$2" ensure="${3:-}"
[ -n "$src" ] && [ -n "$rel" ] || return 0
case "$ensure" in
''|ensure|ensure-json) ;;
*) echo "Harness plugin emitted an invalid staged-config flag: $ensure" >&2
exit 1 ;;
esac
# The relative path becomes a location under $HOME inside the container.
# Anything that could climb out of it, or that is not relative at all, is
# a plugin bug rather than something to normalise quietly.
@@ -379,7 +420,7 @@ emit_staged_config() {
echo "Harness plugin emitted an invalid staged-config path: $rel" >&2
exit 1 ;;
esac
DEJIMA_STAGED_CONFIG+=("$src"$'\t'"$rel")
DEJIMA_STAGED_CONFIG+=("$src"$'\t'"$rel"$'\t'"$ensure")
}
harness_staged_config_load() {
+4 -4
View File
@@ -162,7 +162,7 @@ case "$action" in
echo "dependencies:"
report dependencies valid_dep_spec
echo "allowed-domains:"
report allowed-domains valid_domain
report allowed-domains valid_permitted_domain
echo "corpus:"
report corpus valid_corpus_rule
echo "tools:"
@@ -170,12 +170,12 @@ case "$action" in
;;
approve)
pending_deps=$(classify dependencies valid_dep_spec | sed -n 's/^pending //p')
pending_doms=$(classify allowed-domains valid_domain | sed -n 's/^pending //p')
pending_doms=$(classify allowed-domains valid_permitted_domain | sed -n 's/^pending //p')
pending_corpus=$(classify corpus valid_corpus_rule | sed -n 's/^pending //p')
pending_tools=$(classify tools valid_tool_name | sed -n 's/^pending //p')
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_domain; classify corpus valid_corpus_rule; classify tools valid_tool_name; } | sed -n 's/^rejected //p')
rejected=$( { classify dependencies valid_dep_spec; classify allowed-domains valid_permitted_domain; classify corpus valid_corpus_rule; classify tools valid_tool_name; } | sed -n 's/^rejected //p')
trunc_deps=$(classify dependencies valid_dep_spec | sed -n 's/^truncated //p')
trunc_doms=$(classify allowed-domains valid_domain | sed -n 's/^truncated //p')
trunc_doms=$(classify allowed-domains valid_permitted_domain | sed -n 's/^truncated //p')
if [ -n "$trunc_deps" ]; then
echo "Warning: dependencies declares $trunc_deps entries; only the first $DEJIMA_MAX_DECLARED were considered." >&2
fi
+41 -1
View File
@@ -262,6 +262,27 @@ valid_tool_name() {
provider_dir tool "$1" >/dev/null 2>&1
}
# A domain a project may ask to reach, which is narrower than a valid host
# name: it has to name at least two labels.
#
# A permitted name covers everything under it - the resolver is configured per
# name and matches the name and its subdomains, with no exact-name form to use
# instead - so a bare label grants its whole suffix, and `org` would ask for
# every .org name. The floor is what keeps one entry from being that large.
#
# `valid_domain` stays as it is, because it also gates host-side consumers
# where a single label is an ordinary thing to write. This is the rule for
# entries that reach the firewall, refused at declaration time so the operator
# is told while they are looking at the file rather than by a sandbox that
# will not start.
valid_permitted_domain() {
valid_domain "$1" || return 1
case "$1" in
*.*) return 0 ;;
esac
return 1
}
valid_corpus_rule() {
case " $DEJIMA_CORPUS_RULES " in
*" $1 "*) return 0 ;;
@@ -291,6 +312,19 @@ approve_entry() {
local store="$1" kind="$2" entry="$3" validator
validator="$(_approved_validator "$kind")" || return 1
"$validator" "$entry" || { echo "Invalid $kind entry: $entry" >&2; return 1; }
# Granting is where the narrower rule belongs, not reading. An approval
# already in the store was valid when it was made, and a rule that tightens
# afterwards must not turn it into the corruption message below - that
# message means tampering, and it would be saying so about the operator's
# own file on every launch.
if [ "$kind" = allowed-domains ] && ! valid_permitted_domain "$entry"; then
echo "Not a domain this can permit: $entry" >&2
echo "A permitted destination has to name at least two labels. The" >&2
echo "sandbox's resolver does not forward a dotless query, and a bare" >&2
echo "label would be permitted as a suffix, covering every name under" >&2
echo "it. Use the fully qualified name." >&2
return 1
fi
touch "$store/$kind"
grep -qxF "$entry" "$store/$kind" || printf '%s\n' "$entry" >> "$store/$kind"
}
@@ -481,7 +515,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 +608,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
}
+438 -38
View File
@@ -28,36 +28,65 @@ MAX_MANIFEST_LINES=200
# filling this on first use meant filling it again in every `$(...)` that
# reached it, which was a fork apiece and hundreds of them per launch.
_PROVIDER_ROOT_CACHE="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd)"
provider_root() {
# Assigns rather than prints, so a caller reaching it in a loop does not pay a
# `$( )` fork per call. provider_root below is the printing form for everyone
# who wants a value in a substitution anyway.
_provider_root_into() {
if [ -n "${DEJIMA_PROVIDER_ROOT:-}" ]; then
printf '%s' "$DEJIMA_PROVIDER_ROOT"
printf -v "$1" '%s' "$DEJIMA_PROVIDER_ROOT"
return 0
fi
# Only the repo path is cached. The test hook is re-read every call, so a
# test that repoints it mid-process still gets the directory it just set.
[ -n "$_PROVIDER_ROOT_CACHE" ] || _PROVIDER_ROOT_CACHE="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd)"
printf '%s' "$_PROVIDER_ROOT_CACHE"
printf -v "$1" '%s' "$_PROVIDER_ROOT_CACHE"
}
_provider_axis_dir() {
case "$1" in
forge) printf 'forges' ;;
workflow) printf 'workflows' ;;
harness) printf 'harnesses' ;;
provider_root() {
local _root
_provider_root_into _root
printf '%s' "$_root"
}
# Same assign-instead-of-print split as _provider_root_into, for the same
# reason: provider_dir is reached once per manifest read.
_provider_axis_dir_into() {
case "$2" in
forge) printf -v "$1" 'forges' ;;
workflow) printf -v "$1" 'workflows' ;;
harness) printf -v "$1" 'harnesses' ;;
# The model API a session talks to, and how its credential is
# presented. Selected per harness rather than in [core], because what
# it configures is the credential proxy rather than the shape of the
# work (#171).
#
# "backend" and not "provider", which is what the industry calls a
# model API: this repo already calls the axes themselves providers, and
# one word for both reads as one thing. A harness's own config keeps
# its own vocabulary - cline writes providers.json with a
# lastUsedProvider key, and bin/harness/cline.sh translates.
backend) printf -v "$1" 'backends' ;;
# Not an axis in the select-one sense the other three are: a launch
# enables zero or more tools, and none is the default. It is here
# because everything else about it is the same - a manifest that is
# data, validated by the same schema machinery, never sourced.
tool) printf 'tools' ;;
*) echo "unknown provider axis: $1" >&2; return 1 ;;
tool) printf -v "$1" 'tools' ;;
*) echo "unknown provider axis: $2" >&2; return 1 ;;
esac
}
_provider_axis_dir() {
local _sub
_provider_axis_dir_into _sub "$1" || return 1
printf '%s' "$_sub"
}
_provider_manifest_name() {
case "$1" in
forge) printf 'forge.conf' ;;
workflow) printf 'workflow.conf' ;;
harness) printf 'harness.conf' ;;
backend) printf 'backend.conf' ;;
tool) printf 'tool.conf' ;;
*) return 1 ;;
esac
@@ -72,6 +101,18 @@ valid_provider_name() {
[[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]
}
# `readlink -m` answers memoized per path, because the containment check below
# resolves the same axis directory once per provider and provider_dir is
# reached from inside each `$( manifest_get ... )`. What is cached is one
# path's resolution, not a verdict: every caller still runs the containment
# comparison and the -d check itself.
declare -gA _PROVIDER_REAL_CACHE=()
_provider_resolve_into() {
[ -n "${_PROVIDER_REAL_CACHE[$2]+set}" ] \
|| _PROVIDER_REAL_CACHE["$2"]="$(readlink -m "$2")"
printf -v "$1" '%s' "${_PROVIDER_REAL_CACHE[$2]}"
}
# Resolved once per root+axis+name. The two readlinks are the containment
# check, and it is called on every manifest read; keying the cache on the
# resolved root rather than just the name is what keeps a test that repoints
@@ -79,19 +120,19 @@ valid_provider_name() {
declare -gA _PROVIDER_DIR_CACHE=()
provider_dir() {
local axis="$1" name="$2" sub root axis_root dir_real id
sub="$(_provider_axis_dir "$axis")" || return 1
_provider_axis_dir_into sub "$axis" || return 1
if ! valid_provider_name "$name"; then
echo "invalid $axis name: $name" >&2
return 1
fi
root="$(provider_root)"
_provider_root_into root
id="$root:$axis:$name"
if [ -n "${_PROVIDER_DIR_CACHE[$id]+set}" ]; then
printf '%s' "${_PROVIDER_DIR_CACHE[$id]}"
return 0
fi
axis_root="$(readlink -m "$root/$sub")"
dir_real="$(readlink -m "$root/$sub/$name")"
_provider_resolve_into axis_root "$root/$sub"
_provider_resolve_into dir_real "$root/$sub/$name"
case "$dir_real/" in
"$axis_root"/*) ;;
*) echo "refusing $axis '$name': it resolves outside $axis_root" >&2
@@ -122,17 +163,28 @@ _config_extra_type_valid() {
esac
}
provider_list() {
local axis="$1" sub entry name
sub="$(_provider_axis_dir "$axis")" || return 1
for entry in "$(provider_root)/$sub"/*/; do
# Fills _PROVIDER_LIST_OUT rather than printing, so the warm-up at the bottom
# of this file can walk all three axes without a `$( )` fork apiece.
declare -ga _PROVIDER_LIST_OUT=()
_provider_list_into() {
local axis="$1" sub root entry name
_PROVIDER_LIST_OUT=()
_provider_axis_dir_into sub "$axis" || return 1
_provider_root_into root
for entry in "$root/$sub"/*/; do
[ -d "$entry" ] || continue
name="$(basename "$entry")"
name="${entry%/}"
name="${name##*/}"
valid_provider_name "$name" || continue
printf '%s\n' "$name"
_PROVIDER_LIST_OUT+=("$name")
done
}
provider_list() {
_provider_list_into "$1" || return 1
[ "${#_PROVIDER_LIST_OUT[@]}" -eq 0 ] || printf '%s\n' "${_PROVIDER_LIST_OUT[@]}"
}
# Manifest schema. Same three-column shape as the config table: key, type,
# default. Types are the config file's own, so one validator set covers both.
_manifest_table() {
@@ -190,6 +242,54 @@ autonomy enum:commit|push-branch|open-pr commit
briefing ?relpath
EOF
;;
# credential = none is an endpoint that needs no credential - a model
# the operator serves. There is nothing to withhold, so no proxy runs
# and neither upstream nor proxied_domain applies: the session reaches
# it directly, and where it is comes from the operator's own
# [backend.<name>] base_url rather than from the manifest. Both are
# optional for that reason and bin/dejima requires them where they do
# apply.
#
# api_style is which dialect the endpoint speaks, and it is a property
# of the API rather than of any harness: Anthropic's Messages API and
# an OpenAI-compatible one are different wire formats, so a harness
# that can only speak one has to know which it is being pointed at
# before it writes its own configuration.
#
# A model API and how its credential is presented. Every field here
# is an argument bin/dejima-credential-proxy already takes (#171):
# this axis is what turns "the caller states the arrangement" into
# "a file states it", so adding a backend is adding a directory.
#
# Nothing here is a secret. A backend declares WHERE its credential
# is read from, never what it is - the file it names is read
# host-side per request and never mounted.
#
# WHERE a credential sits is the operator's, so credential_field is
# a `keys` entry rather than a manifest key. A key is not always the
# whole file - Claude Code's console login keeps its key as
# primaryApiKey in ~/.claude.json, beside unrelated app state - and
# the same static arrangement serves a bare key file with the field
# cleared. Two placements of one credential, not two backends.
backend) cat <<'EOF'
upstream ?url
proxied_domain ?hostname
api_style enum:anthropic|openai
credential enum:static|anthropic-oauth|none
auth_header identifier Authorization
auth_format authformat Bearer {token}
strip_headers ?list:identifier
ensure_headers ?list:headerpair
oauth_endpoint ?url
oauth_client_id ?identifier
keys ?list:keyspec
requires ?list:identifier
EOF
;;
# api_styles is which of those dialects this harness can speak. A
# launch pairing it with a backend it has no client for is refused at
# launch rather than failing later with well-formed requests in the
# wrong shape.
harness) cat <<'EOF'
install ?depspec
image_dirs ?list:homepath
@@ -198,8 +298,8 @@ image_env ?list:envpair
command identifier claude
command_path ?path
api_domains ?list:hostname
proxied_api_domain ?hostname
guards ?list:identifier
api_styles ?list:identifier
briefing_args ?args
briefing_path ?homepath
no_confirm_args ?args
@@ -208,6 +308,7 @@ keys ?list:keyspec
resume_command ?template
transcript_update enum:append|rewrite append
briefing ?relpath
assumed_backend ?provider:backend
EOF
;;
*) return 1 ;;
@@ -320,6 +421,26 @@ _provider_valid_value() {
[[ "$sub" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || return 1
[[ "$seg" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]
;;
authformat)
# How the credential is written into its header. "{token}" rather
# than a scheme name, so a provider wanting a bare key or a prefix
# of its own needs no code - and required, because a format
# without it produces a proxy that starts, answers, and sends
# every request unauthenticated. The proxy checks this too; a
# manifest that cannot be loaded is the better place to find out.
case "$value" in *"{token}"*) ;; *) return 1 ;; esac
_provider_valid_value text "$value"
;;
headerpair)
# NAME=VALUE for ensure_headers, where NAME is a header rather
# than an environment variable - lower case and dashes are the
# norm (anthropic-beta), which is exactly what envpair refuses.
case "$value" in *=*) ;; *) return 1 ;; esac
local hname="${value%%=*}" hvalue="${value#*=}"
[ -n "$hname" ] && [ -n "$hvalue" ] || return 1
_provider_valid_value identifier "$hname" || return 1
_provider_valid_value identifier "$hvalue"
;;
relpath)
# Joined to a provider directory, so no absolute paths and no
# traversal - a manifest may only name a file it ships.
@@ -513,12 +634,45 @@ manifest_validate() {
#
# Failures are ignored: a missing or unreadable directory is not this loop's
# to report, and provider_dir refuses it again when something asks.
for _axis in forge workflow harness; do
for _name in $(provider_list "$_axis" 2>/dev/null); do
provider_dir "$_axis" "$_name" >/dev/null 2>&1 || true
#
# One `readlink -m` for every path rather than two per provider. It takes
# several operands and prints one resolved path per line, so the warm-up costs
# a single process instead of one per directory - which is most of what
# sourcing this file costs, and is paid by every `bin/dejima` invocation and
# every test that drives the library in a fresh shell. The batch only fills
# the resolution cache; provider_dir still does its own containment check
# against what it finds there.
_provider_warm_dirs() {
local axis sub root name path i=0
local -a axes=() names=() paths=() lines=()
_provider_root_into root
for axis in forge workflow harness backend; do
_provider_axis_dir_into sub "$axis" || continue
paths+=("$root/$sub")
_provider_list_into "$axis" || continue
for name in "${_PROVIDER_LIST_OUT[@]}"; do
axes+=("$axis")
names+=("$name")
paths+=("$root/$sub/$name")
done
done
done
unset _axis _name
mapfile -t lines < <(readlink -m -- "${paths[@]}" 2>/dev/null)
# A path containing a newline would desync the one-line-per-operand
# pairing. Nothing shipped can produce one - names are validated and the
# root comes from `pwd` - but a mismatched count means the pairing cannot
# be trusted, so the batch is dropped rather than cached wrong. provider_dir
# then resolves each path itself, exactly as it would have.
if [ "${#lines[@]}" -eq "${#paths[@]}" ]; then
for path in "${paths[@]}"; do
_PROVIDER_REAL_CACHE["$path"]="${lines[$i]}"
i=$((i + 1))
done
fi
for i in "${!names[@]}"; do
provider_dir "${axes[$i]}" "${names[$i]}" >/dev/null 2>&1 || true
done
}
_provider_warm_dirs
_manifest_parse() {
local axis="$1" name="$2" dir="$3" id="$4" file line key value type line_count
@@ -613,13 +767,44 @@ manifest_get() {
# `wc -l`, both to avoid a subshell/external-process fork per manifest line:
# this runs once per key in a [forge.<name>]/[harness.<name>] section, on
# every config_validate pass that has not already been answered from cache.
# Cached, and that is a correctness fix as well as a speed one (#284).
#
# Speed: this is reached once per key of a [forge.<name>]/[harness.<name>]
# section on every config_validate pass, and each miss re-reads the manifest
# and revalidates the whole keys list - a fork per element for the keyspec
# parse.
#
# Correctness: dejima-conversation-ship re-resolves its transcript paths from
# a SIGTERM handler, and bash clobbers $? with 128+n when it delivers a pending
# signal at a command boundary. Measured: _config_valid_value returned 0 and
# its caller read 143, so the `|| return 1` chain below failed for a manifest
# that is perfectly valid, the shipper reported "unknown config key" and the
# last lines of the session's transcript went unshipped. Anything that has
# already been resolved once is answered from here instead, so the signal
# handler does no work that a spurious status can fail.
#
# Keyed on the resolved directory rather than the name, like every other cache
# here, so two roots cannot answer for each other.
#
# SUCCESSES ONLY. A remembered refusal was the first shape of this and it is
# the wrong one: it turns one transient failure into a permanent one, and the
# symptom is indistinguishable from the bug this exists to fix - a lookup that
# worked at startup failing for the rest of the process. Nothing here is
# expensive enough to be worth remembering a "no" for.
declare -gA _PROVIDER_KEYS_CACHE=() _PROVIDER_KEYS_SEEN=()
_provider_keys_field() {
local axis="$1" name="$2" dir file line key value="" seen=0 type n=0
local axis="$1" name="$2" dir file line key value="" seen=0 type n=0 id
# An unselected axis contributes no keys - a different question from
# whether a selection is valid. Without this, --print-config prints
# "invalid harness name:" and fails at the thing it is for.
[ -n "$name" ] || return 1
dir="$(provider_dir "$axis" "$name")" || return 1
id="$axis:$dir"
if [ -n "${_PROVIDER_KEYS_SEEN[$id]+set}" ]; then
printf '%s' "${_PROVIDER_KEYS_CACHE[$id]}"
return 0
fi
file="$dir/$(_provider_manifest_name "$axis")"
[ -f "$file" ] || return 1
type="$(_manifest_type "$axis" keys)" || return 1
@@ -637,10 +822,41 @@ _provider_keys_field() {
seen=1
done < "$file"
[ "$seen" = 1 ] || value="$(_manifest_default "$axis" keys)"
# Core's own key on every backend section, appended rather than declared
# by each manifest. Whether to withhold a credential is dejima's question,
# not the backend author's, and a backend that forgot to declare it would
# silently lose the setting.
#
# No default, deliberately: unset means "whatever [credentials] proxy
# says", which is a third state that on/off cannot carry. See
# backend_proxy_enabled.
[ "$axis" != backend ] || value="${value:+$value }proxy:?bool"
_provider_valid_value "$type" "$value" || return 1
_PROVIDER_KEYS_CACHE[$id]="$value"
_PROVIDER_KEYS_SEEN[$id]=ok
printf '%s' "$value"
}
# Is the credential proxy on for this backend?
#
# [backend.<name>] proxy, falling back to [credentials] proxy. Per backend
# because the global switch has the wrong blast radius: an operator with one
# backend the proxy cannot serve had to disable withholding for every backend,
# including the ones it serves perfectly.
#
# Deliberately not config_get_bool, which reports an UNSET optional key as
# off. Unset here means "inherit", and reading it as "disabled" would turn the
# credential proxy off for every backend the moment this key existed.
backend_proxy_enabled() { # <backend-name>
local v=""
[ -n "${1:-}" ] || return 1
if config_type "backend.$1.proxy" >/dev/null 2>&1; then
v="$(config_get "backend.$1" proxy)"
fi
[ -n "$v" ] || v="$(config_get credentials proxy)"
[ "$v" = on ]
}
# A section is known only when a provider directory for its name actually
# exists - provider_dir carries the same name/containment checks
# _config_extra_type_valid already relies on for the provider: config types.
@@ -649,6 +865,7 @@ _config_extra_section_known() {
case "$section" in
forge.*) axis=forge; name="${section#forge.}" ;;
harness.*) axis=harness; name="${section#harness.}" ;;
backend.*) axis=backend; name="${section#backend.}" ;;
# An identity is the operator's own name for a set of credentials, so
# there is no directory to check it against - only that it is a name
# at all. Unlike a provider, an unused one is not a misconfiguration:
@@ -697,6 +914,174 @@ _identity_key_specs() {
done
}
# The pinned defaults file, rendered. Every key that HAS a default, at the
# value this release ships, grouped by section.
#
# Keys with no default are deliberately absent, because there is nothing to
# pin: they are the ones an operator must supply, and "has no line in
# defaults.conf" is exactly what docs/configuration.md marks as required. The
# document and the file agree by construction rather than by someone keeping
# them in step.
#
# EVERY installed provider, not only the selected one. Pinning just the
# harness in use would leave the next one an operator selects handing them
# ITS current defaults - the same failure one axis over, a year later, when
# nothing about their config changed.
# $1: "keep" to take a value already pinned in defaults.conf over the shipped
# one, which is what a rebuild does - it adds keys and drops retired ones
# without moving anything already decided. Empty renders the shipped defaults,
# which is what a first pin does.
config_defaults_render() {
local keep="${1:-}" key type default axis name spec section last=""
printf '# dejima'"'"'s own defaults, pinned when this machine first saw each key.\n'
printf '#\n'
printf '# NOT yours to edit - ./install.sh rewrites it, keeping the values already\n'
printf '# here so a release cannot move a setting you never chose. Put your own\n'
printf '# settings in %s, which overrides everything below and which\n' "$(config_path)"
printf '# dejima never writes.\n'
while IFS= read -r key; do
[ -n "$key" ] || continue
default="$(config_default "$key")" || continue
[ -n "$default" ] || continue
[ "$keep" = keep ] && [ -n "${_CFG_BASE_SEEN[$key]+x}" ] \
&& default="${_CFG_BASE_VAL[$key]}"
section="${key%.*}"
[ "$section" = "$last" ] || { printf '\n[%s]\n' "$section"; last="$section"; }
printf '%s = %s\n' "${key##*.}" "$default"
done < <(_config_keys_core_sorted)
for axis in forge harness backend; do
while IFS= read -r name; do
[ -n "$name" ] || continue
last=""
for spec in $(_provider_keys_field "$axis" "$name" 2>/dev/null); do
IFS=$'\t' read -r key type default <<< "$(_provider_keyspec_parse "$spec")"
[ -n "$default" ] || continue
[ "$keep" = keep ] && [ -n "${_CFG_BASE_SEEN[$axis.$name.$key]+x}" ] \
&& default="${_CFG_BASE_VAL[$axis.$name.$key]}"
[ -n "$last" ] || { printf '\n[%s.%s]\n' "$axis" "$name"; last=1; }
printf '%s = %s\n' "$key" "$default"
done
done < <(provider_list "$axis" 2>/dev/null)
done
}
# The core schema's keys with their sections kept together, so the rendered
# file groups rather than repeating a header per key. The schema's own order
# is otherwise preserved: it is the order docs/configuration.md follows.
_config_keys_core_sorted() {
local key member seen=" " sect
local -a keys=()
while IFS= read -r key; do
[ -n "$key" ] || continue
keys+=("$key")
done < <(_config_table | cut -f1 | grep -E '^[a-z-]+\.[a-z_]+$')
for key in "${keys[@]}"; do
sect="${key%.*}"
case "$seen" in *" $sect "*) continue ;; esac
seen="$seen$sect "
for member in "${keys[@]}"; do
[ "${member%.*}" = "$sect" ] && printf '%s\n' "$member"
done
done
}
# Rebuild the pinned file: keys this release added appended, keys it retired
# dropped, and every value already decided left exactly as it was.
#
# Both halves fall out of rendering with "keep" rather than being two rules
# somebody has to remember, and pruning is not the tidier half: an unknown key
# is one this dejima cannot read, so a file that only ever grew would collect
# them until the notice about it became permanent.
#
# Prints what it did, and what has drifted - a shipped default that no longer
# matches the pinned one - naming both values and the line that would adopt
# it. It does not adopt it. That would be dejima writing a decision into a
# session's behaviour on an upgrade, which is the thing this exists to stop.
config_defaults_rebuild() {
local path tmp key section leaf pinned shipped
local added=0 dropped=0 drifted=0
path="$(config_defaults_path)"
_config_parse_defaults "$path"
dropped="$_CFG_BASE_STALE"
mkdir -p "$(dirname "$path")" || return 1
tmp="$(mktemp "$path.XXXXXX")" || return 1
config_defaults_render keep > "$tmp" || { rm -f "$tmp"; return 1; }
chmod 600 "$tmp"
# Counted from the rendered file rather than guessed at: a key with no
# line in the old one is a key this release added.
while IFS= read -r key; do
[ -n "${_CFG_BASE_SEEN[$key]+x}" ] || added=$((added + 1))
done < <(_config_defaults_keys "$tmp")
if [ ! -e "$path" ]; then
mv "$tmp" "$path" || { rm -f "$tmp"; return 1; }
printf '\nPinned this release'"'"'s defaults in %s.\n' "$path"
return 0
fi
mv "$tmp" "$path" || { rm -f "$tmp"; return 1; }
# Each line carries the blank one before it, so a rebuild with nothing to
# say prints nothing at all rather than a gap where a message would go.
[ "$added" -eq 0 ] || printf '\nPinned %s new default(s) in %s.\n' "$added" "$path"
[ "$dropped" -eq 0 ] || printf '\nDropped %s retired line(s) from %s.\n' "$dropped" "$path"
# Drift, last, because it is the part that asks something of the operator.
while IFS= read -r key; do
[ -n "${_CFG_BASE_SEEN[$key]+x}" ] || continue
pinned="${_CFG_BASE_VAL[$key]}"
shipped="$(config_default "$key")" || continue
[ "$pinned" = "$shipped" ] && continue
[ "$drifted" -eq 0 ] && {
printf '\ndejima ships a different default now for %s:\n' "$path"
}
drifted=$((drifted + 1))
section="${key%.*}"; leaf="${key##*.}"
printf ' [%s] %s: yours stays %s, dejima now ships %s\n' \
"$section" "$leaf" "$pinned" "$shipped"
done < <(_config_defaults_keys "$path")
[ "$drifted" -eq 0 ] || {
printf '\nYours are kept, so nothing about your sandbox changed. To take one,\n'
printf 'write it in %s under its section - never in the file above.\n' "$(config_path)"
}
return 0
}
# "section.key" for each line of a rendered defaults file, in file order.
_config_defaults_keys() {
awk '
/^\[/ { s = $0; gsub(/^\[|\]$/, "", s); next }
/^[a-z_]+ = / && s != "" { split($0, p, " = "); print s "." p[1] }
' "$1"
}
# Write it if it is not there. dejima's own file, so dejima creating it is not
# the kind of write this whole arrangement removes - it is the opposite of
# one. Without it the pin would exist only for operators who ran ./install.sh,
# and dejima runs perfectly well from a checkout without it.
#
# Present is left alone: no rebuild, no prune, no drift check. Those belong to
# ./install.sh, which is when the shipped defaults actually change. Doing them
# per launch would put a standing condition in front of every one.
#
# Prints one line, and only when it creates the file.
config_defaults_ensure() {
local path tmp
path="$(config_defaults_path)"
[ -e "$path" ] && return 0
mkdir -p "$(dirname "$path")" || return 0
tmp="$(mktemp "$path.XXXXXX")" || return 0
if config_defaults_render > "$tmp" 2>/dev/null; then
chmod 600 "$tmp"
mv "$tmp" "$path" || { rm -f "$tmp"; return 0; }
printf 'Pinned dejima'"'"'s current defaults in %s so a later release cannot move them.\n' \
"$path" >&2
else
rm -f "$tmp"
fi
return 0
}
# Looks up one [forge.<name>]/[harness.<name>] key's TYPE<TAB>DEFAULT in that
# provider's `keys` manifest entry instead of a row hardcoded in core.
_config_extra_spec() {
@@ -704,6 +1089,7 @@ _config_extra_spec() {
case "$want" in
forge.*) axis=forge ;;
harness.*) axis=harness ;;
backend.*) axis=backend ;;
identity.*)
rest="${want#identity.}"
key="${rest#*.}"
@@ -889,7 +1275,7 @@ identity_validate() {
# "once" that a session could not reach, and a deprecation nobody sees
# until removal is not a deprecation.
if config_is_set core ssh_key; then
printf ' %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n' \
printf '\n %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n' \
"${C_WARN:-}" "${C_RESET:-}" >&2
printf ' under its old name.\n' >&2
if [ -n "$name" ]; then
@@ -905,8 +1291,10 @@ identity_validate() {
# force it answers with THAT identity's key - and printing it as the
# thing to move would name a key the operator is not being asked to
# touch.
printf ' [identity.default]\n ssh_key = %s\n\n' \
printf ' [identity.default]\n ssh_key = %s\n' \
"$(_config_get_from_file core ssh_key)" >&2
# Worth a pause before the build scrolls it away - see bin/dejima.
DEJIMA_STARTUP_NOTICE=1
fi
[ -n "$name" ] || return 0
@@ -1291,10 +1679,12 @@ the user's machine, with
What this means:
- The workspace directory is bind-mounted read-write and is the real
project. ~/shared is also a real, persistent host directory (not part of
the project) - use it to hand the user a file (a screenshot, a temp
script) without touching the project checkout, or to pick up something
they left there for you. Your harness keeps its own state on the host too,
project. ~/shared is also a real, persistent host directory (this
project's own, and not part of its checkout) - use it to hand the user a
file (a screenshot, a temp script) without touching the project checkout,
or to pick up something they left there for you. It outlives this session
and every session against this project shares it, so treat what is in it
as readable by all of them. Your harness keeps its own state on the host too,
so this session's transcript outlives the container - the work is
recoverable on the host with the command at the end of this
briefing.{{writable_mounts}} Everything else is disposable: installed system
@@ -1450,8 +1840,6 @@ If a task genuinely needs something else the sandbox can't provide -
broader network access than a domain or two, host-level or system changes,
GUI/hardware access - stop and explain clearly to the user what you need
and why, instead of trying to work around the firewall or sandbox yourself.
They can continue this exact session outside the container by running on
the host:
BRIEFING_EOF
}
@@ -1614,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
@@ -1805,9 +2203,11 @@ briefing_compose() {
# The harness fragment closes the briefing: it supplies both the resume
# command closing_a has just promised and the sentence about where the
# session history it resumes from actually lives. Both are the harness's
# own facts - Claude Code resumes from a bind-mounted ~/.claude, Cline
# from a per-project state directory named in the command itself - and
# core stating either one meant stating Claude Code's.
# own facts, INCLUDING whether there is anything to say. Claude Code
# resumes from a bind-mounted ~/.claude and says so; Cline cannot be
# resumed outside dejima at all, and a core sentence promising it could
# was Claude Code's fact stated as everyone's - which left a Cline session
# able to send its operator somewhere that does not work.
#
# $requirements and $project_doc (the missing-dependency report and
# .dejima.md) stay hook-computed and hook-appended after this - they read
+186
View File
@@ -0,0 +1,186 @@
#!/bin/bash
# Where a project's ~/shared lives on the host, and what is in it.
#
# `[core] share_dir` holds one directory per project, and a launch mounts only
# that project's at ~/shared. This is the host side of that: where to put a
# file so the next session against a project finds it, what every project is
# holding, and how to move files left over from when share_dir was mounted
# whole.
#
# Nothing here is a security control. The boundary is the mount bin/dejima
# sets up; this is the operator's view of what is on the other side of it.
#
# Usage: dejima-share <path|list|adopt> [<path>]
set -euo pipefail
case "${1:-}" in
-h|--help)
cat <<'DEJIMA_SHARE_HELP'
dejima-share - the host side of a project's ~/shared
Usage: dejima-share <path|list|adopt> [path]
path [path] print the host directory a launch against `path` mounts at
~/shared, creating it if it does not exist yet - so a file
copied there is waiting for the next session
list every project's share directory, with its size and when it
was last written, plus anything left loose in share_dir
adopt [path] move whatever is loose in share_dir's top level into `path`'s
share directory, after showing it and asking
path defaults to the current directory.
A launch mounts <share_dir>/projects/<slug> at ~/shared, where slug is the
project's absolute path with every non-alphanumeric character replaced by a
dash - the same key the audit and epitaph stores use. Only that directory
crosses into the container, so one project's session cannot read another's
handoff files.
Files that were in share_dir's top level before this split are still there and
are no longer mounted anywhere. `adopt` is for moving them somewhere a session
will see them; it never overwrites.
Nothing here deletes anything by itself, and dejima never prunes share_dir. It
grows until you clear it out - `list` is what tells you what has gone stale.
See docs/configuration.md.
DEJIMA_SHARE_HELP
exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# The provider library rather than the config one directly: [core] harness and
# the other provider-typed keys resolve against the manifests, so config
# validation refuses an ordinary config without it.
# shellcheck source=dejima-provider-lib.sh
source "$SCRIPT_DIR/dejima-provider-lib.sh"
# shellcheck source=dejima-slug-lib.sh
source "$SCRIPT_DIR/dejima-slug-lib.sh"
config_validate || exit 1
action="${1:-}"
[ -n "$action" ] || { echo "Usage: dejima-share <path|list|adopt> [<path>]" >&2; exit 1; }
SHARE_ROOT="$(config_get core share_dir)"
PROJECTS_ROOT="$(share_projects_root "$SHARE_ROOT")"
# A workspace argument becomes a mount path, so it is resolved the way
# bin/dejima resolves the one it is about to mount rather than used as typed.
# A path that does not exist is a typo, and answering it with a share
# directory nothing will ever mount is worse than refusing.
resolve_workspace() {
local given="${1:-$PWD}"
[ -d "$given" ] || { echo "no such directory: $given" >&2; exit 1; }
(cd "$given" && pwd)
}
# Whatever is in share_dir's top level that is not the per-project root. Every
# one of these was visible to every session before the split and is visible to
# none now.
loose_entries() {
find "$SHARE_ROOT" -mindepth 1 -maxdepth 1 ! -name projects -printf '%f\n' \
2>/dev/null | sort
}
case "$action" in
path)
ws="$(resolve_workspace "${2:-}")"
dir="$(share_dir_for "$SHARE_ROOT" "$ws")"
# Created here rather than only at launch, because the point of asking
# is usually to put something in it before the launch happens.
mkdir -p "$dir"
printf '%s\n' "$dir"
;;
list)
if [ ! -d "$PROJECTS_ROOT" ]; then
echo "No project has a share directory yet ($PROJECTS_ROOT does not exist)."
else
found=0
while IFS= read -r d; do
[ -n "$d" ] || continue
found=1
# du and date rather than a stat of the directory itself: the
# directory's own mtime changes when an entry is added or
# removed and not when one is written, which would report a
# share nobody has touched in months as fresh.
size="$(du -sh "$PROJECTS_ROOT/$d" 2>/dev/null | cut -f1)"
newest="$(find "$PROJECTS_ROOT/$d" -type f -printf '%T@\n' 2>/dev/null \
| sort -rn | head -1)"
if [ -n "$newest" ]; then
when="$(date -d "@${newest%.*}" '+%Y-%m-%d' 2>/dev/null || echo '?')"
else
when="empty"
fi
printf '%-10s %-12s %s\n' "${size:-?}" "$when" "$d"
done < <(find "$PROJECTS_ROOT" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' \
2>/dev/null | sort)
[ "$found" = 1 ] || echo "No project has a share directory yet."
fi
loose="$(loose_entries)" || true
if [ -n "$loose" ]; then
echo
echo "Loose in $SHARE_ROOT, mounted into no session:"
while IFS= read -r e; do
[ -n "$e" ] || continue
printf ' %s\n' "$e"
done <<< "$loose"
echo
echo "Move them with 'dejima-share adopt <project path>', or delete them."
fi
echo
echo "Nothing prunes this. Clear out what you no longer need: everything"
echo "in a project's share is readable by every future session against it."
;;
adopt)
ws="$(resolve_workspace "${2:-}")"
dest="$(share_dir_for "$SHARE_ROOT" "$ws")"
loose="$(loose_entries)" || true
if [ -z "$loose" ]; then
echo "Nothing loose in $SHARE_ROOT - every entry is already under projects/."
exit 0
fi
echo "Moving into $ws's share ($dest):"
while IFS= read -r e; do
[ -n "$e" ] || continue
if [ -e "$dest/$e" ]; then
printf ' %-30s SKIP - %s already has one\n' "$e" "$dest"
else
printf ' %s\n' "$e"
fi
done <<< "$loose"
echo
# One question, after showing everything, the way dejima-project
# approve asks - and read from stdin for the same reason it does, so a
# non-interactive caller answering no is the default rather than an
# error about /dev/tty. A move is not undoable by this tool, and the
# files are the operator's.
printf 'Move them? [y/N] '
read -r reply || reply=n
case "$reply" in
y|Y) ;;
*) echo "Nothing moved."; exit 0 ;;
esac
mkdir -p "$dest"
moved=0
while IFS= read -r e; do
[ -n "$e" ] || continue
# Never overwrites: a name that already exists in the destination
# is left where it is and reported, because the two files are both
# the operator's and this cannot know which one they meant.
if [ -e "$dest/$e" ]; then
echo "Skipped $e - $dest already has one" >&2
continue
fi
mv -n "$SHARE_ROOT/$e" "$dest/$e"
moved=$((moved + 1))
done <<< "$loose"
echo "Moved $moved entr$([ "$moved" = 1 ] && echo y || echo ies) into $dest"
;;
*)
echo "Usage: dejima-share <path|list|adopt> [<path>]" >&2
exit 1 ;;
esac
+31 -5
View File
@@ -1,12 +1,15 @@
# Shared library: the ~/.claude/projects slug scheme, and which slugs belong
# to a given workspace. Sourced by bin/dejima (to decide what to mount) and
# bin/dejima-epitaph (to decide whose transcripts to keep). Not on PATH and
# not executable.
# Shared library: the ~/.claude/projects slug scheme, which slugs belong to a
# given workspace, and the share_dir layout keyed by that slug. Sourced by
# bin/dejima (to decide what to mount), bin/dejima-epitaph (to decide whose
# transcripts to keep) and bin/dejima-share. Not on PATH and not executable.
#
# One definition, because the callers must not disagree. A slug the mount
# exposes but a caller treats as foreign is a session whose work is silently
# left behind; a slug a caller treats as the workspace's own but the mount
# does not expose is a hole that nothing reports.
# does not expose is a hole that nothing reports. The share layout is here for
# the same reason: bin/dejima mounts a directory and dejima-share tells an
# operator where to put files in it, and those two naming different places
# would be an operator dropping a file where nothing will ever read it.
# Claude Code's own project key: the absolute cwd with every non-alphanumeric
# replaced by a dash. Matches docker/log-shipper.sh's Loki key and
@@ -14,6 +17,29 @@
# runs inside the container, the other is a separate library).
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
# 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() { 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
+15 -6
View File
@@ -33,7 +33,7 @@ dejima-update-check - say when this dejima checkout is behind its remote
Usage: dejima-update-check
Compares the checkout bin/dejima runs from against the branch it tracks and
prints one line when the remote has commits this checkout does not. Run at
prints a notice when the remote has commits this checkout does not. Run at
startup by bin/dejima; safe to run by hand.
Silent when the checkout is current or ahead, when [core] update_check is off,
@@ -175,16 +175,25 @@ color_init
# fire on the terminal of the operator this is trying to inform.
_remote_shown="$(sanitize "$remote")"
_branch_shown="$(sanitize "${upstream#refs/heads/}")"
# A blank line either side, the indent the other startup warnings carry, and
# C_WARN: a launch prints this between its startup lines and the image build,
# which starts scrolling immediately after, and two plain lines there are
# indistinguishable from either.
printf '\n' >&2
# The count only when it is known. It is not, whenever the remote's tip is a
# commit this checkout has never fetched, which is the common case - and a
# number invented for those would be the number of commits it can see, not the
# number there are.
if [ -n "$count" ] && [ "$count" != "0" ]; then
if [ "$count" = "1" ]; then noun="commit"; else noun="commits"; fi
printf '%sdejima%s: %s/%s has %s %s this checkout does not.\n' \
"$C_KEY" "$C_RESET" "$_remote_shown" "$_branch_shown" "$C_INFO$count$C_RESET" "$noun" >&2
printf ' %sdejima: %s/%s has %s %s this checkout does not.%s\n' \
"$C_WARN" "$_remote_shown" "$_branch_shown" "$count" "$noun" "$C_RESET" >&2
else
printf '%sdejima%s: %s/%s has commits this checkout does not.\n' \
"$C_KEY" "$C_RESET" "$_remote_shown" "$_branch_shown" >&2
printf ' %sdejima: %s/%s has commits this checkout does not.%s\n' \
"$C_WARN" "$_remote_shown" "$_branch_shown" "$C_RESET" >&2
fi
printf ' Update with %sgit -C %s pull%s\n' "$C_KEY" "$REPO_ROOT" "$C_RESET" >&2
# install.sh is safe to re-run, and a release can change what it links on PATH,
# so pulling is only half the update. Both paths absolute because the operator
# reading this is not necessarily standing in this checkout.
printf ' Update with %sgit -C %s pull && %s/install.sh%s\n\n' \
"$C_KEY" "$REPO_ROOT" "$REPO_ROOT" "$C_RESET" >&2
+112 -122
View File
@@ -31,10 +31,16 @@
# Written through a temp file and renamed: the destination is the settings
# layer bin/dejima is about to mount, and a jq that failed partway would
# otherwise leave the session with a truncated one.
# Where dejima's own hooks live inside the container. Baked into the image
# (see docker/Dockerfile) rather than reached through the ~/.claude/hooks
# mount, because that mount is the operator's configuration and dejima should
# not have to write to it to make its own sandbox work.
DEJIMA_HOOK_DIR=/usr/local/lib/dejima/hooks
harness_register_notify() {
local settings="$1" tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-settings-remote.XXXXXX.json")"
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/dejima-notify.sh done"}]}])' \
if jq '.hooks.Stop = ((.hooks.Stop // []) + [{"matcher": "", "hooks": [{"type": "command", "command": "'"$DEJIMA_HOOK_DIR"'/dejima-notify.sh done"}]}])' \
"$settings" > "$tmp"; then
mv "$tmp" "$settings"
else
@@ -107,30 +113,16 @@ harness_state_mounts() {
# what it can host (see `guards` in its manifest), and registering something
# it did not declare would contradict the launch message that says so.
harness_register_guards() {
local repo="$1" backup="$2" settings="$HOME/.claude/settings.json"
local repo="$1" settings="$HOME/.claude/settings.json"
mkdir -p "$HOME/.claude/hooks"
_link_hook() {
local name="$1" src="$repo/hooks/$1" dest="$HOME/.claude/hooks/$1"
if [ -L "$dest" ] && [ "$(readlink -f "$dest")" = "$(readlink -f "$src")" ]; then
echo "Skipping: $name already linked to this repo's copy"
return
fi
if [ -e "$dest" ] || [ -L "$dest" ]; then
mkdir -p "$backup"
echo "Moving: existing ~/.claude/hooks/$name to $backup/$name"
mv "$dest" "$backup/$name"
fi
echo "Linking: $name ($src -> $dest)"
ln -s "$src" "$dest"
}
# Not a guard: this is R3, the briefing's delivery mechanism, and a
# session without it does not know it is in a sandbox.
_link_hook sandbox-context.sh
harness_has_guard push-guard && _link_hook git-push-guard.sh
harness_has_guard merge-guard && _link_hook merge-guard.sh
# Registers nothing. Every hook dejima uses is baked into the image and
# registered per launch into the sandbox's own --settings overlay, so
# installing dejima writes nothing to the operator's Claude Code config.
#
# What is left is taking back what older versions of this function put
# there. The hook is kept rather than deleted because "what a harness
# needs on the host" is a real question a future one may answer
# differently; for Claude Code the answer is now nothing.
# Symlink from before the gitea-merge-guard.sh -> merge-guard.sh rename
# points at a hooks/ path that no longer exists. Only remove it if it is
@@ -141,42 +133,18 @@ harness_register_guards() {
rm -f "$old_merge_guard"
fi
# Linked so it exists at a stable path for the read-only ~/.claude/hooks
# mount, but deliberately NOT registered below: bin/dejima registers it
# per session, into a throwaway settings copy, and only when remote
# access is configured. Registering it here would fire it for every
# unsandboxed host session too, where it has no marker directory to
# write to.
harness_has_guard notify && _link_hook dejima-notify.sh
# deny-dangerous-bash.sh is the one hook that is NOT sandbox-gated: it
# has no DEJIMA check, so it applies to the operator's own host sessions
# as well. That is deliberate, and it is why it may already be owned by
# another install (dotfiles, say) - only claim it if nothing else
# provides it, so this repo works standalone without clobbering an
# existing setup.
if harness_has_guard bash-deny; then
local deny_dest="$HOME/.claude/hooks/deny-dangerous-bash.sh"
if [ -e "$deny_dest" ] || [ -L "$deny_dest" ]; then
echo "Skipping: deny-dangerous-bash.sh already present at $deny_dest (leaving it as-is)"
else
echo "Linking: deny-dangerous-bash.sh ($repo/hooks/deny-dangerous-bash.sh -> $deny_dest)"
ln -s "$repo/hooks/deny-dangerous-bash.sh" "$deny_dest"
fi
fi
echo
if [ ! -f "$settings" ]; then
echo "Creating $settings"
echo '{}' > "$settings"
fi
# No settings.json is created here either: a file dejima writes nothing
# into is a file it has no business bringing into existence.
[ -f "$settings" ] || return 0
# The registration that went with the symlink removed above. Two things
# depend on it going: Claude Code reports a missing hook command on every
# session start while it points at a path that no longer exists, and -
# until the dedupe below stopped matching it - it also stood in for the
# registration it was renamed from, so `_register merge-guard.sh` skipped
# and the merge guard was registered nowhere at all. Dropped only when
# registration it was renamed from, so the merge guard's own registration
# was skipped and it was registered nowhere at all. Dropped only when
# the path it names is gone, so an operator's own file at that name keeps
# its registration, matching the symlink check above.
if [ ! -e "$old_merge_guard" ] && jq -e --arg s "/gitea-merge-guard.sh" \
@@ -191,34 +159,34 @@ harness_register_guards() {
"$settings" > "$stale_tmp" && mv "$stale_tmp" "$settings"
fi
_register() {
local event="$1" matcher="$2" script_name="$3" description="$4"
# endswith on the leading slash, not test(): test() is an unanchored
# regex, so a hook whose name merely ends in another's - which is what
# a rename like gitea-merge-guard.sh -> merge-guard.sh produces - read
# as already registered, and the new hook was silently never added.
if jq -e --arg s "/$script_name" \
"[.hooks.${event}[]?.hooks[]?.command // empty] | any(endswith(\$s))" \
"$settings" >/dev/null 2>&1; then
echo "Skipping: $script_name already registered as a $event hook"
return
# An upgrade has to take back what an older install.sh put here, or the
# claim above is only true of a fresh one. Removed by name and only when
# the symlink points into THIS repo: an operator's own file at one of
# these names is theirs.
local moved dest
for moved in sandbox-context.sh git-push-guard.sh merge-guard.sh dejima-notify.sh \
deny-dangerous-bash.sh; do
dest="$HOME/.claude/hooks/$moved"
if [ -L "$dest" ] && [ "$(readlink -f "$dest")" = "$(readlink -f "$repo/hooks/$moved")" ]; then
echo "Removing: $moved from ~/.claude/hooks (the sandbox registers its own now)"
rm -f "$dest"
fi
echo "Registering $script_name as a $event hook in $settings ($description)"
local tmp
tmp=$(mktemp)
jq --arg matcher "$matcher" --arg cmd "\$HOME/.claude/hooks/$script_name" \
".hooks.${event} = ((.hooks.${event} // []) + [{\"matcher\": \$matcher, \"hooks\": [{\"type\": \"command\", \"command\": \$cmd}]}])" \
"$settings" > "$tmp" && mv "$tmp" "$settings"
}
_register SessionStart "startup|resume|clear|compact" sandbox-context.sh \
"injects sandbox context on session start"
harness_has_guard push-guard && _register PreToolUse Bash git-push-guard.sh \
"blocks pushes outside the allowed host(s)/branches"
harness_has_guard merge-guard && _register PreToolUse Bash merge-guard.sh \
"blocks the configured forge's merge command inside the sandbox"
harness_has_guard bash-deny && _register PreToolUse Bash deny-dangerous-bash.sh \
"blocks obviously destructive Bash commands"
# And its registration, matched the same endswith way _register does.
local ev
for ev in SessionStart PreToolUse Stop; do
if jq -e --arg s "/$moved" \
"[.hooks.${ev}[]?.hooks[]?.command // empty] | any(endswith(\$s))" \
"$settings" >/dev/null 2>&1; then
echo "Removing: $moved's $ev registration from $settings"
local mv_tmp; mv_tmp=$(mktemp)
jq --arg s "/$moved" \
".hooks.${ev} = ((.hooks.${ev} // [])
| map(.hooks = ((.hooks // []) | map(select((.command // \"\") | endswith(\$s) | not))))
| map(select((.hooks | length) > 0)))" \
"$settings" > "$mv_tmp" && mv "$mv_tmp" "$settings"
fi
done
done
echo
return 0
}
@@ -379,14 +347,11 @@ _plugin_version_mounts() {
harness_prepare_config() {
local overlay="$1" settings="$HOME/.claude/settings.json" settings_error
# install.sh normally creates settings.json, and many hosts have no
# CLAUDE.md at all. Created empty rather than left absent because
# harness_staged_config below emits both unconditionally and core skips
# staging a file that does not exist - so without this the session would
# silently start without them. statusline.sh is deliberately NOT created:
# an empty one would be staged executable and run as the status line.
[ -f "$settings" ] || echo '{}' > "$settings"
[ -f "$HOME/.claude/CLAUDE.md" ] || : > "$HOME/.claude/CLAUDE.md"
# Nothing is created on the host. A settings.json or CLAUDE.md the
# operator does not have is not one dejima should make on their machine so
# that it has something to copy - harness_staged_config asks for an empty
# one in the CONTAINER instead, which is where it is needed.
# Claude Code ignores a settings.json it cannot parse, wholesale and with
# only a startup warning, so a stray edit on the host costs the sandbox
@@ -395,7 +360,8 @@ harness_prepare_config() {
# symptom is an unexpected bypass-mode warning rather than anything
# mentioning settings. The file is staged verbatim, so nothing else parses
# it before the session reads it - check here, where the fix is at hand.
if ! settings_error="$(jq . "$settings" 2>&1 >/dev/null)"; then
# Absent is not unparseable: there is simply nothing to stage.
if [ -f "$settings" ] && ! settings_error="$(jq . "$settings" 2>&1 >/dev/null)"; then
echo "$settings is not valid JSON:" >&2
echo " $settings_error" >&2
echo "Claude Code would ignore it entirely and the sandbox would start with" >&2
@@ -410,7 +376,38 @@ harness_prepare_config() {
# --settings layer (the manifest's settings_args) above
# ~/.claude/settings.json, which holds only what the sandbox overrides;
# the operator's hooks, permissions and plugins still apply from there.
#
# And dejima's own hooks, registered HERE rather than in the operator's
# ~/.claude/settings.json. Claude Code merges this layer with theirs, so
# their hooks still run - proven by the one dejima deliberately leaves on
# the host, deny-dangerous-bash.sh, which fires inside a container while
# being registered only there.
#
# Registering them here is what lets install.sh stop writing to a config
# that is not dejima's. The scripts are baked into the image, so a session
# cannot unlink a guard the way it could one staged into a directory it
# owns.
jq -n '{sandbox: {enabled: false}}' > "$overlay"
_register_overlay_hook() { # <event> <matcher> <script>
local event="$1" matcher="$2" script="$3" tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/dejima-overlay.XXXXXX.json")" || return 1
if jq --arg m "$matcher" --arg c "$DEJIMA_HOOK_DIR/$script" \
".hooks.${event} = ((.hooks.${event} // []) +
[{\"matcher\": \$m, \"hooks\": [{\"type\": \"command\", \"command\": \$c}]}])" \
"$overlay" > "$tmp"; then
mv "$tmp" "$overlay"
else
rm -f "$tmp"
echo "warning: could not register $script; it will not run this session." >&2
fi
}
# R3: without this a session is not told it is in a sandbox at all, which
# is why it is registered whether or not any guard is declared.
_register_overlay_hook SessionStart "startup|resume|clear|compact" sandbox-context.sh
harness_has_guard push-guard && _register_overlay_hook PreToolUse Bash git-push-guard.sh
harness_has_guard merge-guard && _register_overlay_hook PreToolUse Bash merge-guard.sh
harness_has_guard bash-deny && _register_overlay_hook PreToolUse Bash deny-dangerous-bash.sh
return 0
}
harness_staged_config() {
@@ -418,10 +415,13 @@ harness_staged_config() {
# and what a dangling symlink means, are core's to decide - filtering
# here would skip a dangling one silently, which is the case that must
# refuse rather than quietly leave the session without the file.
local f
for f in settings.json CLAUDE.md statusline.sh; do
emit_staged_config "$HOME/.claude/$f" ".claude/$f"
done
# settings.json and CLAUDE.md are asked to exist even when the host has
# none: Claude Code reads both, and an empty one reads the same as a file
# the operator never made. statusline.sh is not - an empty one would be
# installed executable and run as the status line.
emit_staged_config "$HOME/.claude/settings.json" ".claude/settings.json" ensure-json
emit_staged_config "$HOME/.claude/CLAUDE.md" ".claude/CLAUDE.md" ensure
emit_staged_config "$HOME/.claude/statusline.sh" ".claude/statusline.sh"
}
# Where this workspace's transcripts are, and which store key each belongs
@@ -601,35 +601,25 @@ harness_transform_transcript() {
' 2>/dev/null
}
# Which of the hooks install.sh registers are actually in the operator's
# settings, and which are not. Prints the missing ones, one per line, and
# returns 1 if there are any - so the launch can name what is not in force
# rather than assert that nothing is.
# harness_guards_registered is deliberately not defined.
#
# Every hook this harness would install is checked, not just the briefing:
# the realistic failure (install.sh never run with a harness set) is
# all-or-nothing, but a hand-edited settings.json missing one guard is a
# session quietly running without it, which is the case worth catching.
# It read the operator's own ~/.claude/settings.json and named any of dejima's
# hooks missing from it, because that is where install.sh used to put them and
# "install.sh never run with a harness set" was a real way to get a session
# with no briefing and no guards.
#
# dejima-notify.sh is deliberately absent: bin/dejima registers it per
# session into a throwaway layer, and it is never in the operator's file.
# Nothing is written there now: the hooks are baked into the image and
# registered per launch into dejima's own --settings overlay, so an absent
# registration in the operator's file is the correct state rather than a
# broken one. Left defined, it named all four as not in force on every launch
# of a host that had never run the old installer - and told the operator to
# run ./install.sh, which strips exactly those registrations, making the
# message permanent.
#
# Reads the operator's real settings rather than dejima's own layer, which
# is composed fresh each launch and always looks right.
harness_guards_registered() {
local settings="$HOME/.claude/settings.json" missing=0
_registered() {
[ -f "$settings" ] || return 1
jq -e --arg s "/$2" "[.hooks.$1[]?.hooks[]?.command // empty]
| any(endswith(\$s))" "$settings" >/dev/null 2>&1
}
_registered SessionStart sandbox-context.sh \
|| { printf 'the session briefing\n'; missing=1; }
harness_has_guard push-guard && { _registered PreToolUse git-push-guard.sh \
|| { printf 'push-guard\n'; missing=1; }; }
harness_has_guard merge-guard && { _registered PreToolUse merge-guard.sh \
|| { printf 'merge-guard\n'; missing=1; }; }
harness_has_guard bash-deny && { _registered PreToolUse deny-dangerous-bash.sh \
|| { printf 'bash-deny\n'; missing=1; }; }
return "$missing"
}
# There is nothing to put in its place. What the overlay carries is composed
# fresh each launch from the manifest, so reading it back would be asking
# dejima whether it did what it just did; which guards the harness cannot host
# at all is the manifest's, and harness_report_guards already says it.
#
# The hook itself stays in the plugin interface. What a harness needs wired on
# the host is a real question, and a future one may answer it differently.
+208 -69
View File
@@ -40,18 +40,17 @@ harness_briefing_state_dir() {
# Which endpoints a session may be pointed at, and why the answer is not "any".
#
# Absent base_url means Anthropic: Cline is configured with the real hostname
# and a placeholder key, and dejima's in-container terminator and host-side
# proxy supply the real credential, exactly as they do for Claude Code. The
# credential never enters the container.
# The selected backend says where the model is. A proxied one carries its own
# upstream and dejima's terminator stands in front of it, so the credential
# never enters the container. A backend with `credential = none` is a model
# the operator serves, carrying its own [backend.<name>] base_url, and it is
# reached directly because there is nothing to withhold.
#
# A base_url means a self-hosted endpoint, and it has to be one reachable
# without a credential - the Docker host, or something on the sandbox's own
# network. That restriction is R5 and not a network one: a hosted provider
# would need an API key inside the container, and dejima has no proxy that
# could hold one back on its behalf. An endpoint that needs no key has nothing
# to withhold, which is why an open-weight model served locally is supported
# today and api.openai.com is not.
# That second kind has to be an endpoint that genuinely needs no credential.
# The restriction is R5 and not a network one: a hosted API pointed at this way
# would need a key inside the container, and dejima has no proxy holding one
# for it. An endpoint that needs no key has nothing to withhold, which is why a
# model served locally is supported and api.openai.com reached this way is not.
#
# Two ways an endpoint qualifies, and neither is about the URL's syntax.
#
@@ -66,17 +65,15 @@ harness_briefing_state_dir() {
# what the container can reach at all, so an endpoint failing this check is
# one the session could not have connected to anyway. Requiring a literal
# address instead was too strict by half: a model server on someone's own
# network normally has a name, and refusing it made the local-endpoint path -
# the reason this harness is provider-agnostic at all - unusable for the
# ordinary case.
# network normally has a name, and refusing it made this path - the reason
# this harness is backend-agnostic at all - unusable for the ordinary case.
#
# What neither branch is doing is protecting the model credential. dejima
# writes a placeholder into providers.json on this path whatever the endpoint
# What this is not doing is protecting the model credential. dejima writes a
# placeholder into providers.json on every proxied path whatever the endpoint
# is, so no real credential enters the container either way. What this decides
# is whether the configuration can WORK: an endpoint that needs a key would
# get the placeholder and fail, and saying so at launch beats an auth error
# three layers down. Pointing it somewhere is the operator's call, bounded by
# an allowlist entry they made deliberately.
# is whether the configuration can WORK: an endpoint that needs a key would get
# a no-credential marker and fail, and saying so at launch beats an auth error
# three layers down.
_cline_host_local() {
local url="$1" host a b
host="${url#*://}"
@@ -130,7 +127,7 @@ _cline_host_allowlisted() {
return 1
}
# The provider configuration the session runs with, and the three conditions
# The backend configuration the session runs with, and the three conditions
# that make a launch wrong: no model, a credential mode with no path through,
# and an endpoint whose key dejima would have to put in the container.
#
@@ -146,7 +143,7 @@ _cline_host_allowlisted() {
# bounds where a repointed endpoint could reach. What rewriting buys is that a
# repointing cannot persist into the next session.
harness_prepare_config() {
local state model base_url settings
local state model settings
state="$(_cline_state_dir "$WORKSPACE_DIR")" || {
echo "harness.cline: state_dir is not set, so there is nowhere to keep" >&2
echo "this project's Cline state. Set it in [harness.cline]." >&2
@@ -162,61 +159,98 @@ harness_prepare_config() {
echo " [harness.cline]" >&2
echo " model = claude-haiku-4-5-20251001" >&2
echo "" >&2
echo "\`cline auth\` outside the sandbox lists what your provider offers." >&2
echo "\`cline auth\` outside the sandbox lists what your backend offers." >&2
return 1
fi
base_url="$(harness_config_get base_url)"
local ctx
ctx="$(harness_config_get context_window)"
# The Anthropic path is the credential proxy's path, and it only works in
# the shape that was actually tested: the proxy on, terminating TLS for
# api.anthropic.com in-container, with Cline configured for the real
# hostname and a placeholder key.
# WHERE THE MODEL IS is the backend's, and only the backend's. One
# setting, so there is nothing for a second one to disagree with.
#
# With the proxy off, Claude Code mounts the operator's real credential -
# a downgrade this repository documents and accepts. There is no
# equivalent here: the credential Cline would need is in the operator's
# own providers.json, which is exactly the file dejima declines to mount,
# and writing a placeholder anyway would produce an auth failure whose
# cause is three layers away. The loopback transport is the same story
# from the other end - it hands the harness a base URL and a token through
# the environment, which Cline does not read for provider selection
# A self-served endpoint is `backend = self-hosted`, carrying its own
# [backend.self-hosted] base_url. That placement is what lets an operator
# keep a local model configured while using a hosted API, and switch
# between them by changing one word.
local backend
backend="$(harness_config_get backend)"
if [ -z "$backend" ]; then
echo "harness.cline: no [harness.cline] backend is set, so nothing says" >&2
echo "where the model is." >&2
echo "" >&2
echo " [harness.cline]" >&2
echo " backend = openrouter a hosted API, key held on the host" >&2
echo " backend = self-hosted a model you serve, with no key at all" >&2
echo "" >&2
echo "There is deliberately no default: one dejima could change later would" >&2
echo "move your session to another endpoint without your config changing." >&2
return 1
fi
local kind base_url=""
kind="$(manifest_get backend "$backend" credential)" || return 1
if [ "$kind" = none ]; then
base_url="$(config_get "backend.$backend" base_url)" || return 1
fi
# The proxy is what keeps a credential out of the container, and it only
# works in the shape that was actually tested: the proxy on, terminating
# TLS in-container, with Cline configured for the endpoint's real hostname
# and a placeholder key.
#
# With the proxy off, claude-code mounts the operator's real credential - a
# downgrade this repository documents and accepts. There is no equivalent
# here: the credential Cline would need is in the operator's own
# providers.json, which is exactly the file dejima declines to mount, and
# writing a placeholder anyway would produce an auth failure whose cause is
# three layers away. The loopback transport is the same story from the
# other end - it hands the harness a base URL and a token through the
# environment, which Cline does not read for provider selection
# (CLINE_PROVIDER and CLINE_MODEL were tried and ignored).
#
# Both refuse rather than start something that will fail confusingly, and
# both have the same way out: point base_url at a model you serve.
if [ -z "$base_url" ]; then
# A backend with no credential is unaffected: there is nothing to withhold
# and no proxy in the path.
if [ "$kind" != none ]; then
local why=""
[ "$(config_get_bool credentials proxy)" = "1" ] \
|| why="[credentials] proxy is off"
backend_proxy_enabled "$backend" \
|| why="the proxy is off for this backend"
[ -n "$why" ] || [ "$(config_get credentials transport)" = "tls" ] \
|| why="[credentials] transport is loopback"
if [ -n "$why" ]; then
echo "harness.cline: $why, and cline has no way to reach Anthropic" >&2
echo "without the TLS terminator the proxy puts in front of it." >&2
echo "harness.cline: $why, and cline has no way to reach a proxied" >&2
echo "backend without the TLS terminator the proxy puts in front of it." >&2
echo "Its credential lives in the providers.json dejima declines to" >&2
echo "mount, so there is nothing to fall back to - unlike claude-code," >&2
echo "which mounts the real credential in that mode." >&2
echo "" >&2
echo "Either turn the proxy back on with the default tls transport," >&2
echo "or set [harness.cline] base_url to a model you serve yourself." >&2
echo "or select a backend you serve yourself." >&2
return 1
fi
fi
if [ -n "$base_url" ] && ! _cline_host_local "$base_url"; then
echo "harness.cline: refusing base_url $base_url." >&2
echo "It is not a host-local endpoint, so it would need an API key" >&2
echo "inside the container, and dejima has no proxy that can hold one" >&2
echo "back for that provider - the model credential would stop being" >&2
echo "something the sandbox never sees." >&2
# An endpoint reached with no credential has to be one that needs none, and
# that is an R5 check rather than a network rule: a hosted API pointed at
# this way would want a key inside the container, and the model credential
# would stop being something the sandbox never sees.
#
# Still here rather than in core because cline is the only harness with a
# direct path today. It belongs in core the moment a second one has one.
if [ "$kind" = none ] && ! _cline_host_local "$base_url"; then
echo "harness.cline: refusing [backend.$backend] base_url $base_url." >&2
echo "That backend needs no credential, so the endpoint has to be one" >&2
echo "that needs none: a model you serve. This is not host-local, so it" >&2
echo "would want a key inside the container." >&2
echo "" >&2
echo "Supported today: leave base_url unset for Anthropic through" >&2
echo "dejima's own credential proxy, or point it at a model you serve" >&2
echo "yourself, which needs no key. That means a loopback or RFC 1918" >&2
echo "address, or a host you have allowlisted:" >&2
echo "For a hosted API, select a backend for it instead - the key is then" >&2
echo "held on the host and injected per request:" >&2
echo "" >&2
echo " [harness.cline]" >&2
echo " backend = openrouter" >&2
echo "" >&2
echo "For one you serve, that means a loopback or RFC 1918 address, or a" >&2
echo "host you have allowlisted:" >&2
echo "" >&2
echo " dejima-allow --project <path> <host>" >&2
echo "" >&2
@@ -246,25 +280,45 @@ harness_prepare_config() {
local tmp
tmp="$(mktemp "$settings/.providers.XXXXXX")" || return 1
if [ -n "$base_url" ]; then
jq -n --arg m "$model" --arg u "$base_url" \
# Which of Cline's own provider types to write, from the selected
# backend's api_style rather than from a second setting an operator has
# to keep in step. Anthropic's Messages API and an OpenAI-compatible one
# are different wire formats, and Cline has a client for each.
local style endpoint key
style="$(manifest_get backend "$backend" api_style)" || return 1
if [ "$kind" = none ]; then
endpoint="$base_url"
key="dejima-no-credential-needed"
else
# The endpoint's REAL hostname, not a loopback one. The terminator
# answers for it inside the container and the host-side proxy holds the
# credential, which is what keeps the session indistinguishable from
# one talking to the API directly.
endpoint="$(manifest_get backend "$backend" upstream)" || return 1
# The placeholder is what Cline sends; the host-side proxy replaces it
# with the real credential on the way out. Same arrangement as Claude
# Code's faked .credentials.json, confirmed end to end against the API.
key="sandbox-credential-proxy-placeholder"
fi
if [ "$style" = anthropic ]; then
# Cline's native Anthropic client, which addresses the API by name and
# so needs no baseUrl: entrypoint.sh has already pointed that name at
# the terminator.
jq -n --arg m "$model" --arg k "$key" \
--arg t "$now" --arg c "$ctx" \
'{version: 1, lastUsedProvider: "anthropic", modes: {},
providers: {anthropic: {
settings: ({provider: "anthropic", model: $m, apiKey: $k}
+ (if $c == "" then {} else {contextWindow: ($c|tonumber)} end)),
updatedAt: $t, tokenSource: "manual"}}}' > "$tmp"
else
jq -n --arg m "$model" --arg u "$endpoint" --arg k "$key" \
--arg t "$now" --arg c "$ctx" \
'{version: 1, lastUsedProvider: "openai-compatible", modes: {},
providers: {"openai-compatible": {
settings: ({provider: "openai-compatible", model: $m,
baseUrl: $u, apiKey: "dejima-no-credential-needed"}
+ (if $c == "" then {} else {contextWindow: ($c|tonumber)} end)),
updatedAt: $t, tokenSource: "manual"}}}' > "$tmp"
else
# The placeholder is what Cline sends; the host-side proxy replaces it
# with the real credential on the way out. Same arrangement as Claude
# Code's faked .credentials.json, confirmed end to end against the API.
jq -n --arg m "$model" \
--arg t "$now" --arg c "$ctx" \
'{version: 1, lastUsedProvider: "anthropic", modes: {},
providers: {anthropic: {
settings: ({provider: "anthropic", model: $m,
apiKey: "sandbox-credential-proxy-placeholder"}
baseUrl: $u, apiKey: $k}
+ (if $c == "" then {} else {contextWindow: ($c|tonumber)} end)),
updatedAt: $t, tokenSource: "manual"}}}' > "$tmp"
fi || { rm -f "$tmp"; return 1; }
@@ -278,6 +332,91 @@ harness_prepare_config() {
# nothing inside the container has to be told where its state is - the source
# is keyed by dejima's workspace slug and only the target follows the harness,
# which is what emit_mount_at is for.
# The operator's own configuration, brought in from the host.
#
# Read-only, and that is the whole point rather than caution: everything here
# is either an instruction the agent follows or a program cline runs. A
# session that could rewrite them would be choosing what happens on the HOST
# the next time cline starts outside a container - persistence for a prompt
# injection, in files nobody re-reads before the next run.
#
# `scan` for the same reason claude-code's directories are scanned: on a Linux
# host these are routinely symlinks into a dotfiles checkout, and the target
# has to be mounted for the link to resolve inside the container. Core refuses
# a target that overlaps a guarded store.
#
# ~/.agents/rules is deliberately absent. It reads like the obvious companion
# to ~/.agents/skills and cline does not load it - checked by planting a file
# in it and one in ~/Documents/Cline/Rules and asking `cline config` which it
# had: only the second appeared. Mounting a directory the harness never reads
# would be a promise dejima cannot keep.
harness_config_dirs() {
emit_config_dir "$HOME/.agents/skills" ro scan
# Not confirmed loaded by that same probe, unlike skills. Mounted because
# it is the documented default for --hooks-dir and because the cost of
# being wrong is an empty directory, where the cost of leaving it out is a
# hook that silently does not run.
emit_config_dir "$HOME/.agents/hooks" ro scan
emit_config_dir "$HOME/.cline/hooks" ro scan
}
# AGENTS.md is instructions the agent follows, so a session that could write
# the host's copy would be choosing what the next session is told. Staged
# rather than mounted: the copy lands root-owned and read-only, and a file
# that is a symlink into a dotfiles checkout arrives as its real content
# rather than as a link the host can still swap.
harness_staged_config() {
emit_staged_config "$HOME/.agents/AGENTS.md" ".agents/AGENTS.md"
# Global rules, from both host locations into the one directory cline
# actually reads.
#
# cline loads global rules from Documents/Cline/Rules and nowhere else -
# ~/.agents/rules reads like its companion and is not read, checked by
# planting a file in each and asking `cline config` which it had. So
# ~/.agents/rules is supported by staging its files INTO the directory
# that is read, rather than by mounting it where it sits and having
# nothing load it.
#
# File by file rather than as a directory mount, because dejima writes its
# own briefing into that same directory: a read-only mount over it would
# leave nowhere to put the briefing.
local d f base briefing seen=" "
# The briefing's own name, from the manifest rather than repeated here.
# dejima wins that collision, and entrypoint.sh already delivers it after
# the staged files - so staging a host file it is about to overwrite would
# only add a mount nobody reads.
briefing="$(basename "$(manifest_get harness cline briefing_path)")"
# Documents first, so a name present in both wins there: it is cline's own
# location for this, and where an operator editing rules through cline is
# writing. Two sources staged to one destination would otherwise be two
# bind mounts at the same container path.
for d in "$HOME/Documents/Cline/Rules" "$HOME/.agents/rules"; do
[ -d "$d" ] || continue
# .md and .txt are what cline reads out of a rules directory.
for f in "$d"/*.md "$d"/*.txt; do
[ -f "$f" ] || continue
base="$(basename "$f")"
[ "$base" = "$briefing" ] && continue
case "$seen" in *" $base "*) continue ;; esac
seen="$seen$base "
emit_staged_config "$f" "Documents/Cline/Rules/$base"
done
done
}
# Writable copies, discarded with the container.
#
# cline rewrites both of these as it runs - its settings TUI toggles them - so
# a read-only copy would fail the write rather than the persistence, and the
# persistence is the part that matters. cline_mcp_settings.json names MCP
# servers, which are commands cline executes: a session that could write the
# host's copy would be choosing what runs there next time.
harness_seed() {
emit_seed_from "$HOME/.cline/settings.json" ".cline/settings.json"
emit_seed_from "$HOME/.cline/cline_mcp_settings.json" ".cline/cline_mcp_settings.json"
}
harness_state_mounts() {
local dir
dir="$(_cline_state_dir "$1")" || return 0
+178 -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
@@ -28,49 +50,69 @@ ENV TZ="$TZ"
# hardening). Whichever harness has one, its plugin's harness_prepare_config
# turns it off in the override layer, so there is nothing here to satisfy.
# Claude Code's is the "sandbox" block in settings.json.
#
# libcap2-bin is capsh, which entrypoint.sh uses to hand the session a
# container it holds no capabilities in. It was already here as a dependency of
# iproute2; a security control resting on another package's dependency graph is
# one an unrelated package change removes.
RUN apt-get update && apt-get install -y --no-install-recommends \
less \
git \
openssh-client \
procps \
fzf \
zsh \
unzip \
gnupg2 \
gh \
iptables \
ipset \
iproute2 \
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.
@@ -148,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),
@@ -187,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
@@ -265,15 +304,19 @@ 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/dns-proxy.sh /usr/local/bin/
COPY docker/init-firewall.sh /usr/local/bin/
COPY docker/caps-lib.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/
COPY docker/anthropic-proxy-forwarder.js /usr/local/bin/anthropic-proxy-forwarder.js
COPY docker/credential-proxy-forwarder.js /usr/local/bin/credential-proxy-forwarder.js
COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper
# The two guards that do not need the harness's help. git-guard shadows git on
# PATH; forge-guard is linked over a forge CLI by entrypoint.sh when a workflow
@@ -287,6 +330,17 @@ COPY docker/forge-guard.sh /usr/local/lib/dejima/forge-guard.sh
# entrypoint.sh, which renders the briefing before the session starts - to
# hand to the harness as an argument, or to write to a file it reads.
COPY hooks/dejima-briefing-render.sh /usr/local/lib/dejima/dejima-briefing-render.sh
# dejima's own hooks, for the same reason and one more: they are registered
# into the sandbox's --settings overlay rather than into the operator's
# ~/.claude/settings.json, so nothing has to be written to a host config to
# make the sandbox work. Baked rather than mounted from the checkout because a
# guard whose script can change under a running session is not one.
#
COPY hooks/deny-dangerous-bash.sh /usr/local/lib/dejima/hooks/deny-dangerous-bash.sh
COPY hooks/sandbox-context.sh /usr/local/lib/dejima/hooks/sandbox-context.sh
COPY hooks/git-push-guard.sh /usr/local/lib/dejima/hooks/git-push-guard.sh
COPY hooks/merge-guard.sh /usr/local/lib/dejima/hooks/merge-guard.sh
COPY hooks/dejima-notify.sh /usr/local/lib/dejima/hooks/dejima-notify.sh
# Back to root before the first RUN that writes outside /home/node. The npm
# installs above run as node deliberately; COPY writes as root regardless of
# USER, so the copies above are unaffected, but the install(1) below is a RUN
@@ -301,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/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 && \
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
+50
View File
@@ -0,0 +1,50 @@
# Sourced by every entrypoint in this image. Nothing here runs on its own.
#
# The startup sequence needs real capabilities - it builds the packet filter,
# binds an NFLOG socket, chowns files and drops privilege twice. Nothing after
# it does, and a bounding set survives execve: a process that reaches uid 0
# anywhere downstream recovers its permitted set from whatever bounding set it
# inherited. So an unprivileged session whose effective set is empty still sits
# inside a tree holding every capability the container declares.
#
# `drop_caps` is how anything that outlives a startup script is started, and it
# is what makes a declared floor a description of the root phase rather than of
# the session's whole life.
#
# capsh, not `setpriv --bounding-set`, which is the obvious tool and silently
# the wrong one: without CAP_SETPCAP it exits 0 having changed nothing, so a
# container that lost the capability would run every session with the full set
# and report success at every step. capsh refuses out loud, which is the only
# behaviour worth having here.
#
# `-- -c 'exec "$@"'` runs the command through capsh's shell and immediately
# replaces it, so nothing is left between the caller and the process.
drop_caps() {
exec capsh --drop=all "$@"
}
# Checked rather than assumed, because the failure it prevents is invisible:
# capsh would abort each drop_caps call, and in an entrypoint the last of those
# is the session, so the container would die at the point it looked ready.
# Named here instead, against the file the floor actually lives in.
#
# Called once by each top-level entrypoint rather than from drop_caps: it is a
# precondition of the whole script, and the point is to refuse before doing
# work rather than partway through it.
require_setpcap() {
# 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
echo "Refusing to start rather than running with a floor that is not the one" >&2
echo "docker/docker-compose.yml declares. Add SETPCAP to its cap_add." >&2
exit 1
}
+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()
@@ -1,6 +1,6 @@
// TCP front door for the host-side credential proxy, which listens on a
// bind-mounted unix socket (no host TCP exposure - see
// bin/dejima-anthropic-proxy). Deliberately no HTTP awareness in either
// bin/dejima-credential-proxy). Deliberately no HTTP awareness in either
// mode: bytes in, bytes out.
//
// tls (default): terminates TLS for api.anthropic.com on :443, which
@@ -22,14 +22,14 @@ const net = require("net");
const tls = require("tls");
const fs = require("fs");
const socketPath = process.env.SANDBOX_ANTHROPIC_PROXY_SOCKET;
const transport = process.env.SANDBOX_ANTHROPIC_TRANSPORT || "tls";
const socketPath = process.env.SANDBOX_CREDENTIAL_PROXY_SOCKET;
const transport = process.env.SANDBOX_CREDENTIAL_TRANSPORT || "tls";
const port = parseInt(
process.env.SANDBOX_ANTHROPIC_PROXY_PORT || (transport === "tls" ? "443" : "18300"),
process.env.SANDBOX_CREDENTIAL_PROXY_PORT || (transport === "tls" ? "443" : "18300"),
10,
);
if (!socketPath) {
console.error("anthropic-proxy-forwarder: SANDBOX_ANTHROPIC_PROXY_SOCKET not set");
console.error("credential-proxy-forwarder: SANDBOX_CREDENTIAL_PROXY_SOCKET not set");
process.exit(1);
}
@@ -43,11 +43,11 @@ const pipe = (client) => {
let server;
if (transport === "tls") {
const certPath = process.env.SANDBOX_ANTHROPIC_TLS_CERT;
const keyPath = process.env.SANDBOX_ANTHROPIC_TLS_KEY;
const certPath = process.env.SANDBOX_CREDENTIAL_TLS_CERT;
const keyPath = process.env.SANDBOX_CREDENTIAL_TLS_KEY;
if (!certPath || !keyPath) {
console.error(
"anthropic-proxy-forwarder: SANDBOX_ANTHROPIC_TLS_CERT/SANDBOX_ANTHROPIC_TLS_KEY not set",
"credential-proxy-forwarder: SANDBOX_CREDENTIAL_TLS_CERT/SANDBOX_CREDENTIAL_TLS_KEY not set",
);
process.exit(1);
}
+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
+87 -35
View File
@@ -48,43 +48,87 @@ services:
# ten per run - becomes a permanent zombie, and a long session eventually
# exhausts the cgroup pids limit and can no longer fork at all.
init: true
# An explicit floor rather than Docker's default set plus two. The default
# carries about fourteen capabilities and this container uses seven: it
# writes and chowns files, runs the firewall, drops privilege twice (gosu
# for the session, `ulogd -u` for the log daemon), and signals dnsmasq.
# FSETID, MKNOD, SETFCAP, SETPCAP, SYS_CHROOT, AUDIT_WRITE and
# NET_BIND_SERVICE are used by nothing here - the last one because the
# sysctl below is what lets the TLS terminator bind :443 unprivileged.
# The session does not get a network namespace of its own. It joins the one
# the filter container owns, where the egress rules are already installed -
# so the filter bounding this session sits somewhere it declares no
# capability to reach. bin/dejima starts that container and passes its name
# here; see the block above its compose invocation, and docs/security.md.
#
# KILL is the one worth explaining, because it is not obvious from reading
# entrypoint.sh and was missing from the first version of this list. It is
# not for anything the entrypoint does. `dejima-allow --container` adds a
# domain to a RUNNING session by appending a server= line and sending
# dnsmasq a SIGHUP, from outside via docker exec - and dnsmasq drops to its
# own user (`user=dnsmasq`), so root in this container cannot signal it
# without CAP_KILL. Without this line, adding a domain mid-session fails
# with "Operation not permitted", which is a documented feature every
# session briefing tells the agent to ask for.
# The default is for a `docker compose` invocation by hand, which this
# file's first line says is not how to run it. It is `network_mode: none`
# rather than any real network, because the value that makes a session safe
# arrives from outside this file, and the question a default answers is
# what happens when it does not arrive: no network at all is a sandbox that
# visibly cannot work, where a real one is a sandbox that works and has
# unfiltered egress.
#
# It does not affect `docker compose build`, which reads `build.network`.
network_mode: ${SANDBOX_NETNS_CONTAINER:-none}
# An explicit floor rather than Docker's default set. The default carries
# about fourteen capabilities; this container writes and chowns files and
# drops privilege once, to the session user. FSETID, MKNOD, SETFCAP,
# SYS_CHROOT, AUDIT_WRITE and NET_BIND_SERVICE are used by nothing here -
# the last one because a sysctl on this namespace, not a capability, is
# what lets the TLS terminator bind :443 unprivileged.
#
# No network capability at all, which is the point of the arrangement
# rather than an economy. This container is inside a network namespace the
# filter container owns and has already installed the egress rules in, and
# capabilities are per process: without NET_ADMIN, nothing here can alter
# those rules even at uid 0. NET_RAW goes with it - a raw socket is the
# other way to speak past a filter, and nothing here opens one.
#
# KILL is here for tini, and for nothing else in this container.
#
# `init: true` above makes PID 1 the init that reaps for the session, and
# tini forwards every signal it receives to its child. That child is the
# entrypoint, which changes uid in place before exec'ing the session - so
# tini is uid 0 signalling a process whose real AND saved uids are the
# sandbox user's. kill(2) permits that only with CAP_KILL or a uid match,
# and there is no match, so without this every forwarded signal is EPERM.
#
# tini treats a failed forward as fatal, and PID 1 exiting ends the
# container. The signal that arrives in practice is SIGWINCH: the kernel
# sends it to the pty's foreground process group on any terminal resize,
# which includes tini. So without this line, splitting a tmux pane kills
# the session with `[FATAL tini (1)] Unexpected error when forwarding
# signal: 'Operation not permitted'`.
#
# It used to be here for `dejima-allow --container`, which signals dnsmasq;
# that reason moved to the filter container with dnsmasq, and this one was
# not noticed until the capability went away. Nothing in the suite ran an
# init at all - see runtime.bats.
#
# The general lesson, since the next capability will be the same shape:
# what the container needs is not only what its entrypoint does. Things
# reach into it later.
# what a container needs is not only what its entrypoint does. Things reach
# into it later.
#
# This narrows the ROOT phase. The session user's capabilities were already
# empty: they are cleared on the gosu drop, which runtime.bats asserts by
# reading CapEff of the running process. What this adds is that the set was
# never there to be regained.
# empty: they are cleared on the privilege drop, which runtime.bats asserts
# by reading CapEff of the running process. What this adds is that the set
# was never there to be regained.
#
# SETPCAP is the odd one out: nothing in the startup sequence uses it for
# its own work. It is spent, once, to take every capability above away from
# the session before it starts - see entrypoint.sh. A bounding set survives
# execve and a uid-0 process recovers its permitted set from it, so without
# this the whole list is held by everything the session can reach, effective
# set or not.
#
# Adding a capability to remove capabilities is worth stating plainly. It
# can only reduce: since Linux 4.3 SETPCAP grants nothing a process does
# not already hold in its permitted set, so what it buys is the drop and
# nothing else.
cap_drop:
- ALL
cap_add:
- NET_ADMIN
- NET_RAW
- KILL
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETUID
- SETGID
- KILL
- SETPCAP
# No path back up. gosu drops privilege rather than gaining it, so this
# does not interfere with it - what it forbids is a setuid binary or a file
# capability raising privileges on exec, which is the shape any escalation
@@ -92,12 +136,13 @@ services:
# structural instead of only observed.
security_opt:
- no-new-privileges:true
# The credential-proxy TLS terminator binds :443 as the unprivileged
# sandbox user (see entrypoint.sh). Docker already defaults this to 0, but
# pinning it makes the dependency explicit: a future default change then
# fails the launch instead of silently breaking the credential path.
sysctls:
net.ipv4.ip_unprivileged_port_start: 0
# No sysctls here. The one this container depends on -
# net.ipv4.ip_unprivileged_port_start, which lets the credential proxy's
# TLS terminator bind :443 as the unprivileged sandbox user - is a property
# of a network namespace, and this container does not own the one it is in.
# bin/dejima sets it on the filter container that does, so it is declared
# once, by the container it belongs to. runtime.bats reads the value back
# from in here, which is where it has to be true.
environment:
# The forge's own env vars (declared by its manifest's env_export) and
# the DEJIMA_* resolved values (DEJIMA_FORGE_HOSTS, DEJIMA_PUSH_HOSTS,
@@ -137,12 +182,13 @@ services:
# The names this harness needs reachable, and the one its credential
# proxy stands in front of - see docker/fixed-domains.sh.
SANDBOX_HARNESS_DOMAINS: ${SANDBOX_HARNESS_DOMAINS:-}
SANDBOX_HARNESS_PROXIED_DOMAIN: ${SANDBOX_HARNESS_PROXIED_DOMAIN:-}
SANDBOX_BACKEND_PROXIED_DOMAIN: ${SANDBOX_BACKEND_PROXIED_DOMAIN:-}
SANDBOX_HARNESS_BRIEFING_ARGS: ${SANDBOX_HARNESS_BRIEFING_ARGS:-}
SANDBOX_HARNESS_BRIEFING_PATH: ${SANDBOX_HARNESS_BRIEFING_PATH:-}
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
@@ -168,8 +214,12 @@ services:
# empty - and unset by entrypoint.sh - otherwise.
ANTHROPIC_BASE_URL: ${SANDBOX_ANTHROPIC_BASE_URL:-}
ANTHROPIC_AUTH_TOKEN: ${SANDBOX_ANTHROPIC_AUTH_TOKEN:-}
SANDBOX_ANTHROPIC_PROXY_SOCKET: ${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}
SANDBOX_ANTHROPIC_TRANSPORT: ${SANDBOX_ANTHROPIC_TRANSPORT:-}
# The placeholder an API-billing session authenticates with. The real key
# never enters the container; the host-side proxy swaps this for it per
# request, exactly as it does for a subscription login's token.
ANTHROPIC_API_KEY: ${SANDBOX_ANTHROPIC_API_KEY:-}
SANDBOX_CREDENTIAL_PROXY_SOCKET: ${SANDBOX_CREDENTIAL_PROXY_SOCKET:-}
SANDBOX_CREDENTIAL_TRANSPORT: ${SANDBOX_CREDENTIAL_TRANSPORT:-}
volumes:
# Mounted at the same absolute path as on the host (rather than a
# fixed /workspace) so Claude Code's project/session-history keying
@@ -232,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
+1498
View File
File diff suppressed because it is too large Load Diff
+93 -75
View File
@@ -8,56 +8,35 @@ set -Eeuo pipefail
# refuse deliberately, and say so (#59).
trap 'status=$?; echo "ERROR: entrypoint.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
# DEJIMA_FORGE_HOSTS is resolved host-side by bin/dejima (forge_hosts, in
# dejima-provider-lib.sh) from whichever forge is configured, so this file
# does not derive or name any one forge's hostnames itself.
# drop_caps and require_setpcap, shared with the filter container's entrypoint.
# Sourced rather than defined here because the callers must not drift: what
# `drop_caps` does is the difference between a session holding nothing and a
# session holding the whole floor, and that is not a thing to have two of.
# shellcheck source=caps-lib.sh
source /usr/local/bin/caps-lib.sh
require_setpcap
# The egress control for this namespace is already up: the filter container
# that owns it installed the rules, and bin/dejima waited for that before
# starting this container. Nothing here brings it up, and nothing here could -
# this container declares none of the capabilities it would take.
#
# LOKI_URL (a full URL) still needs its own derivation here - Loki's host
# has to be reachable for log-shipper.sh to actually deliver anything.
loki_host=""
if [ -n "${LOKI_URL:-}" ]; then
loki_host=$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')
fi
extra_domains="${DEJIMA_FORGE_HOSTS:-} ${loki_host} ${ALLOWED_DOMAINS:-}"
# What this container does need is to be pointed at the resolver enforcing the
# name allowlist. /etc/resolv.conf is per container even when the namespace is
# shared, so 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 the
# restricted resolver and the name allowlist would apply to nothing.
#
# 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
# dns-proxy.sh must run first: it repoints /etc/resolv.conf at a local
# dnsmasq that only resolves allowlisted names, so init-firewall.sh's own
# dig calls (and everything afterward) go through the same restriction.
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/dns-proxy.sh
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/init-firewall.sh
# NFLOG groups only exist once init-firewall.sh has run, so ulogd2 (the
# NFLOG consumer) and the Loki shipper start after it. Both are
# best-effort visibility on top of the enforcement above, not required
# for it - log-shipper.sh itself no-ops if LOKI_URL isn't set. Runs as
# its own unprivileged `ulogd` user (opens the NFLOG socket and output
# files first, then drops), not root - it's parsing headers from
# attacker-influenceable packets (whatever a compromised session sends),
# so a bug in it shouldn't be a path back to root in the container.
#
# `ulogd -d` daemonizes immediately and always exits 0 from this shell's
# point of view, regardless of whether the daemon itself goes on to
# start successfully - so `set -e` above won't catch a failure here (nor
# should it: logging is best-effort, not something worth failing the
# whole container over). Check separately so a failure is at least
# visible instead of silently producing no logs.
/usr/sbin/ulogd -d -u ulogd -c /etc/ulogd-sandbox.conf
sleep 0.5
if ! pgrep -x ulogd >/dev/null; then
echo "WARNING: ulogd2 failed to start - connection logging will be unavailable (see /var/log/ulogd.log)" >&2
fi
# Unlike ulogd2 above, this one stays root - deliberately, not an
# oversight. What it ships is an oversight log of the session running
# beside it, and letting the sandbox user own the shipper would let a
# compromised session tamper with or kill its own logging. Root buys
# tamper-resistance that a security log needs and a best-effort netflow
# counter does not.
#
# What it reads is dnsmasq's query log and ulogd2's output, both written
# by root-owned daemons here and neither session-writable.
/usr/local/bin/log-shipper.sh &
echo "nameserver 127.0.0.1" > /etc/resolv.conf
fi
# A normal login/ssh session always chowns the allocated pty to the logging
@@ -235,6 +214,36 @@ if [ -d /etc/dejima-harness ]; then
done < <(find /etc/dejima-harness -type f -print0)
fi
# Files the harness needs to exist whether or not the operator has one.
#
# Created HERE rather than on the host. A harness that reads a settings file
# still reads one when it is empty, and dejima creating that file on the
# operator's machine - so there is something to copy - is a write to their
# configuration for dejima's own convenience. Root-owned and read-only, the
# same as anything else staged, so the container's copy is not a channel back.
for _spec in ${SANDBOX_HARNESS_STAGED_EMPTY:-}; do
_kind="${_spec%%:*}"
_rel="${_spec#*:}"
case "$_rel" in
/*|*..*)
echo "entrypoint: refusing staged-config path '$_rel': it must be" >&2
echo "relative to the sandbox user's home and must not contain '..'." >&2
exit 1 ;;
esac
_dest="$SANDBOX_HOME/$_rel"
[ -e "$_dest" ] && continue
mkdir -p "$(dirname "$_dest")"
# An empty file is not valid JSON, and a harness that discards a config it
# cannot parse would lose everything in it rather than nothing.
if [ "$_kind" = ensure-json ]; then
printf '{}\n' > "$_dest"
else
: > "$_dest"
fi
chown root:root "$_dest"
chmod 444 "$_dest"
done
# The harness's own writable state. Unlike the staged config above, these are
# owned by the sandbox user and stay writable: they are what the harness
# rewrites as it runs - a plugin registry, a catalogue, an install marker -
@@ -348,11 +357,11 @@ if [ -n "${SANDBOX_HARNESS_BRIEFING_ARGS:-}" ]; then
fi
# TCP front door for the host-side credential proxy (see bin/dejima and
# bin/dejima-anthropic-proxy): the proxy listens on a bind-mounted unix
# socket, and anthropic-proxy-forwarder.js pipes bytes into it. Only started
# bin/dejima-credential-proxy): the proxy listens on a bind-mounted unix
# socket, and credential-proxy-forwarder.js pipes bytes into it. Only started
# when this session actually has a proxy socket.
#
# In the default tls transport the harness's proxied_api_domain is pointed at
# In the default tls transport the backend's proxied_domain is pointed at
# loopback here and the forwarder terminates TLS for it, so the harness runs
# with no base-URL override and no placeholder token - it is indistinguishable
# from a stock install, which is the whole point (see docs/security.md). The hosts
@@ -360,40 +369,40 @@ fi
# extra_hosts: under [credentials] proxy = off nothing listens on :443, and
# an unconditional entry would black-hole the real API on exactly the path
# that has to keep working.
SANDBOX_ANTHROPIC_TRANSPORT="${SANDBOX_ANTHROPIC_TRANSPORT:-tls}"
export SANDBOX_ANTHROPIC_TRANSPORT
SANDBOX_CREDENTIAL_TRANSPORT="${SANDBOX_CREDENTIAL_TRANSPORT:-tls}"
export SANDBOX_CREDENTIAL_TRANSPORT
# Checked here rather than in bin/dejima because every launch reaches this
# script, including a bare `docker run` (see test/runtime.bats). The two halves
# branch on opposite polarity - bin/dejima treats anything but `loopback` as
# tls, this script and the forwarder treat anything but `tls` as loopback - so
# a typo would configure them for different transports and fail every API call
# with nothing pointing back at the cause.
case "$SANDBOX_ANTHROPIC_TRANSPORT" in
case "$SANDBOX_CREDENTIAL_TRANSPORT" in
tls|loopback) ;;
*)
echo "FATAL: SANDBOX_ANTHROPIC_TRANSPORT is set to '$SANDBOX_ANTHROPIC_TRANSPORT'." >&2
echo "FATAL: SANDBOX_CREDENTIAL_TRANSPORT is set to '$SANDBOX_CREDENTIAL_TRANSPORT'." >&2
echo "The only valid values are 'tls' (the default) and 'loopback'; they are" >&2
echo "case-sensitive. Not starting the session - an unrecognised value would" >&2
echo "otherwise be read as one transport here and the other by bin/dejima." >&2
exit 1
;;
esac
if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
if [ -n "${SANDBOX_CREDENTIAL_PROXY_SOCKET:-}" ]; then
forwarder_port=18300
# Which name the terminator answers for is the selected harness's, from
# `proxied_api_domain` in its manifest - this image is shared by every
# harness and must name none of them. Empty here means a harness declared
# a credential proxy it gave no endpoint for: the terminator would come up
# answering for nothing, the session would reach the real API directly,
# and the credential the proxy exists to withhold would be the one in use.
proxied_domain="${SANDBOX_HARNESS_PROXIED_DOMAIN:-}"
# `proxied_domain` in its manifest - this image is shared by every
# backend and must name none of them. Empty here means a proxy socket was
# mounted with no endpoint declared for it: the terminator would come up
# answering for nothing, the session would reach the real API directly, and
# the credential the proxy exists to withhold would be the one in use.
proxied_domain="${SANDBOX_BACKEND_PROXIED_DOMAIN:-}"
if [ -z "$proxied_domain" ]; then
echo "FATAL: a credential proxy socket is mounted, but the selected harness" >&2
echo "declares no proxied_api_domain for the terminator to stand in front of." >&2
echo "FATAL: a credential proxy socket is mounted, but the selected" >&2
echo "backend declares no proxied_domain for the terminator." >&2
echo "Not starting the session - traffic would reach the API unintercepted." >&2
exit 1
fi
if [ "$SANDBOX_ANTHROPIC_TRANSPORT" = "tls" ]; then
if [ "$SANDBOX_CREDENTIAL_TRANSPORT" = "tls" ]; then
forwarder_port=443
# Generated here, in the container, rather than on the host: the key
# never touches host disk and there is no host state to clean up. It
@@ -401,7 +410,7 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
# unprivileged - and worthless to anyone else, since the only thing
# that trusts this anchor is this container, which is already reading
# that traffic in the clear.
tls_dir=/etc/dejima-anthropic-tls
tls_dir=/etc/dejima-credential-tls
mkdir -p "$tls_dir"
# set -e aborts the script on openssl's nonzero exit either way, so a
# failure here never proceeds to chown/export a missing cert - but
@@ -427,8 +436,8 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
chown "$SANDBOX_USER" "$tls_dir/key.pem" "$tls_dir/cert.pem"
chmod 600 "$tls_dir/key.pem"
chmod 644 "$tls_dir/cert.pem"
export SANDBOX_ANTHROPIC_TLS_CERT="$tls_dir/cert.pem"
export SANDBOX_ANTHROPIC_TLS_KEY="$tls_dir/key.pem"
export SANDBOX_CREDENTIAL_TLS_CERT="$tls_dir/cert.pem"
export SANDBOX_CREDENTIAL_TLS_KEY="$tls_dir/key.pem"
# /etc/hosts wins over DNS in getaddrinfo, so this bypasses the DNS
# proxy entirely - which is why fixed-domains.sh leaves the name off
# the allowlist whenever there is a proxy socket.
@@ -438,7 +447,7 @@ if [ -n "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ]; then
# the single most ordinary variable a corporate environment sets.
export NODE_EXTRA_CA_CERTS="$tls_dir/cert.pem"
fi
gosu "$SANDBOX_USER" node /usr/local/bin/anthropic-proxy-forwarder.js &
gosu "$SANDBOX_USER" node /usr/local/bin/credential-proxy-forwarder.js &
# Node's own startup (V8 init, module load, then finally binding the
# listen socket) isn't instant - without this wait, a request that
# arrives right after this script proceeds (an automated test's
@@ -473,6 +482,7 @@ fi
# behavior byte-for-byte.
[ -n "${ANTHROPIC_BASE_URL:-}" ] || unset ANTHROPIC_BASE_URL
[ -n "${ANTHROPIC_AUTH_TOKEN:-}" ] || unset ANTHROPIC_AUTH_TOKEN
[ -n "${ANTHROPIC_API_KEY:-}" ] || unset ANTHROPIC_API_KEY
# Unset is the ordinary case (remote access is opt-in) and says nothing.
# Set-but-unusable is worth a startup line because of how it presents: the
@@ -501,18 +511,26 @@ 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.
if [ "${SANDBOX_TEST_MODE:-0}" = "1" ]; then
exec gosu "$SANDBOX_USER" sleep infinity
drop_caps --user="$SANDBOX_USER" -- -c 'exec "$@"' dejima sleep infinity
fi
# Drop root -> the sandbox user for the actual session; that user has no
# sudo/setuid path back to root after this, so it can't re-run
# init-firewall.sh itself to widen the allowlist mid-session.
# 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
# resting on the other. capsh does both in one process because it has to: the
# bounding set can only be reduced while CAP_SETPCAP is still held, and
# changing uid first gives it away.
#
# SANDBOX_CMD is what actually runs as that user - the selected harness's
# own command unless [core] command overrides it (e.g. to `nvim`) for an
@@ -529,4 +547,4 @@ if [ -z "${SANDBOX_CMD:-}" ]; then
echo "command: docker run --entrypoint sh ..." >&2
exit 1
fi
exec gosu "$SANDBOX_USER" $SANDBOX_CMD "$@"
drop_caps --user="$SANDBOX_USER" -- -c 'exec "$@"' dejima $SANDBOX_CMD "$@"
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# PID 1 of the filter container: brings this launch's egress control up, says
# so, and then holds the network namespace open for the sandbox to join.
#
# The sandbox is a separate container that joins this namespace and declares no
# network capability of its own. Capabilities are per process, so it cannot
# alter the filter installed here even at uid 0 - which is the whole reason the
# two are separate. See docs/security.md.
#
# Nothing a session runs ever executes in here. This container holds the
# privilege; the one beside it holds the session.
set -Eeuo pipefail
trap 'status=$?; echo "ERROR: filter-entrypoint.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
# shellcheck source=caps-lib.sh
source /usr/local/bin/caps-lib.sh
require_setpcap
/usr/local/bin/firewall-up.sh
# Written last, and only on the success path above - `set -e` means a firewall
# that failed to come up never reaches this line. bin/dejima waits for this
# file before it starts the sandbox, so the ordering that makes the arrangement
# fail-closed is a fact about the filesystem rather than about how long a
# launcher guessed the firewall would take.
#
# Under /run, which is a tmpfs: a container that is restarted rather than
# relaunched must not inherit a marker saying its filter is up before it is.
: > /run/dejima-firewall-ready
echo "filter: egress control is up; holding the namespace for the session"
# Held open by a process that does nothing else. The namespace lives as long as
# a process is in it, and this container is started with an init that forwards
# SIGTERM, so `docker stop` ends it promptly rather than waiting out a timeout.
exec sleep infinity
+137
View File
@@ -0,0 +1,137 @@
#!/bin/bash
# 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.
# What this does is a property of a network namespace; everything else an
# entrypoint does is a property of a container.
#
# Called with the caller's environment: DEJIMA_FORGE_HOSTS, LOKI_URL,
# ALLOWED_DOMAINS, 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).
trap 'status=$?; echo "ERROR: firewall-up.sh aborted at line $LINENO (exit $status): $BASH_COMMAND" >&2' ERR
# shellcheck source=caps-lib.sh
source /usr/local/bin/caps-lib.sh
# 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. 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
dejima_permit_open "$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')" internal
fi
# 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
# 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.
#
# Added in its own class rather than all in one. A forge-class name in the open
# class would get the resolver a line more specific than the forge suffix entry,
# feeding the open-class set, and the port restriction would rest on the forge's
# published ranges alone - one layer where the design has two. A project
# declaring github.com is the ordinary way to reach that.
#
# Recorded rather than skipped, even though the suffix entry already makes it
# reachable: [core] internal_domains is checked against these lists by exact
# string, so a name reachable only by suffix reads as one nothing permits, and
# the launch stops on a configuration that is in fact correct.
for _domain in ${ALLOWED_DOMAINS:-}; do
if [ "$(dejima_class_for "$_domain")" = forge ]; then
dejima_permit_forge "$_domain"
else
dejima_permit_open "$_domain"
fi
done
# 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 &
+149 -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,147 @@
# 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 harness (proxied_api_domain); the rule
# about when to allowlist it is dejima's, and stays here.
if [ -z "${SANDBOX_ANTHROPIC_PROXY_SOCKET:-}" ] \
&& [ -n "${SANDBOX_HARNESS_PROXIED_DOMAIN:-}" ]; then
FIXED_DOMAINS+=("$SANDBOX_HARNESS_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
}
# The same, in the class whose ports are restricted. For a name a forge suffix
# already covers: the suffix makes it reachable, but only an entry of its own
# makes it something the rest of the system can name - dejima_mark_internal and
# the policy's own internal_domains check both look for the exact string, and a
# name reachable by suffix alone is invisible to them.
dejima_permit_forge() {
local entry="$1"
[ -n "$entry" ] || return 0
DEJIMA_FORGE_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() {
# Case-folded on both sides, for the reason dejima_class_for gives. Here the
# cost of comparing verbatim is not a lost restriction but a refused launch:
# this returns non-zero for an entry nothing permits, and the caller stops
# the container naming it - so [core] internal_domains and the key that
# permitted the same host, differing only in case, is a configuration that
# is correct and will not start.
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() {
# Case-folded, because DNS names are case-insensitive and the resolver
# treats them so. Comparing verbatim put GITHUB.COM in the open class while
# github.com was forge, which is the narrower class silently lost on a
# spelling an operator is free to use.
local domain="${1,,}" suffix
for suffix in ${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"}; do
suffix="${suffix,,}"
if [ "$domain" = "$suffix" ] || [[ "$domain" == *".$suffix" ]]; then
printf 'forge'
return 0
fi
done
printf 'open'
}
+20 -2
View File
@@ -38,6 +38,12 @@ fi
repo_dir="$PWD"
subcommand=""
argv=("$@")
# Every `-c key=value` this push carries, kept rather than only skipped. The
# question below is where the push will go, and git answers that from the
# config the push runs under - so resolving it without these would answer for
# a different configuration than the one about to be used. A one-command
# `-c remote.origin.pushurl=...` is exactly that case.
config_overrides=()
i=0
while [ "$i" -lt "${#argv[@]}" ]; do
case "${argv[$i]}" in
@@ -45,7 +51,12 @@ while [ "$i" -lt "${#argv[@]}" ]; do
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
;;
-c|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
-c)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] \
&& config_overrides+=(-c "${argv[$i]}")
;;
--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
i=$((i + 1))
;;
-*)
@@ -97,7 +108,14 @@ while [ "$i" -lt "${#argv[@]}" ]; do
done
[ -n "$remote" ] || remote=origin
remote_url="$("$REAL_GIT" -C "$repo_dir" remote get-url "$remote" 2>/dev/null)"
# --push, because that is the URL the push will use. Without it this reads
# remote.<name>.url while git pushes to remote.<name>.pushurl when one is set,
# so a pushurl naming any other host is checked against the fetch URL and
# allowed. It needs no evasion to reach: `git -c remote.origin.pushurl=...
# push origin branch` sets one for a single command, and .git/config is in the
# read-write project mount, so a session can also just write one down.
remote_url="$("$REAL_GIT" ${config_overrides[@]+"${config_overrides[@]}"} \
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)"
# A remote this cannot resolve is refused rather than passed through. It is
# usually a typo, which git would reject in a moment anyway - but it is also
# what a disagreement between this parser and git's looks like, and the
-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
+27 -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)
@@ -48,6 +52,19 @@ ship_stream() {
last_flush=$(date +%s)
}
# tail's stderr is discarded below because -F is meant to wait for a file
# that does not exist yet, and complains once per second while it does.
# That also swallows a permission error, which is not transient and which
# looks identical from the outside: a stream that ships nothing, which is
# also what a quiet session looks like.
#
# This process holds no capabilities (see entrypoint.sh), so it reads these
# two files on their ordinary permissions and nothing else. Say so once
# when that is not enough, rather than shipping an empty stream.
if [ -e "$file" ] && [ ! -r "$file" ]; then
echo "WARNING: $file is not readable by $(id -un) - the $job stream will ship nothing." >&2
fi
last_flush=$(date +%s)
tail -F -n0 "$file" 2>/dev/null | while true; do
if IFS= read -r -t "$BATCH_MAX_SECONDS" line; then
@@ -61,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.
@@ -70,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
+75
View File
@@ -0,0 +1,75 @@
#!/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.
#
# `--check` runs the verification alone, changing nothing. It exists so a test
# against a running container asserts the property by running this code rather
# than by restating it - in particular the root list below, which is the part
# most easily got wrong.
set -eu
check_only=false
if [ "${1:-}" = "--check" ]; then
check_only=true
elif [ "$#" -gt 0 ]; then
echo "usage: ${0##*/} [--check]" >&2
exit 2
fi
# 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
if ! $check_only; then
# 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
fi
# 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:\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,
+446 -105
View File
@@ -9,73 +9,219 @@ scratch space, and what the sandbox knows about itself.
## Configuration reference
Every key dejima reads, its default, and what it is for. All of it lives in
one file, `~/.config/dejima/config`. `./install.sh` writes a starter copy with
every key present and commented out at its default, so nothing here has to be
set to get a working sandbox: uncomment what you need. `dejima --print-config`
prints the resolved schema with real values, which is the fastest way to see
what a given launch will actually use.
Every key dejima reads, its default, and what it is for.
### Two files, and only one of them is yours
`~/.config/dejima/config` is **yours**. dejima reads it and never writes it.
`./install.sh` creates it if you have none, carrying your selection and
anything it could migrate from an older setup, and nothing else.
`~/.config/dejima/defaults.conf` beside it is **dejima's**, and holds every
defaulted key at the value dejima shipped *when this machine first saw that
key*. Your file overrides it; it overrides what the current release would have
chosen.
That second file is why a release cannot move a setting you never chose.
`[core] harness` has no default at all for this reason - a default that could
change later is a config file that is not the source of truth - and this
applies the same argument to every other key. It is not hypothetical: the
`forge` and `workflow` defaults once changed from `gitea`+`worktree-pr` to
`none`+`local-only`, which silently stopped existing sandboxes pushing.
The first launch on a machine writes it and says so once. `./install.sh`
rebuilds it: keys a release added are appended, keys it retired are dropped,
and **every value already in it stays**. When a shipped default no longer
matches your pinned one, `./install.sh` names both and the line that would
take the new one. It does not take it - that is the whole point - and the line
goes in *your* file, never in `defaults.conf`, which the next rebuild rewrites.
Do not edit `defaults.conf`. Anything in it that dejima no longer understands
is skipped rather than refused, since you are not the one who put it there, and
a launch tells you to re-run `./install.sh`.
`dejima --print-config` prints every key as it resolves. Which of the three
answered is readable from the files themselves: in `config` it is yours, in
`defaults.conf` it is pinned, in neither it is what this release ships.
**`required` in the Default column is what you have to set yourself**, and it
is exactly the set with no line in `defaults.conf`: a key with no default has
nothing to pin. Exactly one is required unconditionally - `[core] harness`.
Every other `required` is conditional on a selection you made, and each table
below says which; a section belonging to something you did not select is never
read, so its required keys are not yours to fill in.
Every tool takes `--help`, which describes its own arguments and what they do.
That is the fastest answer to "what does this flag mean"; this page is for the
config file behind them.
### `[core]` - what this sandbox runs
The three selections come first because everything below depends on them: the
harness decides which `[harness.<name>]` section is read, and the forge decides
which `[forge.<name>]`.
| Key | Default | Purpose |
| --- | --- | --- |
| `[core] harness` | *(none - must be set)* | agent harness the container runs; refused if unset, so a later default cannot change your selection. Also settable per launch with `--harness`, which is how a host keeps one harness as its default and reaches another when it wants to |
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`/`github-pr`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
| `[core] command` | *(the selected harness's own `command`)* | container's main process (e.g. `nvim`), also settable per launch with `--cmd`. Overriding it changes what dejima launches, not which harness is wrapped |
| `[core] identity` | unset | which `[identity.<name>]` section supplies the credentials and git name this session acts under; also settable per launch with `--identity`, which is how one host runs a session per account. Unset selects `[identity.default]` where the config has one. A name with no section is refused rather than falling back |
| `[core] 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) |
| `[core] 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 |
| `[ui] banner` | `on` | the three-line summary at the top of a launch - project and branch, the selection, and the switches whose state changes what a session can do. `off` prints nothing. Never drawn when output is redirected |
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir, mounted at `~/shared` |
| `[core] ssh_key` | unset | **deprecated** - the pre-identity spelling of `[identity.default] ssh_key`. Still read when the config has no `[identity.default]`; refused when it has one, since the two say the same thing |
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
| `[harness.claude-code] file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
| `[harness.claude-code] statusline_path` | `~/.claude/statusline.sh` | statusline script path |
| `[harness.cline] model` | *(none - must be set)* | model id the endpoint serves; no default, because any shipped one goes stale with the next Cline release. Also settable per launch with `--model` |
| `[harness.cline] base_url` | unset | OpenAI-compatible endpoint. Unset means Anthropic through dejima's credential proxy. Accepts a loopback or RFC 1918 address, or a host you have allowlisted. Also settable per launch with `--base-url` |
| `[harness.cline] context_window` | unset | tokens Cline may use before compacting. Needed for a model it has no catalogue entry for, which is every self-served one. Also settable per launch with `--context-window` |
| `[harness.cline] state_dir` | `~/.local/share/dejima/cline` | per-project Cline state; one directory per workspace |
| `[forge.gitea] ssh_host` | unset | Gitea/Forgejo SSH hostname: skip if you only push to GitHub |
| `[forge.gitea] api` | unset | `https://<gitea host>/api/v1`: skip if you only push to GitHub |
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github`: deliberately separate from `[github] token_file` below; see "GitHub push" in docs/security.md |
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh` |
| `[mounts] readonly` | unset | extra host paths (colon-separated) a personal config needs, mounted read-only at their own path |
| `[mounts] 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 |
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly |
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](security.md)) |
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint; `--no-log` clears it for one launch, which silences everything downstream of it |
| `[epitaph] enabled` | `on` | keep the summary a harness writes when a session compacts; `--no-epitaph` skips it for one launch |
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
| `[epitaph] loki` | `off` | also push each epitaph to `[logging] loki_url`, host-side |
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md. `--no-conversation` turns it off for one launch, and `--no-log` implies it |
| `[audit] dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
| `[audit] baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
| `[audit] keep` | `10` | how many past findings/baselines to retain |
| `[audit] diffs` | `on` | keep the diff of what changed alongside each finding; `off` records that something changed without retaining the content |
| `[audit] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings; also settable per launch with `--strict-audit` |
| `[remote-access] enabled` | `off` | the switch: nothing in "Remote access" below runs until this is `on` |
| `[remote-access] ntfy_url` | unset | ntfy topic URL for session notifications |
| `[remote-access] base_url` | unset | public URL of the web front door, for notification links |
| `[remote-access] ttyd_port` | `7681` | port `ttyd` listens on |
| `[remote-access] ttyd_font_size` | unset | `ttyd` terminal font size |
| `[remote-access] ttyd_tmux_prefix` | unset | the tmux prefix the page's buttons send; the page assumes stock `C-b` without it |
| `[remote-access] ttyd_focus_on_tap` | unset | `on` lets a tap on the terminal raise the phone keyboard; unset means only the KBD button does |
| `[remote-access] ttyd_button_colors` | unset | `off` returns the toolbar to monochrome |
| `[remote-access] debounce` | `60` | seconds between repeat notifications for the same session |
| `harness` | **required** | agent harness the container runs; refused if unset, so a later default cannot change your selection. Also settable per launch with `--harness`, which is how a host keeps one harness as its default and reaches another when it wants to |
| `forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`/`github-pr`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
| `workflow` | `local-only` | workflow the session briefing tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
| `identity` | unset | which `[identity.<name>]` section supplies the credentials and git name this session acts under; also settable per launch with `--identity`, which is how one host runs a session per account. Unset selects `[identity.default]` where the config has one. A name with no section is refused rather than falling back |
| `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 |
| `ssh_key` | unset | **deprecated** - the pre-identity spelling of `[identity.default] ssh_key`. Still read when the config has no `[identity.default]`; refused when it has one, since the two say the same thing |
**No forge is the default**: `[core] forge = none` with
`[core] workflow = local-only`, so the sandbox works locally and pushes
nowhere. Every other workflow needs forge credentials, which is what you are
opting into when you pick one.
### `[harness.<name>]` - the harness you selected
One section per harness, and only the one `[core] harness` names is read. Each
ships its own keys; see [providers.md](providers.md) for the manifest contract
behind them.
#### `[harness.claude-code]`
| Key | Default | Purpose |
| --- | --- | --- |
| `backend` | **required** | which model API this harness talks to, and so which credential the proxy injects. A directory under `backends/`, each shipping its own `[backend.<name>]` keys. Per harness rather than global, because running Claude Code against Anthropic and Cline against something else is the ordinary case. No default, for the reason `[core] harness` has none: one that could change later would move a session to another endpoint without its config changing. Unset, dejima writes `anthropic` into your config once and says so - a migration rather than a default, so nothing it decides later can move you. Also settable per launch with `--backend`. See [providers.md](providers.md#model-backends) |
| `projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
| `file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
| `statusline_path` | `~/.claude/statusline.sh` | statusline script path |
#### `[harness.cline]`
| Key | Default | Purpose |
| --- | --- | --- |
| `backend` | **required** | as above, and the risk is concrete here: Cline supports many backends and has one of its own. Nothing is written into your config for you either, unlike Claude Code: there is no one endpoint every existing Cline session was already using, so any value dejima recorded would be a guess. Also settable per launch with `--backend` |
| `model` | **required** | model id the endpoint serves, exactly as that endpoint spells it - a hosted catalogue namespaces by vendor (`z-ai/glm-5.3`) and may qualify a variant (`deepseek/deepseek-r1:free`). No default, because any shipped one goes stale with the next Cline release. Also settable per launch with `--model` |
| `context_window` | unset | tokens Cline may use before compacting. Needed for a model it has no catalogue entry for, which is every self-served one. Also settable per launch with `--context-window` |
| `state_dir` | `~/.local/share/dejima/cline` | per-project Cline state; one directory per workspace |
### `[backend.<name>]` - the model API you selected
One section per backend, and only the one your harness's `backend` names is
read. `proxy` is available in every one of them; the rest are that backend's
own. See [providers.md](providers.md#model-backends) for how much use each has
actually had.
| Key | Default | Purpose |
| --- | --- | --- |
| `[backend.<name>] proxy` | *(inherits `[credentials] proxy`)* | whether the credential proxy runs for this backend. Per backend so that one backend the proxy cannot serve is not a reason to stop withholding credentials from the ones it can. Off means this backend's real credential is given to the session, and dejima says so at launch |
| `[backend.anthropic] credential_file` | `~/.claude/.credentials.json` | the host file this backend's credential is read from, per request. A path, never a secret, and never mounted into the container |
| `[backend.anthropic-api-key] credential_file` | `~/.claude.json` | where your Anthropic API key is. Defaulted to where Claude Code's console login puts it, so the common case needs nothing set; point it at a file of your own if you hold the key yourself. Read host-side per request and never mounted, and the key is stripped from the copy of `~/.claude.json` dejima stages |
| `[backend.anthropic-api-key] credential_field` | `primaryApiKey` | which value inside that file is the key, as dot-separated object names. Clear it (`credential_field =`) when the file is nothing but the key |
| `[backend.openrouter] credential_file` | **required** | your OpenRouter API key, in a file. Read host-side per request and never mounted; the container only ever holds a placeholder. No default, because a key's location is yours and guessing at one would point dejima at a file it has no reason to think exists |
| `[backend.self-hosted] base_url` | **required** | where you serve the model. A loopback or RFC 1918 address, or a host you have allowlisted - it has to be an endpoint needing no credential, since there is none to withhold. On the backend rather than the harness, so a local endpoint stays configured while a hosted API is selected |
### `[credentials]` - how the credential is withheld
| Key | Default | Purpose |
| --- | --- | --- |
| `proxy` | `on` | host-side credential proxy; `off` mounts the real credential into the container directly. Overridable per backend with `[backend.<name>] proxy` |
| `transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit base URL, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](security.md)) |
### `[identity.<name>]` - who the session acts as
Groups the SSH key, the git name and the forge credential belonging to one
account, so a host can run a session per account. Selected by `[core]
identity`, or `[identity.default]` when that is unset. Nothing here is
required: an identity supplies what it names and no more.
| Key | Default | Purpose |
| --- | --- | --- |
| `ssh_key` | unset | the SSH key this identity pushes with |
| `user_name` | unset | `user.name` for commits made in the session |
| `user_email` | unset | `user.email` for commits made in the session |
| `token_file` | *(the forge's own default)* | this identity's Gitea/Forgejo API token, overriding `[forge.gitea] token_file`. Read only when `[core] forge = gitea` |
| `push_token_file` | unset | this identity's write-capable GitHub PAT, overriding `[forge.github] push_token_file`. Read only when `[core] forge = github` |
See [Identities](#identities) below for the worked examples.
### `[forge.<name>]` and `[github]` - forge credentials
Only the section `[core] forge` names is read. `[github]` is separate from
`[forge.github]` on purpose: the first is read access to GitHub whichever forge
you selected, the second is push access when GitHub *is* your forge.
| Key | Default | Purpose |
| --- | --- | --- |
| `[forge.gitea] ssh_host` | **required** for `forge = gitea` | Gitea/Forgejo SSH hostname |
| `[forge.gitea] api` | **required** for `forge = gitea` | `https://<gitea host>/api/v1` |
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github`: deliberately separate from `[github] token_file`; see "GitHub push" in [docs/security.md](security.md) |
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh`. Read whichever forge is selected, and it should stay read-only |
### `[mounts]` - extra host paths
| 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 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
| Key | Default | Purpose |
| --- | --- | --- |
| `dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
| `baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
| `keep` | `10` | how many past findings/baselines to retain |
| `diffs` | `on` | keep the diff of what changed alongside each finding; `off` records that something changed without retaining the content |
| `strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings; also settable per launch with `--strict-audit` |
### `[epitaph]` - what a compacted session leaves behind
| Key | Default | Purpose |
| --- | --- | --- |
| `enabled` | `on` | keep the summary a harness writes when a session compacts; `--no-epitaph` skips it for one launch |
| `dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
| `loki` | `off` | also push each epitaph to `[logging] loki_url`, host-side |
### `[logging]` - shipping logs off the host
Off entirely until `loki_url` is set. Nothing leaves the machine otherwise,
and there is no endpoint of dejima's own anywhere in this.
| Key | Default | Purpose |
| --- | --- | --- |
| `loki_url` | unset | ship DNS/firewall logs to a Loki endpoint; `--no-log` clears it for one launch, which silences everything downstream of it |
| `conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in [docs/security.md](security.md). `--no-conversation` turns it off for one launch, and `--no-log` implies it |
### `[remote-access]` - the web front door
Off entirely until `enabled` is `on`. The `ttyd_*` keys configure the terminal
page and are read once at process start, so editing them needs
`systemctl --user restart dejima-ttyd.service`. See
[docs/remote-access.md](remote-access.md).
| Key | Default | Purpose |
| --- | --- | --- |
| `enabled` | `off` | the switch: nothing else here runs until this is `on` |
| `ntfy_url` | **required** for notifications | ntfy topic URL for session notifications. `dejima-notify-watch` refuses to start without it; nothing else reads it |
| `base_url` | unset | public URL of the web front door, for the notification's deep link. Absent, pushes still go out - just with no link on them |
| `debounce` | `60` | seconds between repeat notifications for the same session |
| `ttyd_port` | `7681` | loopback port `ttyd` listens on; the reverse proxy has to `proxy_pass` to the same one |
| `ttyd_font_size` | unset | terminal font size in px, 8-72. Applies to every client, phone and desktop alike |
| `ttyd_tmux_prefix` | unset | the tmux prefix the page's buttons send; the page assumes stock `C-b` without it |
| `ttyd_focus_on_tap` | unset | `on` lets a tap on the terminal raise the phone keyboard; unset means only the KBD button does |
| `ttyd_button_colors` | unset | `off` returns the toolbar to monochrome |
### `[ui]` - what a launch prints
| Key | Default | Purpose |
| --- | --- | --- |
| `banner` | `on` | the three-line summary at the top of a launch - project and branch, the selection, and the switches whose state changes what a session can do. `off` prints nothing. Never drawn when output is redirected |
## Global and per-project domains and dependencies
Allowed domains and dependencies each stack through their own layers. The
@@ -97,11 +243,20 @@ for them.
A project can declare what it needs in two committed files:
```
.dejima/allowed-domains # one domain per line
.dejima/allowed-domains # one domain per line, fully qualified
.dejima/dependencies # <apt|npm|pip|cargo>:<name> per line, or a
# tarball:<name>@<sha256>:<url> release archive
```
A domain has to name at least two labels. A permitted name covers everything
under it, because the sandbox's resolver is configured per name and matches the
name and its subdomains, with no exact-name form to use instead - so a bare
label would grant its whole suffix, and `org` would ask for every `.org` name.
The same floor applies to every key whose value becomes a permitted
destination: `[core] internal_domains`, a forge's `ssh_host` and `api`, a
backend's `base_url`, and `[logging] loki_url`. A single-label value is refused
when the entry is declared and again at launch, naming the key it came from.
**Declaring grants nothing.** The project directory is bind-mounted
read-write, so a session could write these files; they are read only to
compare against reality and suggest a command. Approvals live outside the
@@ -333,7 +488,9 @@ you don't have one.
### Cline
Almost nothing carries over, and that is the design rather than a gap.
Your own configuration carries over the same way Claude Code's does - the
table under [What comes in from your host](#what-comes-in-from-your-host) is
the list. Your *sessions* do not, and that is the design rather than a gap.
Cline keeps one `sessions.db` indexing every session it has ever run, with
the project a session belonged to as a field inside the record rather than a
@@ -344,21 +501,33 @@ under `[harness.cline] state_dir` (default `~/.local/share/dejima/cline`),
mounted at `~/.cline/data` in the container. Sessions persist across runs of
the same project and are invisible to every other project.
Because that directory is dejima's rather than yours, resuming on the host
needs `CLINE_DATA_DIR` set to it. The exact command is at the end of every
session's briefing.
Because that directory is dejima's rather than yours, resuming on the host is
not a supported path - see
[Sessions, and where they live](#sessions-and-where-they-live) below. Relaunch
dejima on the same project instead, which is what the briefing tells the
session to offer.
The provider configuration is written fresh by dejima at each launch and is
not yours either. Two shapes are supported:
The model configuration is written fresh by dejima at each launch and is not
yours either. Where the model is comes from `[harness.cline] backend` and
nowhere else, so there is no second setting for it to disagree with, and two
shapes are supported:
- **`base_url` unset** - Anthropic, reached through dejima's own credential
proxy exactly as Claude Code is. What is written into the container is the
placeholder `sandbox-credential-proxy-placeholder`; the real credential
stays on the host.
- **`base_url` set to a model you serve yourself** - either a loopback or
RFC 1918 address, or a host you have allowlisted with `dejima-allow`.
- **A backend the proxy serves** (`anthropic`, `anthropic-api-key`,
`openrouter`) - reached through dejima's own credential proxy exactly as
Claude Code is. What is written into the container is the placeholder
`sandbox-credential-proxy-placeholder` and the endpoint's real hostname; the
credential stays on the host. Which of Cline's own provider types dejima
writes comes from the backend's `api_style`: its native Anthropic client for
one, its OpenAI-compatible client for the other.
- **A backend with no credential** (`self-hosted`, carrying its own
`[backend.self-hosted] base_url`) - a model you serve, at either a loopback
or RFC 1918 address or a host you have allowlisted with `dejima-allow`.
Nothing needs a credential, so there is nothing to withhold.
Keeping the address on the backend rather than on the harness is what lets you
have a local model and a hosted API both configured and switch between them by
changing one word.
A name qualifies through the allowlist rather than through how it is spelled,
because that is the same store deciding whether the container can reach the
endpoint at all: one that fails the check is one the session could not have
@@ -366,13 +535,13 @@ connected to anyway. A model server on your own network normally has a name,
so requiring a literal address would have made this path unusable for the
ordinary case.
Anything else is refused at launch - a hosted provider's API key would have to
go inside the container, and dejima has no proxy that could hold one back on
its behalf. Note what the check is *not* doing: dejima writes a placeholder
into the container on this path whatever the endpoint is, so no real
credential enters it either way. What the check decides is whether the
configuration can work at all, which is worth saying at launch rather than as
an auth error three layers down. See
Anything else is refused at launch - a hosted API pointed at through a
no-credential backend would want its key inside the container, and dejima has
no proxy that could hold one back on its behalf. Note what the check is *not*
doing: dejima writes a placeholder into the container on the proxied path
whatever the endpoint is, so no real credential enters it either way. What the
check decides is whether the configuration can work at all, which is worth
saying at launch rather than as an auth error three layers down. See
[development.md](development.md#worked-example-cline).
If the endpoint serves a model Cline has no catalogue entry for - which is every
@@ -408,6 +577,96 @@ editing the base `Dockerfile`: a dependency added that way is declared,
approved and layered like any other, and it survives a rebuild. Editing the
Dockerfile directly puts it outside that record.
#### Sessions, and where they live
This project's Cline state - sessions, transcripts, tasks - is kept on the host
in `~/.local/share/dejima/cline/<project>` and mounted at `~/.cline/data`. It
survives the container being replaced, so **launching dejima again on the same
project picks up where the last session left off.**
A directory per project rather than one shared: Cline indexes every session it
has ever run in a single database, so sharing one would put every project's
history in every project's context - the same boundary `~/.claude/projects`
gets, for the same reason.
**Cline sessions are not importable or exportable.** A session started by
running `cline` on your own machine is in `~/.cline/data` and is not visible
inside dejima; a session started inside dejima is in the per-project directory
and is not offered back to a host-side Cline. There is no import, no export,
and no merge - see
[#301](https://gitbox.branderson.io/dejima/dejima/issues/301) for why that was
looked at and not built.
The session briefing tells a Cline agent this, so it will not offer to hand
you a session to resume outside the sandbox. Claude Code can be resumed on the
host and its briefing says so; the claim lives in each harness's own briefing
fragment rather than in the shared preamble, because only one of them can make
it.
#### What comes in from your host
The same shape as Claude Code's, and for the same reason: your own
configuration should reach a session, and nothing the session writes should
reach your machine.
| Host path | How it arrives | Why |
| --- | --- | --- |
| `~/.agents/skills/` | read-only mount | skills are instructions the agent follows |
| `~/.agents/hooks/`, `~/.cline/hooks/` | read-only mount | hooks are programs cline runs |
| `~/.agents/AGENTS.md` | read-only copy | instructions; a copy also survives it being a symlink into a dotfiles checkout |
| `~/.agents/rules/*.md`, `*.txt` | read-only copy, into `Documents/Cline/Rules/` | see below |
| `~/Documents/Cline/Rules/*.md`, `*.txt` | read-only copy | the location cline actually reads |
| `~/.cline/settings.json`, `~/.cline/cline_mcp_settings.json` | writable copy, discarded | cline rewrites both as it runs |
| `~/.cline/data/` | this project's own state directory | see above |
Read-only is the point rather than caution. A session that could rewrite a
hook, a skill or an MCP server definition would be choosing what happens on
**your** machine the next time you start cline outside a container - which is
persistence for a prompt injection, in files nobody re-reads before the next
run. The two settings files are copies rather than read-only mounts because
cline rewrites them itself; the copy is discarded with the container.
The read-only directories are scanned for symlinks, so a `skills/` that is a
link into a dotfiles checkout brings its target too. dejima refuses a target
that overlaps one of its own guarded stores.
**Global rules come from two places and land in one.** cline loads them from
`~/Documents/Cline/Rules` and nowhere else - `~/.agents/rules` reads like the
obvious companion to `~/.agents/skills` and is not loaded, which you can see
by planting a file in each and opening `cline config`. So dejima copies the
files from *both* host locations into the one directory cline reads, rather
than mounting `~/.agents/rules` where nothing would look at it.
They are copied file by file rather than mounted as a directory, because
dejima writes its own briefing into that same directory and a read-only mount
over it would leave nowhere to put it.
Two collisions have fixed answers. A name present in **both** host locations
is taken from `Documents/Cline/Rules`, since that is cline's own location for
rules and where it writes when you edit them through cline. A host file named
`00-dejima.md` is skipped: that is the briefing's name, and dejima's wins.
## What dejima says before it starts
Some launches print a notice first: an environment variable that is no longer
read, a deprecated key, a guard that is not registered, a line dejima wrote
into your config. Everything after that is the image build, which on a cold
cache is thousands of lines - so when a notice was printed, dejima waits for
Enter before starting it.
The wait happens only when there was something to read, and only when a
terminal is attached. An unattended launch never blocks on it.
`--no-pause` skips the wait and still prints the notice. It is for starting
several sessions at once - a tmuxinator layout, a script - where every one of
them has a terminal and none of them wants a keypress. It is a flag rather
than a config key on purpose: it describes the terminal in front of one
launch, not a standing preference.
A long list of entries left in the shared directory is sampled rather than
printed whole, for the same reason: it is a standing condition you clear once,
and it should not push the things about this run off the screen.
## Identities
**Several accounts on one machine, and none of them yours.** An
@@ -594,23 +853,57 @@ 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, `~/.claude`, `~/.local/bin`, dejima's own checkout, or the project mount
(already read-write, so listing it again is a mistake worth surfacing). A
writable mount over any of those would let a session approve its own domains,
delete the audit findings naming it, or plant code that runs unsandboxed on the
host next time you start an agent. Overlap is checked in both directions after
resolving symlinks, so `read_write = ~` is caught too.
store, the epitaph store, every project's share, `~/.local/bin`, dejima's own
checkout, the project mount (already read-write, so listing it again is a
mistake worth surfacing), and **everything the selected harness keeps on the
host** - its config directories, the files staged and seeded from them, and
the state directories its manifest declares. A writable mount over any of
those would let a session approve its own domains, delete the audit findings
naming it, read another project's transcripts, or plant code that runs
unsandboxed on the host next time you start an agent. Overlap is checked in
both directions after resolving symlinks, so `read_write = ~` is caught too.
The harness half of that list is derived from what the harness declares, not
written down in dejima, so it covers whichever one you selected - `~/.claude`
for Claude Code, `~/.agents` and `~/.cline` for Cline - and covers a harness
added later without anyone extending a list.
Sessions are told which directories these are, so "everything else is
disposable" stays true in the briefing rather than becoming a trap.
@@ -662,28 +955,69 @@ pick up from somewhere else, run dejima inside tmux and use
## Shared scratch space
`~/shared` is a read-write bind mount of `[core] share_dir` (default
`~/.local/share/dejima/shared`, created automatically) - separate
from the project mount, for passing files (screenshots, temp scripts) in and
out of a session without touching the actual checkout. Persists across
sessions since it's a real directory, not a container volume.
`~/shared` is a read-write bind mount of one project's directory under
`[core] share_dir` (default `~/.local/share/dejima/shared`, created
automatically) - separate from the project mount, for passing files
(screenshots, temp scripts) in and out of a session without touching the actual
checkout. Persists across sessions since it's a real directory, not a container
volume.
**One directory per project, not one for the host.** A launch mounts
`<share_dir>/projects/<slug>` at `~/shared`, where the slug is the project's
absolute path with every non-alphanumeric character replaced by a dash - the
same key the audit and epitaph stores use. So a session sees its own project's
handoff files and no other project's, and nothing an agent has been told
changes, because inside the container the path is still `~/shared`.
`dejima-share` is the host side of that:
```sh
dejima-share path # the host directory for this project, created if new
dejima-share path ~/code/x # …for another project
dejima-share list # every project's share, with size and last write
dejima-share adopt # move files left loose in share_dir into this project's
```
**To hand a file *to* a session,** put it in `dejima-share path` before
launching. That is what makes the inbound direction work without a mount that
every project can read.
**From another machine,** `sshfs` puts a project's share in your own file
manager over the SSH access you already have. Read
[Mounting the box's files](remote-access.md#mounting-the-boxs-files) before
setting that up: every byte and every filename in there was written by a
sandboxed session, and a desktop that mounts the directory indexes,
thumbnails and previews what it finds without waiting to be asked. Nothing
dejima runs is in that path, so the sanitising it does for its own output does
not reach there.
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.** It persists across sessions by design, which
means anything you left in it is readable by every session after the one you
left it for. A credential dropped in there to get one job done, and then
forgotten, is a credential you have handed to every session since.
**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
persists by design, so anything in it is readable by every future session
against that project: a credential dropped in there to get one job done, and
then forgotten, is a credential you have handed to every session against that
project since. Nothing prunes `share_dir` - `dejima-share list` prints each
project's size and the date of its last write, and deleting is yours.
**Files from before the split.** `share_dir` used to be mounted whole, so
anything at its top level was visible to every session. Those files are still
there and are now mounted into nothing; dejima says so at every launch while
that is true, rather than moving an operator's files on its own.
`dejima-share adopt <project>` moves them into one project's share after
showing what it will move and asking, and it never overwrites.
## Reading what a session left behind
@@ -782,14 +1116,21 @@ session can run.
### The session briefing
A `SessionStart` hook (`~/.claude/hooks/sandbox-context.sh`) injects a
reminder when `DEJIMA` is set: the session is running in a
disposable, firewalled container and should stay within the task at hand -
if something genuinely needs to break out (broader network, host changes,
GUI/hardware), stop and ask rather than trying to work around the firewall,
since the same session can be resumed unrestricted on the host via
`claude --resume`. No-op outside the container. `deny-dangerous-bash.sh`
(`PreToolUse`) also applies here, since `~/.claude/hooks` is mounted in.
Every session is told where it is running: a disposable, firewalled container
it should stay within, and if something genuinely needs to break out - broader
network, host changes, GUI/hardware - it should stop and ask rather than work
around the firewall. Whether the session can be resumed unrestricted on the
host afterwards is the harness's own answer, and each harness's briefing
fragment gives it: Claude Code's names `claude --resume`, Cline's says plainly
that its sessions do not leave the sandbox.
*How* the briefing reaches a session is per harness too, because they do not
agree on a mechanism. Claude Code takes it as a system prompt through the flag
its manifest names; Cline takes no such flag and registers no hook, so
`entrypoint.sh` renders it into a file the harness reads, root-owned and
read-only. A harness doing neither gets it through a `SessionStart` hook. What
the briefing *says* is none of theirs - one script composes it for all three
routes. See [development.md](development.md).
When GitHub is a push destination this session - `--allow-github-push`, or
`forge = github` under a pushing workflow - the briefing also tells the
+88 -53
View File
@@ -12,12 +12,15 @@ 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 container start |
| `docker/ulogd.conf` | `ulogd2` config: turns `init-firewall.sh`'s NFLOG-tagged packets into JSON |
| `docker/log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to the configured Loki endpoint (`[logging] loki_url`). Container-side, because those files only exist there |
| `docker/entrypoint.sh` | Runs as root: DNS proxy, firewall, logging, drops to the sandbox user via `gosu`, then execs the session's command |
| `docker/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/log-shipper.sh` | Tails the two records `egress-policy` produces - name lookups from `dns-lookup-log`, connection verdicts from ulogd2 - 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 |
| `docker/docker-compose.yml` | Wires up mounts/env; invoked via `bin/dejima`, not directly |
| `hooks/sandbox-context.sh` | `SessionStart` hook: injects sandbox context/workflow reminders, and any `.dejima.md` the project ships |
@@ -64,8 +67,10 @@ top-level entries like `statusline.sh`) to resolve, and runs
`docker compose run`. `bin/dejima-allow`
adds a domain to a running session's firewall from the host - see
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
`install.sh` installs the hooks into
`~/.claude/hooks` and registers them in `~/.claude/settings.json`.
`install.sh` writes nothing to `~/.claude`. Every hook dejima uses is baked
into the image at `/usr/local/lib/dejima/hooks` and registered per launch into
the `--settings` overlay, which Claude Code merges with your
`~/.claude/settings.json` rather than replacing it.
## Tests
@@ -74,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
@@ -120,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
@@ -151,11 +159,23 @@ attached: read what `gitea-pr` printed.
### Running the tests
```sh
bats test/ # everything
bats test/audit.bats # one suite - the suites are independent, see .dejima.md
bash test/run-confined.sh # every confined suite - the pull-request gate
bash test/run-confined.sh --changed # only the suites your diff calls for
bats test/audit.bats # one suite - the suites are independent
SKIP_BUILD=1 bats test/runtime.bats # the one suite needing Docker; reuse the current image
```
`test/run-confined.sh` runs the files concurrently and, within each file, the
tests too, via bats' own `--jobs`. Adding `--jobs $(nproc)
--no-parallelize-across-files` to a bare `bats` invocation gets the same effect
for a single suite; both halves of that are needed, because bats otherwise
requires GNU parallel for `--jobs` and only the across-files half actually does.
`--changed` is for iterating. It maps the diff to suites through `suites_for()`
in that script, prints which lane it ran, and says plainly that it is not the
gate - a change is tested when the whole set has run, not when the selected
subset has.
`runtime.bats` is the only suite that needs a Docker daemon: it is the one
that builds the image and boots real containers to assert against.
Everything else, `audit.bats`, `ci-runner.bats`, `config.bats`,
@@ -189,10 +209,12 @@ it has to argue about.
### What a harness must be able to do
Support is per harness and deliberate. A supported harness gets the same
depth of integration Claude Code has - the same mounts, the same guards, the
same audit surfaces - so a harness is not a manifest someone drops in. That
is only possible if the harness can host what those protections need.
Support is per harness and deliberate. A supported harness gets everything
dejima has to give: its state scoped per project, its config carried in
read-only, its audit surfaces watched, and its guards installed wherever it
can host one. So a harness is not a manifest someone drops in - and where it
cannot host a protection, the launch says so rather than leaving the gap
unnamed.
These are the requirements. A harness that fails one of the first four cannot
be supported, and that is a decision about the harness rather than a gap in
@@ -303,7 +325,7 @@ or better is the requirement.
| **R3** | **Yes, by running it, and the documentation is wrong about how.** A rules file at `~/Documents/Cline/Rules/00-dejima.md` reached the model: asked for a codeword only that file contained, the session answered with it. `~/.agents/AGENTS.md` works too. `~/Cline/Rules`, which the documentation names, is **not** read on Linux, and `CLINE_RULES` pointing at a file did nothing. So the briefing is a file rather than a hook, which is what `briefing_path` in the manifest is for. |
| **Network reach** | **In the container, unlike Claude Code's.** Not a requirement, but the thing [security.md](security.md#the-firewall-bounds-the-container-not-the-harness) says has to be established per harness rather than assumed. Asked to fetch a page the allowlist does not carry, Cline's web-fetch tool ran and returned "Unable to connect" - the same answer `curl` gives. So the egress allowlist bounds what reaches a Cline session, which is not true of Claude Code's `WebSearch`. |
| **R4** | **Yes, by running it, and this is where Cline is strongest.** `--data-dir` relocates the whole store - `db/`, `settings/`, `logs/`, `cache/`. A full run against a chosen directory put everything there and left nothing in the default location. There are per-path environment variables too: `CLINE_DATA_DIR`, `CLINE_DB_DATA_DIR`, `CLINE_SESSION_DATA_DIR`, `CLINE_TEAM_DATA_DIR`, `CLINE_PROVIDER_SETTINGS_PATH`, `CLINE_HOOKS_LOG_PATH`. Per-project scoping is a flag rather than an inference. |
| **R5** | **Yes, and confirmed end to end.** Credentials are a plain file, `settings/providers.json`, whose path `CLINE_PROVIDER_SETTINGS_PATH` relocates - so dejima can decline to mount the operator's. The substitute is the one that already exists: Cline configured for `anthropic` with the placeholder key `sandbox-credential-proxy-placeholder`, pointed at the real hostname, ran a real completion through dejima's in-container terminator and host-side proxy. No base-URL override and no new proxy work - the same arrangement as Claude Code's faked `.credentials.json`, and the session is indistinguishable from a stock install. |
| **R5** | **Yes, and confirmed end to end.** Credentials are a plain file, `settings/providers.json`, whose path `CLINE_PROVIDER_SETTINGS_PATH` relocates - so dejima can decline to mount the operator's. The substitute is the one that already exists: Cline configured for `anthropic` with the placeholder key `sandbox-credential-proxy-placeholder`, pointed at the real hostname, ran a real completion through dejima's in-container terminator and host-side proxy. No base-URL override and no new proxy work - the same arrangement as Claude Code's faked `.credentials.json`, and the session is indistinguishable from a stock install. **What that completion did not establish is which models the subscription credential will serve a client that is not Claude Code**: Anthropic gates that per model and answers a refused one with a 429, so the arrangement is confirmed and the entitlement is not dejima's to give. See [providers.md](providers.md#a-subscription-credential-is-claude-codes-and-anthropic-enforces-that). |
Two notes for whoever writes the integration. Cline's plugin manifest schema
still carries a field described as "not read by Claude Code", so its plugin
@@ -342,20 +364,26 @@ that command lives in the harness's briefing fragment rather than in
`resume_command`: briefing substitution is one pass, so a placeholder inside
a substituted value is never resolved.
*Provider-agnostic is not the same as endpoint-agnostic.* Cline can be pointed
anywhere; dejima cannot follow it everywhere. With `base_url` unset it runs
against Anthropic through dejima's own credential proxy and the key never
enters the container. Pointed at someone else's hosted API there is no proxy
that could hold a key back, so the key would have to go in - which is R5
failing, not a network restriction. The plugin therefore accepts `base_url`
only for a model the operator serves themselves - a loopback or RFC 1918
address, or a host they have allowlisted - where an open-weight model needs no
credential at all and R5 has nothing to protect. The allowlist is what
qualifies a name, because it is the same store deciding what the container can
reach: an endpoint failing the check is one the session could not have
connected to anyway, and a model server on someone's own network normally has
a name rather than a literal address. A provider-generic proxy would widen
this to hosted providers; nobody has written one (#171).
*Backend-agnostic is not the same as endpoint-agnostic.* Cline can be pointed
anywhere; dejima cannot follow it everywhere. A backend the proxy serves is
fine whatever its dialect - the proxy takes the credential's kind and header
names as arguments, so `openrouter` works the way `anthropic` does and the key
never enters the container either way. Whether the ENDPOINT then serves the
request is a separate question dejima does not answer: a Claude subscription
credential is served to Claude Code and refused to other clients for the
larger models, which is Anthropic's call and not one dejima works around
(see [providers.md](providers.md#a-subscription-credential-is-claude-codes-and-anthropic-enforces-that)). What dejima cannot follow is a hosted
API reached *without* a backend: there is no proxy holding a key back on its
behalf, so the key would have to go in, which is R5 failing rather than a
network restriction.
So the `credential = none` path is only accepted for a model the operator
serves themselves - a loopback or RFC 1918 address, or a host they have
allowlisted - where the endpoint needs no credential at all and R5 has nothing
to protect. The allowlist is what qualifies a name, because it is the same
store deciding what the container can reach: an endpoint failing the check is
one the session could not have connected to anyway, and a model server on
someone's own network normally has a name rather than a literal address.
*Conversation logging works; epitaphs do not, and only one of those is
Cline's fault.* Cline's transcripts are
@@ -487,9 +515,9 @@ harness-specific path resolving to nothing is the honest answer.
| Seeded files | that a harness reading a credential from a fixed path needs something at it, since an absent file and an empty one are not reliably the same to it. The manifest's `image_files`, created empty and 0600 in that harness's layer, where a mounted credential shadows it - rather than by `entrypoint.sh`, which runs in every image including the base one alone | `harnesses/<name>/harness.conf` |
| Image environment | anything the harness wants set for every session of its own - for Claude Code, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`. The manifest's `image_env`, in the same layer | `harnesses/<name>/harness.conf` |
| Command | there is no default anywhere shared. `SANDBOX_CMD` comes from the manifest's `command` via `bin/dejima`, and `entrypoint.sh` refuses an empty one rather than naming a harness a given image may not contain | `harnesses/<name>/harness.conf`, `docker/entrypoint.sh` |
| Proxied endpoint | which name the in-container TLS terminator answers for, and what `/etc/hosts` pins to loopback. The manifest's `proxied_api_domain`; a credential proxy with no domain declared is refused rather than started answering for nothing | `harnesses/<name>/harness.conf`, `docker/entrypoint.sh` |
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it. `api_domains` and `proxied_api_domain` in the manifest | `harnesses/<name>/harness.conf` |
| Credentials | the whole proxy exists because Claude Code authenticates from `~/.claude/.credentials.json` rather than an env var; the entrypoint seeds a well-formed fake so it has something to read | `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js`, `docker/entrypoint.sh` |
| Proxied endpoint | which name the in-container TLS terminator answers for, and what `/etc/hosts` pins to loopback. The BACKEND's `proxied_domain`, not the harness's: a harness reaches whichever endpoint it is configured for, so the name belongs to the endpoint. A proxied backend with no domain declared is refused rather than started answering for nothing | `backends/<name>/backend.conf`, `docker/entrypoint.sh` |
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it. `api_domains` on the harness, `proxied_domain` on the backend | `harnesses/<name>/harness.conf`, `backends/<name>/backend.conf` |
| Credentials | which substitute the harness accepts, and where it reads it from: a well-formed fake `~/.claude/.credentials.json` the entrypoint seeds for Claude Code, a placeholder key in `settings/providers.json` for Cline, a placeholder in an environment variable on the API-key path. The proxy itself is neither harness's - what the credential is, which header carries it and whether it refreshes are the backend's, passed as arguments | `bin/dejima-credential-proxy`, `docker/credential-proxy-forwarder.js`, `docker/entrypoint.sh` |
| Hook protocol | the event payload arrives on stdin; `SessionStart` returns `hookSpecificOutput.hookEventName`/`additionalContext` as JSON, while a `PreToolUse` hook blocks by exiting 2 with its reason on stderr | `hooks/*.sh` |
| Hook registration | four hooks written into `~/.claude/settings.json` in Claude Code's schema, one `SessionStart` with a `startup\|resume\|clear\|compact` matcher and three `PreToolUse` matching `Bash`. In the plugin, gated per declared guard | `bin/harness/claude-code.sh` |
| Briefing delivery | **by which of three routes**, not whether. Claude Code takes the briefing as a system prompt, through the flag its manifest names in `briefing_args`, because a `SessionStart` payload past roughly nine kilobytes is persisted and replaced with a preview - so the hook route silently under-delivered a briefing carrying a project's `.dejima.md`. Cline takes no such flag and registers no hook, so it declares a `briefing_path` and reads a file `entrypoint.sh` writes root-owned and read-only. A harness doing neither still gets it through the hook. What the briefing *says* is none of theirs: composing it host-side is core's, and the container-side part - this container's id, the declared-versus-installed gap, the project's `.dejima.md` - is one script every route calls | `hooks/dejima-briefing-render.sh`, `hooks/sandbox-context.sh`, `docker/entrypoint.sh` |
@@ -539,13 +567,20 @@ because absent is visible to the operator and broken is not.
`claude-code` is no exception to that: it is declared by its own manifest and
built into its own layer like any other. The base image carries nothing of any
harness's - not its package, not the directories it keeps state in, not its
environment, and not its name as a default command. What is left on the
harness axis is the credential proxy, which is Claude Code's shape throughout
and is the R5 row above.
environment, and not its name as a default command.
Nor is the credential proxy harness-shaped. Which header carries the
credential, what shape it takes, what must not travel beside it and whether it
can be refreshed are all arguments the selected backend supplies, so a second
harness reaches a proxied backend without a branch of its own - the R5 row
above. Cline has run real completions through the same terminator
and the same host-side proxy, against `anthropic` and against `openrouter`,
which is a different api_style through the same code.
`providers.bats` covers the manifest contract itself and is where a new
`harness.conf` gets its parsing and validation tests. The suites that assert
on Claude Code paths by name - `config.bats`, `audit.bats`, `static.bats`,
`runtime.bats` - are the ones that will need a second set, and a green run
against the existing harness proves nothing about a new one. Nothing in
`test/` currently runs a second harness end to end.
`harness.conf` gets its parsing and validation tests. `config.bats`,
`static.bats`, `project.bats` and `conversation.bats` each carry a second
harness's cases beside Claude Code's, which is the shape a third one should
follow. What is still missing is the end of the ladder: `runtime.bats` boots
only `claude-code`, so nothing in `test/` runs a second harness end to end in
a real container, and a green confined run says nothing about that.
+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
+185 -16
View File
@@ -3,16 +3,20 @@
[← dejima README](../README.md)
dejima's forge (Gitea/Forgejo/...), workflow (worktree-and-PR, or something
else) and harness (Claude Code, or another agent CLI) behaviour are not
hardcoded into core. Each is a directory under `forges/`, `workflows/` or
`harnesses/`, selected by `[core] forge`, `[core] workflow` and
`[core] harness` in `~/.config/dejima/config`. Shipped today:
else), harness (Claude Code, or another agent CLI) and model backend
(Anthropic, OpenRouter, something you serve) behaviour are not hardcoded into
core. Each is a directory under `forges/`, `workflows/`, `harnesses/` or
`backends/`. Shipped today:
| Axis | Directory | Selector | Ships |
| --- | --- | --- | --- |
| forge | `forges/` | `[core] forge` | `forges/gitea`, `forges/github`, `forges/none` |
| workflow | `workflows/` | `[core] workflow` | `workflows/worktree-pr`, `workflows/branch-push`, `workflows/local-only` |
| harness | `harnesses/` | `[core] harness` | `harnesses/claude-code` |
| harness | `harnesses/` | `[core] harness` | `harnesses/claude-code`, `harnesses/cline` |
| backend | `backends/` | `[harness.<name>] backend` | `backends/anthropic`, `backends/anthropic-api-key`, `backends/openrouter`, `backends/self-hosted` |
The backend is selected per harness rather than in `[core]`, because it is the
harness that talks to a model API - see [Model backends](#model-backends).
See [Shipped providers](#shipped-providers) below for what each one is for.
@@ -44,7 +48,7 @@ elsewhere needs to change.
## The manifest file
Each provider directory has exactly one manifest file, named for its axis:
`forge.conf`, `workflow.conf`, `harness.conf`. Format is the same
`forge.conf`, `workflow.conf`, `harness.conf`, `backend.conf`. Format is the same
`key = value` shape as the main config file:
- One `key = value` pair per line; surrounding whitespace is trimmed.
@@ -134,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 like every other allowed connection. The startup self-check asserts the refusal when the opt-in is off, and skips that check when it is on, so a break in the opt-in plumbing shows up as a failed push rather than a container that will not start. 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. |
@@ -227,11 +231,11 @@ 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. |
| `proxied_api_domain` | `?hostname` | (empty) | The model API endpoint the credential proxy stands in front of. Allowlisted **only when the proxy is off**: with it on, the session reaches the API through the forwarder, so leaving the name unresolvable defeats a bypass that looks it up by DNS. Which name it is comes from here; when to allowlist it is dejima's rule. |
| `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. |
| `briefing_path` | `?homepath` | (empty) | Where this harness reads its briefing from, relative to `$HOME`, for a harness that has no session-start hook to be handed it through. Set, `entrypoint.sh` renders the briefing into that path root-owned and read-only before the session starts, and refuses to start it at all if that fails. Empty for a harness that registers a hook instead, which is how Claude Code gets it. |
| `briefing_path` | `?homepath` | (empty) | Where this harness reads its briefing from, relative to `$HOME`, for a harness that has no session-start hook to be handed it through. Set, `entrypoint.sh` renders the briefing into that path root-owned and read-only before the session starts, and refuses to start it at all if that fails. Empty for a harness that takes the briefing on the command line (`briefing_args`, which is how Claude Code gets it) or that can be handed it through a session-start hook. |
| `no_confirm_args` | `?template` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
| `settings_args` | `?template` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
| `keys` | `?list:keyspec` | (empty) | This harness's own `[harness.<name>]` config schema - same grammar as a forge's `keys`. |
@@ -243,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
@@ -262,6 +266,129 @@ resuming the session on the host is `claude --resume <session id>`, with
`{{session_id}}` filled in at composition time; and its briefing fragment is
`briefing.md`.
## Model backends
`backends/<name>/backend.conf`. Which model API a session talks to, and how the
credential proxy presents the credential to it.
**"Backend" and not "provider",** which is what the industry usually calls a
model API. This document already calls forge, workflow and harness "provider
axes", and one word for both reads as one thing. A harness keeps its own
vocabulary: `cline` writes a `providers.json` with a `lastUsedProvider` key,
and `bin/harness/cline.sh` translates.
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `upstream` | `?url` | (empty) | Where the proxy forwards to. Required unless `credential` is `none`, which runs no proxy. |
| `api_style` | `enum:anthropic\|openai` | (required) | Which dialect the endpoint speaks. A property of the API rather than of any harness: Anthropic's Messages API and an OpenAI-compatible one are different wire formats. A harness declares which it can speak in `api_styles`, and a launch pairing the two wrongly is refused rather than sending well-formed requests in a shape the endpoint cannot parse. |
| `proxied_domain` | `?hostname` | (empty) | The name the in-container TLS terminator answers for, and what `/etc/hosts` pins to loopback. Required unless `credential` is `none`. On this axis rather than the harness's: a harness reaches whichever endpoint it is configured for, so the name belongs to the endpoint. Declared on the harness, the terminator would answer for one API while the session talked to another, and the credential the proxy exists to withhold would be the one in use. |
| `credential` | `enum:static\|anthropic-oauth\|none` | (required) | Whether there is a credential, and whether it expires. `static` is read and sent; `anthropic-oauth` is refreshed against `oauth_endpoint` and the rotated token written back to the host file, which is what keeps a host-side login working rather than being signed out by a sandbox run; `none` is a model you serve, with nothing to withhold and so no proxy at all. |
| `auth_header` | `identifier` | `Authorization` | Which header carries it. |
| `auth_format` | `authformat` | `Bearer {token}` | How it is written. Must contain `{token}`, so a backend wanting a bare key or a prefix of its own needs no code; a format without it produces a proxy that starts, answers, and sends every request unauthenticated. |
| `strip_headers` | `?list:identifier` | (empty) | Headers that must not travel beside the injected one. `x-api-key` next to an `Authorization` is two answers to the same question, and which one an API prefers is not dejima's to guess. The auth header is never stripped from under itself. |
| `ensure_headers` | `?list:headerpair` | (empty) | `NAME=VALUE` that has to appear in a comma-list header, merged into whatever the client sent and left alone when already present. Anthropic's OAuth beta is the one shipped example. |
| `oauth_endpoint` | `?url` | (empty) | Required by the `anthropic-oauth` kind, meaningless to `static`. |
| `oauth_client_id` | `?identifier` | (empty) | Same. |
| `keys` | `?list:keyspec` | (empty) | This backend's own `[backend.<name>]` config schema, exactly as a forge's works. A backend with a credential is expected to declare `credential_file`. |
| `requires` | `?list:identifier` | (empty) | Which of those must be set for a launch to proceed. |
### What pairs with what
Compatibility is not a list anyone maintains: it is the backend's `api_style`
against the harness's `api_styles`, checked at launch. This table is what those
declarations currently work out to.
It answers whether dejima will pair the two, which is a question about
dialects. Whether the endpoint then serves the request is the endpoint's to
answer, and for a Claude subscription credential the answer depends on the
harness - see [A subscription credential is Claude
Code's](#a-subscription-credential-is-claude-codes-and-anthropic-enforces-that)
below.
| Backend | `claude-code` | `cline` | Credential | You configure |
| --- | --- | --- | --- | --- |
| `anthropic` | yes | yes | OAuth, refreshed and written back | nothing, once you have logged in on the host |
| `anthropic-api-key` | yes | yes | a static key, held on the host | nothing, if Claude Code signed you in; otherwise `[backend.anthropic-api-key] credential_file` |
| `openrouter` | no | yes | a static key, held on the host | `[backend.openrouter] credential_file` |
| `self-hosted` | no | yes | none, and so no proxy at all | `[backend.self-hosted] base_url` |
`claude-code` speaks Anthropic's Messages API and nothing else, so the two
OpenAI-compatible backends are refused with it - at launch, naming both sides,
rather than by sending well-formed requests in a shape the endpoint cannot
parse. `cline` has a client for each dialect, which is what makes it the
backend-agnostic one.
Adding a backend does not change this table by hand: a new directory whose
`api_style` is one a harness already declares works with that harness the day
it lands.
### A subscription credential is Claude Code's, and Anthropic enforces that
`backends/anthropic` reads the OAuth credential Claude Code writes when you
sign in with a Claude subscription. **Anthropic serves that credential to
Claude Code and refuses other clients, per model.** Observed on a Max
subscription: `claude-haiku-4-5` is served, `claude-opus-5` and
`claude-sonnet-5` are refused, while an otherwise identical request from
Claude Code succeeds.
The refusal arrives as **HTTP 429 `rate_limit_error`**, with a body whose
message is the word `Error`. It is indistinguishable from a quota problem
from the inside, which is worth knowing before you spend an afternoon on
your usage graphs: the account can be nowhere near any limit and every
request will still be refused.
So with `[core] harness = cline` and `backend = anthropic`, expect Haiku to
work and the larger models not to. For those, point cline at a backend whose
credential is yours to spend rather than a subscription's:
```
[harness.cline]
backend = anthropic-api-key # console billing, key held on the host
# or
backend = openrouter
```
`claude-code` with `backend = anthropic` is unaffected - that pairing is the
one Anthropic is serving.
**dejima does not work around this**, and that is a limit on what dejima does
rather than an opinion about the attempt.
The credential proxy substitutes the credential and changes nothing else: the
header block the API receives is the harness's own, down to not adding an
`Accept-Encoding` the client did not send. Getting past this gate would mean
sending a system prompt the harness never wrote, so that the request presents
as a different product - which is the one thing the proxy is built not to do,
and would put a second answer to "which client is this" inside the component
whose whole job is to pass the first one through unchanged.
It would also be brittle in a way that costs the operator rather than dejima:
it works until the shape it copies changes, and the failure lands on their
account.
Which models are gated is Anthropic's to change, and the table above is an
observation on one subscription rather than a documented policy - if it moves,
this section moves with it.
**Which backend a launch uses is the harness's setting**, `[harness.<name>]
backend`, overridable for one launch with `--backend`. Per harness because
that is how it is used: one harness against Anthropic and another against
something else, on the same machine.
No harness ships a default for it, for the reason `[core] harness` has none. A
harness may instead declare `assumed_backend`: what it was talking to before
the key existed, which dejima writes into the operator's config the first time
it finds the key unset, and says so. That is a migration rather than a default
- recorded once in the operator's own file, so a later change to dejima cannot
move a session whose config never changed. A harness only states it where the
value is not a guess; one whose past endpoint depended on other settings
declares nothing, and an unset backend is then a refusal.
**Nothing here is a secret.** A backend declares *where* its credential is
read from - `credential_file` is a path - never what it is. The file it names
is read host-side per request and is never mounted into the container, which
is the property the whole arrangement exists for.
## Tool
Not an axis: a launch selects one forge, one workflow and one harness, and
@@ -318,8 +445,8 @@ known until the container is actually running.
### How the briefing reaches the session
**A session-start hook is a size-limited channel, which is why it is no longer
the only route.** Claude Code persists a large `SessionStart` payload to a file
**A session-start hook is a size-limited channel, which is why it is not the
only route.** Claude Code persists a large `SessionStart` payload to a file
and injects a short preview in its place: measured in a dejima container, 9KB
arrived whole and 21KB did not. A composed briefing plus a project's own
`.dejima.md` runs well past that, so most of the briefing silently never
@@ -442,7 +569,11 @@ every launch:
| workflow | `branch-push` | worktree → push, stops at the branch |
| workflow | `local-only` | **default** - commits locally, pushes nothing |
| harness | `claude-code` | hosts every guard; **not a default** - `[core] harness` must be set |
| harness | `cline` | provider-agnostic, so it can be pointed at an open-weight model served locally; hosts no guards, and needs `[harness.cline] model` set and a CPU with AVX2 |
| harness | `cline` | backend-agnostic, so it can be pointed at an open-weight model served locally; hosts no guards, and needs `[harness.cline] model` set and a CPU with AVX2 |
| backend | `anthropic` | Anthropic on a subscription login; the OAuth token is refreshed and written back to the host |
| backend | `anthropic-api-key` | Anthropic on API billing, whether the key came from Claude Code's console login (`primaryApiKey` in `~/.claude.json`, the default) or a file you hold yourself |
| backend | `openrouter` | a hosted OpenAI-compatible API; a static key, held on the host |
| backend | `self-hosted` | a model you serve; no credential and so no proxy, and the endpoint must be host-local |
### Harness integration maturity
@@ -461,7 +592,7 @@ over, which is a kind of coverage no test suite substitutes for.
**Cline is new, and has barely been used.** Its integration was built
deliberately and is tested, but a test only covers what someone thought to
check. The first run against a live model immediately turned up a bug no test
had: Cline silently discards a provider entry with no `updatedAt` and falls back
had: Cline silently discards a `providers.json` entry with no `updatedAt` and falls back
to its own hosted endpoint, which presents as a connection failure to a host
nobody configured. The first real session turned up a second, in a component
Cline does not own at all - the conversation shipper passed its payload as a
@@ -477,6 +608,41 @@ can be pointed at any endpoint, including a model you serve yourself - and
expect to be the person who finds the next crack.
### Model backend maturity
The same caveat the harnesses and forges get, and for the same reason: four
backends in one table invites the reading that each got the same amount of
use, and they did not.
**`anthropic` is the one dejima was built around.** The credential proxy, the
in-container terminator, the refresh-and-write-back - all of it was designed
against a subscription login and generalised afterwards. It has been used for
real work continuously, which is the coverage no test substitutes for.
**`anthropic-api-key` works and has not been leaned on.** It was verified end
to end on a real machine: the model answers, billing shows as API usage, the
key stays on the host and the container holds only a placeholder. That is one
confirmed launch rather than months of use, and the difference matters for the
same reason it does above.
**`openrouter` is at the same level.** Built from the same manifest shape,
exercised against a fake upstream and through a full launch, and confirmed
against the real endpoint. What has not happened yet is a long tail of
sessions through it, so expect the rough edges that only volume finds.
**`self-hosted` runs no proxy at all**, which makes it the simplest of the
four and the one with least to go wrong in dejima: there is no credential to
withhold, so the session reaches the endpoint directly and dejima's only job
is refusing an address that is not host-local. It has been run against a local
model. What it does not get is any of the credential machinery's protection,
because there is nothing to protect.
If you are choosing with no other reason to prefer one, and you have a
subscription, `anthropic` is the path with the most miles on it. The other
three are supported in the same sense the second harness is: built
deliberately, tested, and newer than the thing they sit beside.
### Forge integration maturity
The same caveat the harnesses get, for the same reason: two forges listed in
@@ -585,7 +751,10 @@ never needs port 22 at all. It opens it through core's existing GitHub push
grant (`ALLOW_GITHUB_PUSH`, the same one `--allow-github-push` uses), which
its `hosts_allowlisted_by_core = on` is what asks for: port 22 to GitHub's
published ranges and nothing else, logged like every other allowed
connection and verified by the firewall's own startup self-check. The
connection. The startup self-check asserts that port 22 is refused when the
opt-in is off, and skips that check when it is on, so a break in the opt-in
plumbing shows up as a failed push rather than a container that will not
start. The
port restriction is an IPv4 statement; what makes port 22 shut *at all*
without this grant is that the container blocks IPv6 as well, so an
allowlist naming one address family is not the whole of it. Only when
+7 -4
View File
@@ -46,11 +46,14 @@ at all.
`test/runtime.bats` needs Docker, which a sandbox does not have. It is the only
thing that exercises the mounts, the firewall and the entrypoint end to end, so
a release is exactly when not running it stops being acceptable. Either run it
on the host, or **check that the privileged CI job ran** - it is pinned to one
self-hosted runner and queues silently when that runner is down, so a pull
request can sit with the runtime half neither green nor red.
on the host, or **read the pull request's checks**: green means every job
finished and passed, `runtime.bats` among them. Yellow or grey means the
privileged job has not run - it is pinned to one self-hosted runner and queues
silently when that runner is down.
"CI covers it" is not "it passed."
So the answer is the colour. Green is verified end to end; say so. Anything
else is a run that has not happened, and reporting it as passing is what this
paragraph exists to prevent.
## 2. Security review
+67 -17
View File
@@ -52,12 +52,11 @@ running.
## The bind address is the one thing to get right
`docker/init-firewall.sh` deliberately ACCEPTs container OUTPUT to the
container's default-route address - the Docker bridge gateway, which is your
host. That is the one host address a sandbox can reach at all. Combine it
with the fact above (a tmux client is a host shell) and the consequence is
direct: **a front door the sandbox can reach is a compromised session's own
escape hatch.**
`docker/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
the sandbox can reach is a compromised session's own escape hatch.**
So:
@@ -542,6 +541,46 @@ nvim split separator resizes the split. Neither needs any server-side
configuration - they are stock tmux and nvim behaviour; the page's only
contribution is delivering the drag.
### A scroll gesture jumps too far, and the amount is tmux's
A scroll gesture is sent as wheel notches, one per row of finger travel. What a
notch is then worth belongs to whoever receives it, and this page is not it:
when the pane is scrolling its own history, tmux is.
**Find out what yours does before changing anything:**
```sh
tmux list-keys -T copy-mode-vi | grep WheelUpPane
```
Stock tmux scrolls five lines (`send-keys -X -N 5 scroll-up`), which is a fifth
of a desktop terminal and better than half a phone one. A configuration that
binds `halfpage-up` instead - a common enough idiom that it is worth naming -
scrolls half the pane, so twenty lines in a forty-line one. That is the case
this section exists for: on a phone it throws the screen on the smallest
movement of a thumb, and placing a particular line takes several tries.
Set it to a few lines, in your own `~/.tmux.conf`:
```tmux
bind -T copy-mode-vi WheelUpPane select-pane \; send -X -N 4 scroll-up
bind -T copy-mode-vi WheelDownPane select-pane \; send -X -N 4 scroll-down
bind -T copy-mode WheelUpPane select-pane \; send -X -N 4 scroll-up
bind -T copy-mode WheelDownPane select-pane \; send -X -N 4 scroll-down
```
Both tables, because which one is active follows `mode-keys`. `scroll-up` takes
no amount of its own, so the `-N` repeat count is the amount. It applies to
every client of that tmux server, phone and desktop alike - which is the reason
it is not something dejima sets for you: `dejima-attach` joins the tmux session
you launched from, and its configuration is yours.
Nothing in dejima can make the step smaller from this side. The page can only
choose how many notches to send, and sending fewer means dragging further for
each one - which trades a jumpy scroll for a stiff one rather than fixing
either. An application reading the mouse directly, rather than through tmux,
picks its own figure and there is no binding for that at all.
### Toolbar
```
@@ -798,13 +837,20 @@ what the outer proxy sends.
## Notifications
`dejima` registers `hooks/dejima-notify.sh` as a `Stop` hook for a
remote-enabled session, into the throwaway settings override layer it already
generates - never into your real `~/.claude/settings.json`. Your own hooks
keep running alongside it, since Claude Code merges hooks across layers rather
than replacing. The layer is mounted read-only, so a session cannot
unregister its own hook. The hook writes one empty file named `done` into
this run's marker directory: no content, no stdin, no network.
**Only for a harness that declares the `notify` guard**, because a
notification needs a session-lifecycle signal to hang off and not every
harness has one. Claude Code does; Cline does not, and a remote-enabled Cline
session gets attach and the web terminal but no push. The launch says which
guards a harness is going without, so that is visible rather than a
notification that silently never arrives.
Where it is declared, `dejima` registers `hooks/dejima-notify.sh` as a `Stop`
hook for a remote-enabled session, into the throwaway settings override layer
it already generates - never into your real `~/.claude/settings.json`. Your
own hooks keep running alongside it, since Claude Code merges hooks across
layers rather than replacing. The layer is mounted read-only, so a session
cannot unregister its own hook. The hook writes one empty file named `done`
into this run's marker directory: no content, no stdin, no network.
**One push per stop, and not a second one a minute later.** The hook and the
watcher both understand a `waiting` event as well, and Claude Code's
@@ -1098,14 +1144,18 @@ key that already gets you a normal shell on the box is the right one, and it
grants nothing new here - you had the shell.
**Find the host path first.** `~/shared` is where the *container* sees it; on
the host it is `[core] share_dir`, which defaults to
`~/.local/share/dejima/shared` and is not named `shared` at all.
`dejima --print-config | grep share_dir` on the box prints the resolved path.
the host it is one project's directory under `[core] share_dir`, which defaults
to `~/.local/share/dejima/shared` and is not named `shared` at all.
`dejima --print-config | grep share_dir` on the box prints the resolved root,
and `dejima-share list` prints what is under it. Mount `<share_dir>/projects`
to get one directory per project with no loose files mixed in, or a single
project's directory - `dejima-share path <project>` - to keep the mount to the
one you actually pull from.
Anything in `[mounts] read_write` needs no lookup, because those mount at the
host path they name: `~/specs` in the container is `~/specs` on the box.
```sh
sshfs box:.local/share/dejima/shared ~/mnt/dejima-shared \
sshfs box:.local/share/dejima/shared/projects ~/mnt/dejima-shared \
-o IdentityFile=/home/<you>/.ssh/id_ed25519,IdentitiesOnly=yes \
-o PasswordAuthentication=no \
-o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3 \
+14 -10
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
@@ -57,7 +57,11 @@ doing any work.
hostile?** The field says no for untrusted code, and tiers gVisor and
per-workload VM kernels above it. dejima's answer is that escape moves a
layer - true where the layer exists. See
[Which deployment this describes](threat-model.md#which-deployment-this-describes).
[Which deployment this describes](threat-model.md#which-deployment-this-describes)
and [Why a container, and not a microVM](threat-model.md#why-a-container-and-not-a-microvm),
which is where the reasoning is written down so a sweep can contradict it.
The live half of this question is question 5: whether a current escape
clears this configuration's floor.
2. **Does a name-based egress allowlist hold?** It holds against an agent that
must resolve a name. It does not hold against exfiltration through an
allowed domain or against DNS query-name channels, both of which are listed
@@ -152,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.
@@ -166,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.
+488 -105
View File
@@ -271,12 +271,15 @@ 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
layout it is unpacked with, all consumed by the image build - so a session
that edits one chooses what the next launch downloads and installs as root.
`backends/*/backend.conf` names the upstream every request is forwarded to and
the host path the credential is read from, so an edit there redirects where a
real credential is sent.
An ordinary edit is a diff you are about to read and is not reported; one git
cannot see - hidden by `.gitignore`, `.git/info/exclude`, `assume-unchanged`
@@ -288,29 +291,31 @@ can make; it is one the audit reports. See
- Running the harness with its permission prompts off removes your chance to
review tool calls before they run. Only point this at trusted repositories.
- The mounted project itself has **no write protection**. `deny-dangerous-
bash.sh`, `git-push-guard.sh` and `merge-guard.sh` are regex
pattern-matching on the literal Bash command string - a fast-fail guard
against an overeager agent doing something obviously destructive, not a
boundary against one that's actively trying to get around them (a
different scripting language, an encoded command, editing `.git/config`
directly all sail right through).
Nothing here stops a compromised or badly-instructed session from
modifying or deleting the real working copy it's mounted against.
- The mounted project itself has **no write protection**. The guards are
fast-fail checks against an overeager agent doing something obviously
destructive, not a boundary against one actively trying to get around them.
`deny-dangerous-bash.sh` is regex pattern-matching on the literal Bash
command string, so a different scripting language, an encoded command or
editing `.git/config` directly all sail right through. The push and merge
guards shadow `git` and the forge CLI on `PATH` and read argv, so quoting
and pipelines stop mattering - but a session that means to get round one can
call `/usr/bin/git` directly. Nothing here stops a compromised or
badly-instructed session from modifying or deleting the real working copy
it's mounted against.
- That same read-write access to the mounted project is also a
host-code-execution vector, not just a data-destruction one: a
compromised session could plant `.git/hooks/post-commit` (or
`pre-push`), set `core.hooksPath`, or write a
`.claude/settings.local.json` with a malicious hook command, into the
project. Any of those run unsandboxed on the host the next time you run
`git` or open Claude Code in that repo - no network egress required, so
`git` or open your harness in that repo - no network egress required, so
the firewall doesn't see it either. Same underlying persistence pattern
as the `~/.claude/hooks` and `~/.claude.json` risks above, just through
as the harness config risks above, just through
the project mount instead of the config mount. "Only point this at
trusted repositories" (above) covers input trust; this is about the
sandbox writing a persistence payload into an otherwise-trusted repo.
The workspace's own `.git/hooks` directory is mounted read-only
(same pattern as the `~/.claude` code directories below), so planting a hook
(same pattern as the harness's own code directories below), so planting a hook
file directly is closed - accepted breakage: installing hooks
*in-session* (`husky`, `pre-commit install`) fails, which is the
point. `core.hooksPath` and `.claude/settings.local.json` aren't
@@ -495,6 +500,16 @@ approximation of its name.
## One project's session cannot reach another's
The property is the same for every harness; the mechanism is not, because it
depends on how the harness keys its own state. Claude Code stores a directory
per project and dejima mounts a slice of it. Cline stores one flat database
indexing every session it has ever run, with the project as a field inside the
record, so there is no slice to take - dejima gives each workspace a data
directory of its own instead. Both reach the same place: a session sees its
own project's history and no other's.
### Claude Code: a slice of your own tree
- `~/.claude/projects` (session transcripts and per-project memory) is
**not** mounted as a tree. A session gets the workspace's own slug
directory plus one for each live git worktree nested under it,
@@ -548,6 +563,31 @@ approximation of its name.
That costs undo history for that session if it is later resumed on the
host, and it is the reason this cannot be scoped as completely as
`projects/`, where the workspace's own slug is known at launch.
### Cline: a directory dejima owns, one per workspace
- `~/.cline/data` is **not** mounted. Cline indexes every session it has ever
run in one `sessions.db`, with the project a session belonged to as a field
inside the record rather than a directory above it, so there is no
per-project subtree to slice: mounting your data directory would put every
project's transcripts in every session, which is this section's whole
subject.
A launch instead creates `<[harness.cline] state_dir>/<workspace slug>` on
the host and mounts *that* at `~/.cline/data`. Every session under it belongs
to this project by construction rather than by a filter that could be wrong,
which is why Cline clears this requirement more cleanly than Claude Code
rather than less.
The cost is that the directory is dejima's rather than yours: a session
started inside the sandbox is not offered back to a host-side Cline, and one
you started on the host is not visible inside. There is no import or export
([#301](https://gitbox.branderson.io/dejima/dejima/issues/301)), and the
briefing tells the session so, so it will not offer you a resume command that
cannot work.
### Whichever harness is selected
- Epitaphs - the summary a session leaves when its context is compacted away -
are kept host-side in `[epitaph] dir`, and that directory is never mounted. A session can neither read what earlier
sessions concluded nor edit what this one is about to record, which is the
@@ -555,8 +595,185 @@ approximation of its name.
they are extracted from *is* session-writable, so a summary records what was
there rather than vouching for it - and nothing loads one back into a later
session, deliberately: that would be the channel #104 closed, rebuilt.
- `[core] share_dir` is **not** mounted whole. A launch mounts
`<share_dir>/projects/<slug>` at `~/shared`, so a session sees its own
project's handoff files and no other project's - the same slicing as
`projects/` above, applied to the directory that was the remaining way
around it. A handoff file is session-authored by design, and one project's
session writing a plausible-looking note that a session in another project
then picks up is #104's channel by a different route.
The per-project split bounds *which* sessions, not *how long*: a project's
share persists, so everything in it is readable by every later session
against that project. Nothing prunes it, which is why
[docs/configuration.md](configuration.md#shared-scratch-space) says to clear
it out and `dejima-share list` prints what each project is holding.
`<share_dir>/projects` is in the guarded list, so an operator cannot hand
the whole of it back through `[mounts] readonly` or `[mounts] read_write`.
The session's own share arrives at `~/shared` without going through either.
## The model API credential never enters the container
### Four arrangements, and what a session can take from each
Which one a launch is in comes from the selected backend's `credential` kind
and from whether the proxy is on. The differences are worth stating together,
because only the last one puts a durable credential where a session can read
it.
| Arrangement | In the container | What a compromised session can take | What it can still do |
| --- | --- | --- | --- |
| **Proxied, refreshing** (`credential = anthropic-oauth`) | A fake credential file whose secrets are placeholders. The real one is read host-side per request. | The placeholder. | Use the API as you, through the proxy, while the session runs. |
| **Proxied, static key** (`credential = static`) | A placeholder key in the harness's own model configuration, and the endpoint's real hostname. | The placeholder. | The same. |
| **A backend with no credential** (`credential = none`, a model you serve) | Nothing but the endpoint's address. It needs no credential, so there is none to withhold. | Nothing. | Reach that endpoint, which is why it has to be one you allowlisted. |
| **No proxy** (`[credentials] proxy = off`, or `[backend.<name>] proxy = off`) | The real credential file, mounted. | **The credential itself**, durable and usable offline. | Everything the credential allows, for as long as it is valid. |
**The first three keep the credential out; the fourth does not.** That is the
whole of the difference, and it is why `proxy = off` is described here as a
kill switch rather than as an option.
**Both Anthropic billing models are in the first three.** A subscription login
is `backends/anthropic`, whose OAuth token the proxy refreshes and writes back;
a console key is `backends/anthropic-api-key`, a static credential against the
same endpoint presented as `x-api-key` - one backend whether the key came from
Claude Code's own login or a file you hold. Either way the credential is held on
the host and injected per request. On the API-key path the container gets
`ANTHROPIC_API_KEY=sandbox-credential-proxy-placeholder` and no credential file
at all.
**Only `proxy = off` lands in the fourth row, and only where you wrote it.**
A credential the selected backend cannot serve - a Claude Code signed in with
a console API token, whose file holds no `claudeAiOauth` block, against
`backends/anthropic` - is a refusal, naming the backend, the file, the shape
it expected and the two ways forward. A kill switch that can engage itself is
not one, which is why this row is a kill switch rather than an option.
The refusal is scoped to a credential that *exists* and cannot be served. An
absent one mounts nothing and downgrades nothing - that is a host where the
harness has not been signed in yet, and it still launches.
**The proxy is switched per backend, not globally.** `[backend.<name>] proxy`
falls back to `[credentials] proxy` when unset. One backend whose credential
the proxy cannot serve is then an opt-out for that backend, rather than a
reason to stop withholding credentials from the ones it serves. Turning it off
for a backend prints what the session is being given, because it is a
downgrade the operator chose rather than a setting that happens to be off.
**Everything dejima copies in is scanned for credential-shaped values it does
not recognise, and an unknown one is a question rather than a surprise.** The
scanner reports a redacted path, a shape name, the harness and the backend -
never the value, and never a real path, since a gitconfig can carry userinfo
in a section header. **Finding it is best effort**: this recognises common
credential shapes and can miss an unusual one. Removing one it has found is
not - a value it names is removed whole, including one split across
continuation lines.
The choice is yours: keep it out for this launch, let it in for this launch,
let it in every launch, or stop. Only the third persists, in
`credential-scan.decisions` beside your config, keyed by field rather than by
value so rotating a credential does not re-ask a question you already
answered. A standing *scrub* is available there too but is not offered at the
prompt: removing something the harness turns out to need would break every
launch after, with the cause several layers from the symptom. With nothing
attached to answer, the launch stops rather than carrying it in.
This is the general form of the two fixes below, which are denylists and so
only know what was already found. See
[#291](https://gitbox.branderson.io/dejima/dejima/issues/291).
#### What the scan does not catch
It recognises the *shape* of a credential, not its meaning, so it is a
backstop against the class of leak that has actually happened here twice -
not a guarantee that nothing crosses. Specifically:
- **Values containing whitespace.** Both heuristic rules require one token
from a restricted alphabet, because the alternative flags every sentence in
a config file: English prose scores 4.0-4.5 bits per character by the same
entropy measure a random key does. People do put spaces in passphrases they
chose themselves, and one of those would go straight through. It is a
deliberate trade and it fits these files, which hold credentials issued to a
machine rather than typed by a person.
- **Short or low-entropy secrets in fields not named like one.** A token that
is eight lowercase letters in a field called `nickname` is indistinguishable
from a nickname.
- **Anything in a format it cannot parse.** JSON and INI only. An unparseable
file is copied unchecked, and says so at launch rather than being silently
trusted or silently blocked.
- **Anything dejima does not copy.** This covers the files dejima *stages* -
`~/.claude.json` and `~/.gitconfig`. It does not read your read-write
mounts, the workspace, or anything else you have pointed a session at, and a
credential you leave in one of those is not something dejima is looking for.
The value of it is not that it is exhaustive. It is that a credential shape
dejima has never seen becomes a question at launch instead of a copy nobody
noticed for a year.
**Two host files are copied into a container, and both are filtered.**
`~/.gitconfig` can hold a `credential.helper` that prints a token, an
`http.<url>.extraHeader` carrying an Authorization line, or userinfo inside a
`url.<...>.insteadOf` key; none of that is part of what the sandbox is
granted, so the copy drops it and says so. The general problem - that these
filters are denylists, and a harness's authentication is not a stable
interface - is [#291](https://gitbox.branderson.io/dejima/dejima/issues/291).
**`~/.claude.json` is staged, not mounted, and the staging strips a
credential.** That file mixes app state with configuration the copy exists to
carry, and a Claude Code signed in with a console API token keeps its key
there as a top-level `primaryApiKey` - not in `.credentials.json`, which stays
`{}` for that login. The copy drops that key, and `backends/anthropic-api-key`
reads it host-side per request instead. Without it the proxy would
withhold one file while dejima copied another, on every launch and whatever
backend was selected.
**The residual the first two share is use-while-running.** A session that has
been turned can spend your quota, ask the model anything, and read the answers
- through the proxy, without ever holding the credential. That is inherent to
the sandbox working at all: the session has to be able to reach the model, and
anything it can reach it can misuse. What the proxy buys is that the misuse
stops when the session does, and nothing durable leaves.
**Refreshing and static differ in what a leak elsewhere costs, not in what the
container sees.** Neither enters it. But an OAuth access token is short-lived
and the proxy rotates it, while an API key is typically long-lived and
account-wide - so the host-side file is worth more in the static case, and a
compromise of the *host* is correspondingly worse. Scope the key at the
backend where you can, the same argument
[docs/providers.md](providers.md) makes for a repository-scoped forge token.
**The proxied domain is not in the egress allowlist while the proxy is on.**
`docker/fixed-domains.sh` adds it only when there is no proxy, so a session
that tries to reach the API by name finds it unresolvable and goes through the
terminator instead. Worth being precise about the limit: this defeats a lookup,
not a route. The API's address stays reachable as an IP literal for as long as
any other allowlisted name resolves to it.
**A hosted OpenAI-compatible backend is the second arrangement, and it is
what `backends/openrouter` is.** The key sits in a file on the host, named by
`[backend.openrouter] credential_file` and read per request; the container
gets `sandbox-credential-proxy-placeholder` and the endpoint's real hostname,
which the in-container terminator answers for. Nothing about the arrangement
is visible to the session, which is the same property the Anthropic path has
and the reason both are worth having rather than one.
Worth naming what a *hosted* backend adds over Anthropic's OAuth, since it is
not the container that changes: an API key is typically long-lived and
account-wide where an access token is short-lived and rotated, so the host-side
file is worth more. Scope the key at the backend where it offers that, the
same argument this repository makes for a repository-scoped forge token.
**A backend you serve yourself is the strongest posture available**, and it is
worth saying so plainly rather than treating it as the degraded one. A session
pointed at a model on your own network has no credential anywhere in the
picture, so there is nothing to place, nothing to withhold and nothing to
steal. What it costs is that the model is yours to run.
It is a backend like any other - `backends/self-hosted`, with
`credential = none` and its address in `[backend.self-hosted] base_url`. That
placement is deliberate: an operator who serves a model locally and also uses a
hosted API keeps both configured and switches by changing one word, rather than
having to unset one setting to stop it overriding another.
**This assumes the host has one.** dejima authenticates nothing: the proxy
holds a credential the harness wrote on the host, so a machine where the
harness has never signed in has nothing for it to hold. The proxy then does
@@ -567,11 +784,19 @@ prerequisite. Sign in on the host first; see the README's
Refreshing an expired credential is the same: on the host, not from inside a
session, where the credential deliberately never goes.
### The refreshing OAuth path, in detail
The `backends/anthropic` arrangement, at the level of what the proxy actually
does. The machinery is not Anthropic's - the credential's kind, which header
carries it, what must not travel beside it are all arguments a backend
supplies - but it is worth reading through one backend rather than in the
abstract, and this is the one with the most miles on it.
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
enters the container. It isn't mounted: `~/.claude` is not shared as a
whole (see the mount inventory below), so there is no path from
inside the container to that file. `dejima` starts a host-side proxy
(`bin/dejima-anthropic-proxy`) on a per-session unix socket and mounts a
(`bin/dejima-credential-proxy`) on a per-session unix socket and mounts a
fake credential in place of the real one - the secrets are placeholders,
while `scopes`/`subscriptionType`/`rateLimitTier`/`clientId` are copied so
the session's UI stays accurate. Its expiry is a year out rather than
@@ -579,7 +804,7 @@ session, where the credential deliberately never goes.
refresh token that isn't real, logging the session out. Inside the
container,
`entrypoint.sh` points `api.anthropic.com` at a loopback TLS terminator
(`anthropic-proxy-forwarder.js`) whose certificate is generated at startup
(`credential-proxy-forwarder.js`) whose certificate is generated at startup
and trusted only there, and which pipes raw bytes into the proxy's socket.
The proxy injects the real access token per request and owns OAuth refresh
- rotated tokens are written back to the real host file atomically, so
@@ -588,7 +813,13 @@ session, where the credential deliberately never goes.
`ANTHROPIC_AUTH_TOKEN`, nothing marking the session as unusual. The proxy
forwards the client's header block verbatim - same names, casing, order and
repeats, and no `Accept-Encoding` it didn't send - changing only
`Authorization`, and `anthropic-beta` if the OAuth value isn't there. Left
`Authorization`, and `anthropic-beta` if the OAuth value isn't there.
Neither of those is a name the proxy holds: which header carries the
credential, what shape it takes, what must not travel beside it and which
comma-list value has to be present are all arguments, and `bin/dejima`
supplies the Anthropic ones (#171). The credential kind is an argument too -
a refreshing OAuth file, or a static key that is read and sent - so a
backend that cannot be refreshed is not a branch here. Left
alone, Python's `http.client` would rewrite all of that into its own style
and move the fingerprint from the client to the wire. It cannot disguise
that the connection is CPython's `ssl` rather than Node: the TLS handshake
@@ -617,7 +848,7 @@ session, where the credential deliberately never goes.
after an upgrade, as `UNABLE_TO_VERIFY_LEAF_SIGNATURE`,
`ERR_TLS_CERT_ALTNAME_INVALID`, or a pin-specific error. What
distinguishes pinning from a merely broken certificate is that
`curl --cacert /etc/dejima-anthropic-tls/cert.pem https://api.anthropic.com/v1/messages`
`curl --cacert /etc/dejima-credential-tls/cert.pem https://api.anthropic.com/v1/messages`
still succeeds from inside the container while Claude Code does not: the
certificate is fine, the client is refusing it. **This is not a reason to
set `[credentials] proxy = off`.** That kill switch mounts the real,
@@ -646,7 +877,13 @@ session, where the credential deliberately never goes.
real credential into the container. Rung one is a supported, tested mode,
not an improvised workaround; if you reach the third, treat it as a
temporary state rather than a new baseline.
## What the container sees of `~/.claude`
## What the container sees of Claude Code's `~/.claude`
One harness's inventory, because there is no shared one to give: what a
harness keeps where, and which of it is safe to mount, is the harness's own
answer and each plugin gives it. Cline's is the table in
[configuration.md](configuration.md#what-comes-in-from-your-host); the
reasoning below is what both are built on.
- `~/.claude` is **not** shared with the container as a whole. Its contents
are enumerated in `docker-compose.yml`, in three classes, and anything not
@@ -669,10 +906,10 @@ session, where the credential deliberately never goes.
approval step, skills can ship executable scripts, and
commands/agents/output-styles are prompt text loaded into a later host
session, so without this a compromised session could plant a backdoored
hook - including by unlinking one of `install.sh`'s hook symlinks and
dropping a plain file in its place, since the dynamic per-target mounts
only protect symlink targets that resolve *outside* `~/.claude`, never
this directory itself - tamper with plugin code, or drop a malicious
hook - including by unlinking one of your own hook symlinks and dropping a
plain file in its place, since the dynamic per-target mounts only protect
symlink targets that resolve *outside* `~/.claude`, never this directory
itself - tamper with plugin code, or drop a malicious
skill/command/agent/output-style file. Any of those would then run or
load **unsandboxed on the host** the next time Claude Code starts
there, turning a single compromised sandbox session into persistent
@@ -780,15 +1017,54 @@ session, where the credential deliberately never goes.
SSH-agent/token story documented above. Keep credentials out of your
global gitconfig, or expect them to be reachable from inside the
sandbox.
- **The egress filter is not in the container the session runs in.** A launch
starts two: a filter container that owns the network namespace and installs
the packet filter, and the sandbox, which joins that namespace with
`network_mode: container:` rather than getting one of its own. Capabilities
are per process rather than per namespace, so the sandbox cannot alter the
filter bounding it even at uid 0 - measured, not reasoned: `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
filter; `bin/dejima` waits for the filter container to say it is up and
refuses the launch otherwise. And killing the filter container leaves the
sandbox holding loopback alone - no network at all, rather than an unfiltered
one.
Nothing a session runs ever executes in the filter container. It holds the
privilege; the one beside it holds the session. Each launch gets a network of
its own, so concurrent sessions are separated at the link layer as well.
A launch removes both on the way out, and reconciles at the start against
whatever a `SIGKILL` or a restarted host left behind - a filter container
with no session in its namespace, older than the window in which a pair is
still being built. What that reclaims is address space rather than safety: a
leaked filter container filters a namespace nothing is in, and Docker's
address pool is finite enough that exhausting it fails a later launch with an
error naming neither cause nor remedy.
- The sandbox's own user (matching your host user; see "Config parity" in
the README) has no `sudo`/setuid path back to root once the container is up:
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
that user via `gosu` before execing the configured command (`[core] command`,
or the selected harness's own if that is unset). A compromised session can't
re-run `init-firewall.sh` with a wider allowlist to open its own
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
cleared from the effective/permitted sets on the `gosu` drop, and that
user gets a permission error running `iptables` directly.
`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 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
reached uid 0 in that tree would recover its capabilities from. So the two
claims above - no path back to root, and no capability if there were one -
hold separately rather than one resting on the other. The drop and the uid
change happen in one `capsh` process because they have to: the bounding set
can only be reduced while `CAP_SETPCAP` is still held, and changing uid first
gives it away. The same applies to `log-shipper.sh`, the one root process
that outlives the entrypoint.
- A forge credential reaches the container as `-e NAME`, never
`-e NAME=value`. The value comes from the launcher's own environment, so it
sits in `/proc/<pid>/environ` (mode 0400, owner only) rather than in
@@ -796,17 +1072,45 @@ session, where the credential deliberately never goes.
anyone. `docker inspect` still shows the resolved value to whoever can
reach the daemon - that is daemon access rather than any process at all,
and it is not something the launcher can change.
- The container runs with `no-new-privileges` and an explicit capability
floor - `cap_drop: ALL` plus the ones it actually uses: what the
entrypoint's root phase needs, and `KILL`. `KILL` is there for
`dejima-allow --container`, not for the entrypoint - adding a domain to a
running session signals dnsmasq, and dnsmasq runs as its own user. The list
itself is in `docker/docker-compose.yml`, which is where it stays correct. The first means a setuid binary or a file capability cannot raise
- **Each container runs with `no-new-privileges` and a capability floor of its
own**, `cap_drop: ALL` plus what that container actually uses. The two lists
are the arrangement stated as configuration:
The session container declares **no network capability at all** - not
`NET_ADMIN`, not `NET_RAW`. It is inside a namespace the filter container
owns and has already installed the rules in, and capabilities are per
process, so uid 0 in there is refused by the kernel rather than by policy.
What it keeps is what its root phase does before the session starts: write
and chown files, and drop privilege once. `SETPCAP` is there to spend rather
than to use - it is what lets the entrypoint take the rest of the list away
again before execing the session, and since Linux 4.3 it grants nothing a
process does not already hold.
The filter container holds the network capabilities. The session's list is in
`docker/docker-compose.yml` and the filter's is in `bin/dejima`, each beside
the container it describes.
`KILL` is on both lists, for unrelated reasons, and it is the one worth
reading twice. The filter container needs it because adding a domain to a
running session signals dnsmasq, which runs as its own user. The session
container needs it because `init: true` makes PID 1 an init that forwards
signals to the session - and the entrypoint changes uid in place before
exec'ing it, so that is uid 0 signalling a process whose real and saved uids
are the sandbox user's. Without the capability every forwarded signal is
`EPERM`, tini treats a failed forward as fatal, and an ordinary terminal
resize ends the session.
`no-new-privileges` means a setuid binary or a file capability cannot raise
privileges on exec, which is the shape any escalation from the session user
would have to take; the second means the capabilities nothing here uses were
never in the bounding set to be regained. Both are asserted against a running
container rather than against the compose file, and the capability list the
test uses is read out of that file so the two cannot drift.
would have to take. The floor means the capabilities nothing uses were never
in the bounding set to be regained. Both are asserted against running
containers rather than against the files, and both lists the tests use are
read out of those files so neither can drift.
What a floor describes is a container's **root phase**. A launch runs with
`init: true`, so PID 1 is the init that reaps for the session and it holds
that container's floor for as long as the container lives; the session is its
child and holds nothing.
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
see
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
@@ -910,18 +1214,20 @@ 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)
- GitHub's published IP ranges, port 443 only (see "GitHub push" below)
- `registry.npmjs.org` and `platform.claude.com` (the harness and its
updates), `release-assets.githubusercontent.com` (GitHub release assets, a
separate CDN from github.com's own IPs, and where a good deal of tooling
fetches its binaries); `api.anthropic.com` when the credential proxy is off
(kill switch or API-key auth)
- `registry.npmjs.org`, plus whatever the selected harness declares as its own
(`platform.claude.com` for Claude Code; Cline declares none, so a session
falling back to Cline's hosted endpoints fails to connect, which is the
right outcome); `release-assets.githubusercontent.com` (GitHub release
assets, a separate CDN from github.com's own IPs, and where a good deal of
tooling fetches its binaries); and the selected backend's API endpoint when
the credential proxy is off for it
That is the whole built-in list, and it is meant to stay that way: it covers
what the sandbox itself cannot run without, not what a project might want.
@@ -947,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.
@@ -966,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
@@ -986,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
@@ -1017,27 +1377,37 @@ 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.
The sandbox-context.sh `SessionStart` hook 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 over it.
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
over it.
### The firewall bounds the container, not the harness
@@ -1128,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
@@ -1199,7 +1575,7 @@ Useful filters on top of those:
```logql
# DNS lookups that got refused (not on the allowlist)
{job="dejima-dns"} |= "REFUSED"
{job="dejima-dns"} | json | outcome="refused"
# Rejected connections only (parses ulogd2's JSON, filters on its action field)
{job="dejima-net"} | json | action="blocked"
@@ -1319,8 +1695,15 @@ config-file path that opens the same port, but not
silently: `hosts_allowlisted_by_core` is what asks for it, explicitly, in a
manifest, and only when the selected workflow actually pushes. What it opens
is the same GitHub ranges the read-only path already allows on port 443, now
also on port 22, logged like every other allowed connection and verified by
the firewall's own startup self-check.
also on port 22, logged like every other allowed connection.
The startup self-check covers the default. With the opt-in off it asserts port
22 is refused and stops the container if it is not; with the opt-in on it skips
that one check, and says so in the log. Everything else it asserts still
runs. So a break in the opt-in plumbing
surfaces as a failed `git push` rather than as a container that refuses to
start. `test/runtime.bats` asserts the open direction against a real container
pair, which is where that property is covered.
`[github] token_file` must point at a **read-only** fine-grained PAT
regardless of this setting. `gh auth setup-git` (run at container start if
@@ -1353,9 +1736,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.
+82
View File
@@ -263,6 +263,80 @@ repository's behalf.
**What this repository can do about that is say so**, which is the whole of
this section. Nothing in dejima can bound its own host.
## Why a container, and not a microVM
The most frequent question about this project.
The short answer is that a microVM is the stronger boundary and nothing here
disputes it. Container escape is the least likely of the six threats above and
the only one that needs an exploit at all, and the layer it lands on is already
bounded by a control enforced from outside the guest. The effort went there
rather than into a harder container.
**There is already a VM here**, which is what the usual framing misses. It is
put as container versus microVM, as though the alternative were a container
with nothing underneath. On the deployment this document describes, dejima runs
inside a devbox VM whose rule set is enforced from the hypervisor. So the choice
is not one boundary against two. It is an outer VM enforced from outside the
guest, against that plus an inner one managed by software running on the box it
is protecting - inside the blast radius of the row it is meant to bound. A
boundary enforced from outside is worth more than one enforced by the thing
inside it, and that is most of the answer.
**The field's ordering is not in dispute.** gVisor's syscall interposition and
a per-workload VM kernel are both stronger than a namespaced container.
Firecracker is what AWS runs Lambda on, for the reason that applies here too:
untrusted code, multi-tenant, on shared hardware. The disagreement is about
where marginal effort goes, not about which primitive is stronger.
### What a stronger runtime would and would not change
It would not touch the five threats ranked above escape. Two of them would get
*harder*, because both are enforced by how the container is configured rather
than by what isolates it:
- **One project's session cannot reach another's**, because each path crossing
the boundary is named individually rather than mounting a tree
([#104](https://gitbox.branderson.io/dejima/dejima/issues/104)). Under a VM
each becomes a filesystem daemon.
- **The container sees an agent socket, never your key.** That is a live host
socket, and a VM has no equivalent to hand it one.
One would get easier: egress rules on a tap device sit where the guest holds no
capability to touch them. That is available without changing the runtime, which
is what [#305](https://gitbox.branderson.io/dejima/dejima/issues/305) does.
**And "swap the runtime" understates the work**, because several controls are
implemented *in* Docker rather than merely hosted by it. The dependency
approval is a derived image build; the tooling reaches live sessions through
it; the capability floor is an argument about Docker's model. Porting them is a
rewrite of the parts that *are* the security argument, in a project that says
at the top that none of it has been reviewed by anyone else.
### What would change the answer
The decision is a judgement about effort, not a door held shut, and these are
the things that would reopen it:
- **Kata Containers is the cheap path if this reasoning is wrong.** It is an
OCI runtime, so the compose file, the mounts and the tooling survive, and it
can use Firecracker underneath. A per-launch switch rather than a fork.
- **Pointing dejima at projects chosen by someone else.** That contradicts an
assumption stated at the top of this document, and reorders the ranked list.
- **A container escape in the wild that clears this configuration's floor**:
non-root session user, `no-new-privileges`, `cap_drop: ALL` and a short list
added back. The class recurs, and whether a live one clears that floor is
asked on a schedule in
[research-review.md](research-review.md#the-standing-questions) rather than
assumed either way.
What is done instead is depth below the container, which is
[the stack](#the-stack-and-what-each-piece-buys): two of the three layers under
dejima are enforced where the guest cannot reach them, and the third bounds a
compromise in time. A sandbox that reaches for a microVM and stops there has
nothing underneath it, so an escape from the VM ends its argument. Here it is a
row with controls of its own.
## What this repository does not claim
- **That the egress allowlist bounds what reaches the model.** It bounds what
@@ -276,6 +350,14 @@ this section. Nothing in dejima can bound its own host.
belongs in that harness's assessment.
[security.md](security.md#the-firewall-bounds-the-container-not-the-harness)
has the evidence.
- **That a session cannot use the model API as you while it runs.** It can.
The credential proxy keeps the credential on the host, so nothing durable
leaves and the misuse stops when the session does - but a session that has
been turned can spend your quota and read whatever it asks for, through the
proxy, without ever holding the credential. That is inherent to the sandbox
working at all. [security.md](security.md#four-arrangements-and-what-a-session-can-take-from-each)
has the four arrangements and what each one puts in the container, including
the one that puts the credential itself there.
- **That escape is impossible.** It is a row in the table with controls of its
own, not an outcome that has been designed away.
- **That the hooks stop a hostile agent.** They stop an unlucky one.
+23 -6
View File
@@ -44,8 +44,9 @@ function usage() {
echo "Timestamps are stripped unless --timestamps is given."
echo ""
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
"set for this invocation only)."
"your shell profile), and a token: GITEA_TOKEN if it is set, otherwise" \
"the contents of $GITEA_TOKEN_FILE. Inside a dejima sandbox the" \
"variable is the arrangement - the file is deliberately not there."
}
function require_config() {
@@ -66,10 +67,26 @@ function require_token() {
GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE")
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "No Gitea token found. Generate one in the Gitea web UI" \
"(Settings > Applications, scopes write:repository + write:issue)" \
"and save it to $GITEA_TOKEN_FILE (chmod 600), or set GITEA_TOKEN" \
"in the environment for this invocation only." >&2
if [ -n "${DEJIMA:-}" ]; then
# In a sandbox the token arrives as GITEA_TOKEN, read host-side from
# whatever the forge's token_file names. Writing the file suggested
# below would satisfy this check and nothing else: the container is
# disposable, and the operator's real credential is not in it. Say
# what has to change instead, which is outside.
echo "No Gitea token in this sandbox. It arrives as GITEA_TOKEN," \
"read on the host from the token_file the configured forge" \
"names - so there is nothing to create in here, and writing" \
"$GITEA_TOKEN_FILE would not help." >&2
echo "" >&2
echo "Most likely this session has no forge configured, or the" \
"configured one is not gitea. Ask the operator to check" \
"[core] forge and [forge.<name>] token_file." >&2
else
echo "No Gitea token found. Generate one in the Gitea web UI" \
"(Settings > Applications, scopes write:repository +" \
"write:issue) and save it to $GITEA_TOKEN_FILE (chmod 600)," \
"or set GITEA_TOKEN in the environment." >&2
fi
exit 1
fi
}
+23 -6
View File
@@ -28,8 +28,9 @@ function usage() {
echo "Prefer it over --body for anything with quotes, backticks or newlines."
echo ""
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
"set for this invocation only)."
"your shell profile), and a token: GITEA_TOKEN if it is set, otherwise" \
"the contents of $GITEA_TOKEN_FILE. Inside a dejima sandbox the" \
"variable is the arrangement - the file is deliberately not there."
}
function require_config() {
@@ -50,10 +51,26 @@ function require_token() {
GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE")
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "No Gitea token found. Generate one in the Gitea web UI" \
"(Settings > Applications, scopes write:repository + write:issue)" \
"and save it to $GITEA_TOKEN_FILE (chmod 600), or set GITEA_TOKEN" \
"in the environment for this invocation only." >&2
if [ -n "${DEJIMA:-}" ]; then
# In a sandbox the token arrives as GITEA_TOKEN, read host-side from
# whatever the forge's token_file names. Writing the file suggested
# below would satisfy this check and nothing else: the container is
# disposable, and the operator's real credential is not in it. Say
# what has to change instead, which is outside.
echo "No Gitea token in this sandbox. It arrives as GITEA_TOKEN," \
"read on the host from the token_file the configured forge" \
"names - so there is nothing to create in here, and writing" \
"$GITEA_TOKEN_FILE would not help." >&2
echo "" >&2
echo "Most likely this session has no forge configured, or the" \
"configured one is not gitea. Ask the operator to check" \
"[core] forge and [forge.<name>] token_file." >&2
else
echo "No Gitea token found. Generate one in the Gitea web UI" \
"(Settings > Applications, scopes write:repository +" \
"write:issue) and save it to $GITEA_TOKEN_FILE (chmod 600)," \
"or set GITEA_TOKEN in the environment." >&2
fi
exit 1
fi
}
+23 -6
View File
@@ -25,8 +25,9 @@ function usage() {
echo "the two is not given is left as it is."
echo ""
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
"set for this invocation only)."
"your shell profile), and a token: GITEA_TOKEN if it is set, otherwise" \
"the contents of $GITEA_TOKEN_FILE. Inside a dejima sandbox the" \
"variable is the arrangement - the file is deliberately not there."
}
function require_config() {
@@ -47,10 +48,26 @@ function require_token() {
GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE")
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "No Gitea token found. Generate one in the Gitea web UI" \
"(Settings > Applications, scopes write:repository + write:issue)" \
"and save it to $GITEA_TOKEN_FILE (chmod 600), or set GITEA_TOKEN" \
"in the environment for this invocation only." >&2
if [ -n "${DEJIMA:-}" ]; then
# In a sandbox the token arrives as GITEA_TOKEN, read host-side from
# whatever the forge's token_file names. Writing the file suggested
# below would satisfy this check and nothing else: the container is
# disposable, and the operator's real credential is not in it. Say
# what has to change instead, which is outside.
echo "No Gitea token in this sandbox. It arrives as GITEA_TOKEN," \
"read on the host from the token_file the configured forge" \
"names - so there is nothing to create in here, and writing" \
"$GITEA_TOKEN_FILE would not help." >&2
echo "" >&2
echo "Most likely this session has no forge configured, or the" \
"configured one is not gitea. Ask the operator to check" \
"[core] forge and [forge.<name>] token_file." >&2
else
echo "No Gitea token found. Generate one in the Gitea web UI" \
"(Settings > Applications, scopes write:repository +" \
"write:issue) and save it to $GITEA_TOKEN_FILE (chmod 600)," \
"or set GITEA_TOKEN in the environment." >&2
fi
exit 1
fi
}
+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
+3
View File
@@ -1,3 +1,6 @@
They can continue this exact session outside the container by running on
the host:
{{resume_command}}
(session history is shared via the bind-mounted ~/.claude, so it will pick
+30 -3
View File
@@ -3,13 +3,40 @@ 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
proxied_api_domain = api.anthropic.com
guards = bash-deny push-guard merge-guard notify
# Anthropic's Messages API and nothing else. Claude Code speaks one dialect, so
# pairing it with an OpenAI-compatible backend is refused at launch rather
# than producing well-formed requests in the wrong shape.
api_styles = anthropic
no_confirm_args = --dangerously-skip-permissions
briefing_args = --append-system-prompt
settings_args = --settings /etc/dejima-settings.json
keys = projects_dir:path=~/.claude/projects file_history_dir:path=~/.claude/file-history statusline_path:path=~/.claude/statusline.sh
# Which model API this harness talks to, and so which credential the host-side
# proxy injects. Per harness rather than global (#171): an operator running
# Claude Code against Anthropic and Cline against something else is the
# ordinary case, and one setting cannot say both.
#
# NO DEFAULT, for the reason [core] harness has none. A default that could ever
# change is a config file that is not the source of truth: a session whose
# config did not change must not start talking to a different endpoint because
# dejima's idea of a sensible one moved.
keys = backend:?provider:backend projects_dir:path=~/.claude/projects file_history_dir:path=~/.claude/file-history statusline_path:path=~/.claude/statusline.sh
# What this harness was talking to before the key existed, WRITTEN INTO the
# operator's config the first time a launch finds it unset - not resolved as a
# default on every read.
#
# The difference is the whole point. A default is dejima's answer, re-decided
# every launch, and changing it later moves a session whose config never
# changed. A migration is the operator's answer, recorded once: after it runs,
# nothing dejima decides later can move them.
#
# Safe to state here because it is not a guess. Claude Code against Anthropic
# is what every existing session already was, so writing it down changes
# nothing about the launch it runs in.
assumed_backend = anthropic
resume_command = claude --resume {{session_id}}
briefing = briefing.md
+9 -9
View File
@@ -1,10 +1,10 @@
export CLINE_DATA_DIR={{harness_state_dir}}
cline history # find this session's id
cline --id <session-id>
This session cannot be continued outside dejima, so do not offer that or ask
the operator to try it. It CAN be continued by launching dejima again on this
project: this project's Cline state - sessions, transcripts, tasks - is kept
on the host in a directory of its own and survives the container being
replaced, so the next launch picks up where this one left off.
(the session id is deliberately absent from this briefing: Cline mints one when
a run starts, which is after this text was written, so `cline history` is where
to read it. CLINE_DATA_DIR has to be set because this project's Cline state is
kept in that directory on the host rather than in ~/.cline/data - Cline indexes
every session it has ever run in a single database, so dejima gives each
project its own and one project's history is never another's context.)
(Cline indexes every session it has ever run in a single database, so dejima
gives each project its own rather than sharing one. That directory is
dejima's, not Cline's own, and pointing a host-side Cline at it is not a
supported path - which is why resuming outside is not on offer.)
+27 -13
View File
@@ -14,7 +14,9 @@ install = npm:cline
# Documents/Cline/Rules is where the briefing is staged. That path is not a
# guess and not what Cline's documentation says: ~/Documents/Cline/Rules
# reaches the model on Linux and ~/Cline/Rules does not.
image_dirs = .cline .cline/data Documents Documents/Cline Documents/Cline/Rules
# .agents and .cline/hooks are the operator's own config, mounted from the
# host by the plugin; they need a mount point in the image to land on.
image_dirs = .cline .cline/data .cline/hooks .agents .agents/skills .agents/hooks Documents Documents/Cline Documents/Cline/Rules
# DO_NOT_TRACK and DISABLE_TELEMETRY are both honoured; CLINE_NO_AUTO_UPDATE
# stops a session upgrading its own harness mid-run, which would replace a
@@ -22,21 +24,15 @@ image_dirs = .cline .cline/data Documents Documents/Cline Documents/Clin
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
# to allowlist. Cline's own endpoints (api.cline.bot and friends) are
# deliberately absent - a session that falls back to them has lost its
# provider configuration, and failing to connect is the right outcome.
# backend configuration, and failing to connect is the right outcome.
api_domains =
# Only meaningful on the Anthropic path, where Cline is pointed at the real
# hostname with a placeholder key and dejima's terminator and host-side proxy
# supply the credential. Confirmed end to end. On the local-endpoint path
# nothing connects here and pinning it costs nothing.
proxied_api_domain = api.anthropic.com
# None. Cline registers no hook that can refuse a tool call, so bash-deny has
# nowhere to live, and it has no session lifecycle signal dejima can turn into
# a notification. push-guard and merge-guard are not listed because no harness
@@ -44,6 +40,12 @@ proxied_api_domain = api.anthropic.com
# names what this costs; see harness_report_guards.
guards =
# Both, which is what makes this harness the backend-agnostic one: it has a
# native Anthropic client and an openai-compatible one, and dejima picks
# between them from the selected backend's api_style rather than from a
# setting an operator has to keep in step by hand.
api_styles = anthropic openai
# R3. Cline registers no session-start hook, so the briefing is a file, and
# this is the path that reaches the model - established by running it, not by
# reading the documentation, which says ~/Cline/Rules. On Linux that directory
@@ -63,9 +65,10 @@ no_confirm_args = --auto-approve true
# goes stale with the next Cline release, and choosing the model an unattended
# agent runs on is the operator's call. Absent, the launch refuses and says so.
#
# base_url absent means the Anthropic path through dejima's credential proxy.
# Set, it means a self-hosted endpoint, and the plugin refuses one that is not
# host-local - see bin/harness/cline.sh for why that is an R5 check.
# base_url is NOT here. Where the model is belongs to the backend: a
# self-served one carries its own [backend.<name>] base_url, which is what
# lets an operator keep a local endpoint configured while using a hosted API
# and switch between them by changing one word.
# context_window is the one Cline cannot work out for itself. It budgets
# against its own catalogue, and for a model it does not recognise - which is
# every self-served one - it has no budget at all, so it grows the conversation
@@ -77,7 +80,18 @@ no_confirm_args = --auto-approve true
# compacts constantly. Set it a little under whatever the server allows -
# Cline's accounting is approximate and a compaction does not always land
# under target when a single tool result is large.
keys = state_dir:path=~/.local/share/dejima/cline model:?identifier base_url:?url context_window:?int:512:100000000
# See claude-code's manifest for why this is per harness and why it has no
# default. The risk is concrete here rather than theoretical: Cline supports
# many backends and has one of its own, so any value dejima shipped
# would be dejima choosing an endpoint on the operator's behalf, and changing
# it later would move a running setup without touching its config.
#
# And no assumed_backend either, deliberately. What this harness was talking
# to before the key existed depends on base_url - unset meant Anthropic, set
# meant a model the operator serves - so there is no single value to record,
# and recording the wrong one would write a proxied endpoint into the config
# of a session that never used one. Making the two coherent is its own change.
keys = backend:?provider:backend state_dir:path=~/.local/share/dejima/cline model:?modelid context_window:?int:512:100000000
# Deliberately empty: resuming needs the state directory's path as well as the
# session id, briefing placeholders do not nest, and `template` forbids the
+22 -8
View File
@@ -35,11 +35,16 @@ session_id="${1:-}"
resume_id="${session_id:-<your session id>}"
project_dir="${2:-$PWD}"
# /etc/hostname in a docker container is the container's own ID, which
# docker exec accepts directly - substituting it into the briefing below
# means the agent states its real container instead of guessing (a wrong
# guess that matches *another* running sandbox makes dejima-allow
# "succeed" against the wrong container).
# The id of this launch, read from the only place a session can see one.
#
# Not this container's own id, and worth being exact about: Docker names a
# container that joined another's network namespace after the container it
# joined, so what a session reads here is its FILTER container's id. That is
# still the right thing to put in the briefing - `dejima-allow` takes either
# half of the pair and derives the other - and it is what the agent has, so
# substituting it means stating a real id rather than guessing one. A wrong
# guess that matched another running sandbox would make dejima-allow "succeed"
# against somebody else's session.
container_id=$(cat /etc/hostname 2>/dev/null)
container_id="${container_id:-<container-id>}"
@@ -192,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")
+93 -20
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.
#
@@ -25,28 +25,92 @@ input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')
cwd=$(echo "$input" | jq -r '.cwd // empty')
# Only care about `git push` invocations.
if ! [[ "$command" =~ (^|[[:space:]&\|;])git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+push([[:space:]]|$) ]]; then
exit 0
fi
# Only care about `git push` invocations - found by walking tokens rather than
# by matching a shape.
#
# The pattern this replaced recognised `git push` and `git -C <dir> push` and
# nothing else, so every other spelling of git's own options - `git -c k=v
# push`, `git --git-dir=... push`, `git --no-pager push` - was not a push as
# far as this hook was concerned, and it exited 0 before any check ran. The
# PATH shadow in docker/git-guard.sh walks argv and caught them regardless,
# which is why this was a quiet gap in the earlier warning rather than an open
# one.
#
# Split on the shell separators first, because a push can be the second half
# of a compound command, and only the segment that STARTS with git is a git
# invocation - "echo push" is not.
push_seen=0
segment=""
IFS=$'\n' read -rd '' -a _segments < <(
printf '%s' "$command" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g'
printf '\0')
for seg in ${_segments[@]+"${_segments[@]}"}; do
read -ra _t <<< "$seg"
[ "${#_t[@]}" -gt 0 ] || continue
[ "${_t[0]}" = git ] || continue
_i=1
while [ "$_i" -lt "${#_t[@]}" ]; do
case "${_t[$_i]}" in
-c|-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
_i=$((_i + 1)) ;;
-*) ;;
push) push_seen=1; segment="$seg"; break ;;
*) break ;;
esac
_i=$((_i + 1))
done
[ "$push_seen" -eq 1 ] && break
done
[ "$push_seen" -eq 1 ] || exit 0
# Everything below reads the git invocation, not the whole command line.
command="$segment"
repo_dir="$cwd"
if [[ "$command" =~ git[[:space:]]+-C[[:space:]]+([^[:space:]]+) ]]; then
repo_dir="${BASH_REMATCH[1]}"
fi
# Positionally parse everything after "push": first non-flag token is the
# remote, second (if any) is the refspec. Word-splitting rather than a full
# shell parse, so quoting edge cases aren't handled - same pragmatic
# tradeoff as the sibling deny-dangerous-bash.sh denylist.
after_push="${command#*push}"
read -ra tokens <<< "$after_push"
# One pass over the whole command, the way the shadow walks argv: skip git's
# own options until the first token that IS the subcommand, then take the
# first non-flag token as the remote and the second as the refspec.
#
# Splitting on the first occurrence of the substring "push" is what this
# replaced, and it split inside `remote.origin.pushurl=...`, a branch named
# push-fix, and anything else carrying the word. What came out was a nonsense
# remote, which then took the unresolvable path below.
#
# Word-splitting rather than a full shell parse, so quoting edge cases are not
# handled - the same pragmatic tradeoff as the sibling
# deny-dangerous-bash.sh denylist, and the reason the unresolvable case
# refuses rather than guesses.
#
# `-c key=value` is kept rather than dropped: git resolves where a push goes
# from the config the push runs under, so asking without these answers for a
# different configuration. `git -c remote.origin.pushurl=... push origin
# branch` is that case.
config_overrides=()
positional=()
for tok in "${tokens[@]}"; do
case "$tok" in
-*) continue ;;
*) positional+=("$tok") ;;
esac
read -ra tokens <<< "$command"
t=0
seen_push=0
while [ "$t" -lt "${#tokens[@]}" ]; do
tok="${tokens[$t]}"
if [ "$seen_push" -eq 0 ]; then
case "$tok" in
-c) t=$((t + 1))
[ "$t" -lt "${#tokens[@]}" ] && config_overrides+=(-c "${tokens[$t]}") ;;
-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
t=$((t + 1)) ;;
push) seen_push=1 ;;
*) ;;
esac
else
case "$tok" in
-*) ;;
*) positional+=("$tok") ;;
esac
fi
t=$((t + 1))
done
remote="origin"
@@ -58,11 +122,20 @@ if [ "${#positional[@]}" -ge 2 ]; then
refspec="${positional[1]}"
fi
remote_url=$(git -C "$repo_dir" remote get-url "$remote" 2>/dev/null)
# --push: the URL the push will actually use. remote.<name>.pushurl wins over
# remote.<name>.url when it is set, and reading the latter checks a host the
# push is not going to. See docker/git-guard.sh, which asks the same question.
remote_url=$(git ${config_overrides[@]+"${config_overrides[@]}"} \
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)
if [ -z "$remote_url" ]; then
# Can't resolve a remote from here - let git produce the real error
# rather than block or guess.
exit 0
# Refused, not passed through. A remote that does not resolve is usually a
# typo git would reject in a moment anyway - but it is also what a
# disagreement between this parser and git's own looks like, and the
# failure of that disagreement has to be a refusal rather than a push
# nobody checked. docker/git-guard.sh takes the same direction; the two
# answering differently was the bug.
echo "Blocked by git-push-guard.sh: cannot resolve the remote '$remote' to a URL, so there is nothing to check this push against. Name a remote that exists." >&2
exit 2
fi
host=$(echo "$remote_url" | sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')
+7 -8
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
@@ -41,11 +40,11 @@ if [ "${DEJIMA_BRIEFING_VIA_ARGS:-0}" = "1" ]; then
fi
# The image's copy first, because that is the one that exists in a real
# session: this file reaches the container through the ~/.claude/hooks mount,
# and install.sh links it there one file at a time, so its sibling in the repo
# is not alongside it. The sibling is the fallback so the hook can be run
# session: this file is baked in beside it and registered by absolute path
# into the sandbox's --settings overlay, so nothing about it depends on the
# operator's ~/.claude. The sibling is the fallback so the hook can be run
# against a checkout - which is what the test suite does - and readlink -f is
# what turns the linked path back into the repo one.
# what turns a linked path back into the repo one.
#
# Missing or unreadable degrades to no briefing rather than failing the hook:
# a SessionStart hook that exits non-zero drops the whole session's context,
+47 -24
View File
@@ -1,10 +1,13 @@
#!/bin/bash
# Idempotent installer for dejima. Registers the selected harness's guard
# hooks wherever that harness reads them from, and puts
# Idempotent installer for dejima. Writes NOTHING to the harness's own
# configuration: every hook dejima uses is baked into the image and registered
# per launch into a settings layer of dejima's own, so installing this does not
# change how the harness behaves outside a sandbox. What it still does is take
# back what older versions put there. It puts
# bin/dejima, bin/dejima-allow, bin/dejima-dep, bin/dejima-project,
# bin/dejima-audit and the opt-in remote-access scripts on PATH. Safe to
# re-run - every step is a no-op if already done, and every change is
# printed as it happens.
# bin/dejima-audit, bin/dejima-share and the opt-in remote-access scripts on
# PATH. Safe to re-run - every step is a no-op if already done, and every
# change is printed as it happens.
#
# It never enables anything: the remote-access scripts do nothing without
# [remote-access] enabled = on in ~/.config/dejima/config, and the user units
@@ -76,6 +79,10 @@ link_bin dejima-project
# operator to run `dejima-audit ack`, which is only true once it is on PATH.
link_bin dejima-audit
link_bin dejima-epitaph
# ~/shared is one directory per project now, so "where do I put a file for the
# next session" has an answer only this can give - and the launch notice about
# files left over from the old layout tells the operator to run it.
link_bin dejima-share
link_bin dejima-update-check
# Remote access (opt-in, see docs/remote-access.md). All three are inert
@@ -123,9 +130,14 @@ else
_starter_extra_keys="$(_config_extra_keys_for forge gitea)"
fi
# Seeded from any legacy variables still exported, so the one-time
# migration is a review rather than a retyping. Everything else is
# written commented-out at its schema default, so the file doubles as
# the reference for what can be set.
# migration is a review rather than a retyping.
#
# Only what is seeded. This file used to carry every key commented out at
# its schema default, so it doubled as the reference - which meant a second
# copy of the defaults, in comments nothing regenerates, drifting against
# the schema as keys were added. defaults.conf beside it is that reference
# now, complete and rewritten by every ./install.sh, and this file holds
# only what the operator has actually decided.
{
printf '# dejima configuration. See README.md.\n'
printf '# Every key below is shown at its default; uncomment to change one.\n'
@@ -147,11 +159,6 @@ else
[ -n "$_key" ] || continue
_leaf="${_key##*.}"
_sec="${_key%.*}"
_default="$(config_default "$_key")"
if [ "$_sec" != "$_section" ]; then
printf '\n[%s]\n' "$_sec"
_section="$_sec"
fi
_seed=""
case "$_key" in
forge.gitea.ssh_host) _seed="${GITEA_SSH_HOST:-}" ;;
@@ -179,24 +186,40 @@ else
[ -n "${XDG_DATA_HOME:-}" ] && _seed="$XDG_DATA_HOME/dejima/baselines"
;;
esac
if [ -n "$_seed" ]; then
printf '%s = %s\n' "$_leaf" "$_seed"
else
printf '# %s = %s\n' "$_leaf" "$_default"
[ -n "$_seed" ] || continue
# The header is written with the first key that lands under it, so
# a section nothing seeded leaves no empty stanza behind.
if [ "$_sec" != "$_section" ]; then
printf '\n[%s]\n' "$_sec"
_section="$_sec"
fi
printf '%s = %s\n' "$_leaf" "$_seed"
done < <(_config_keys; [ -z "$_starter_extra_keys" ] || printf '%s\n' "$_starter_extra_keys")
} > "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
fi
# The pinned defaults, rebuilt: keys this release added appended, keys it
# retired dropped, everything already decided left where it is. This is the
# only place that reports a shipped default having moved, because this is when
# it moves - you pulled and re-ran this. A launch checking would be a standing
# condition in front of every one.
# The blank line is the rebuild's, not this script's: on a run with nothing to
# add, drop or report it says nothing, and an unconditional `echo` here left a
# second blank line between two paragraphs that were already separated.
config_defaults_rebuild
# Guard registration, after the starter config above rather than before it:
# where dejima's guards go, and in what shape, is the selected harness's
# business, and on a fresh install that selection is the one just seeded.
# Registering before reading it would mean writing one harness's config for
# an operator who has chosen another.
#
# Delegated to the harness's plugin, which is also what gates each guard on
# the harness having declared it (see `guards` in its manifest).
# Nothing to register: the sandbox's hooks are baked into the image and
# registered per launch into its own settings overlay. What is delegated to
# the plugin is the host-side cleanup an upgrade needs - taking back what
# older versions wrote into the harness's config - which still has to know
# which harness is selected.
echo
source "$SCRIPT_DIR/bin/dejima-harness-lib.sh"
# A config that does not parse leaves config_get empty for EVERY key, so
@@ -205,20 +228,20 @@ source "$SCRIPT_DIR/bin/dejima-harness-lib.sh"
# fault, on stderr, which is the answer to a question this cannot answer.
_harness=""
if config_exists && ! config_validate; then
echo "$CONFIG_FILE could not be read, so no guard hooks were registered."
echo "$CONFIG_FILE could not be read, so no harness host-side setup was checked."
echo "Fix what is reported above and re-run ./install.sh."
elif ! _harness="$(config_get core harness 2>/dev/null)" || [ -z "$_harness" ]; then
_harness=""
echo "No [core] harness is set in $CONFIG_FILE, so no guard hooks were"
echo "registered. Set one and re-run ./install.sh."
echo "No [core] harness is set in $CONFIG_FILE, so no harness host-side"
echo "setup was checked. Set one and re-run ./install.sh."
else
DEJIMA_HARNESS_NAME="$_harness"
harness_plugin_load "$_harness"
if declare -F harness_register_guards >/dev/null 2>&1; then
echo "Registering $_harness's guard hooks"
echo "Checking $_harness's host-side setup"
harness_register_guards "$SCRIPT_DIR" "$BACKUP_DIR"
else
echo "Harness '$_harness' installs no guard hooks."
echo "Harness '$_harness' needs nothing on the host."
echo
fi
fi
+14 -3
View File
@@ -629,10 +629,21 @@ EOF
printf 'keep = 10\n' >> "$CONFIG_DIR/config"
snap; git -C "$WS" config user.name changed
chmod 500 "$AUDITDIR/$slug"
run timeout 5 "$AUDIT" compare "$WS" "$STATE"
# The subject is FOREVER, not fast. Rotation trims oldest-first and breaks
# when the unlink fails; without that break the loop's condition never
# changes and it spins. Any finite bound catches that, so the bound is
# generous on purpose: a five-second one was timing the machine, and on a
# loaded one a correct run exceeded it and reported a spin that had not
# happened (#295).
run timeout 120 "$AUDIT" compare "$WS" "$STATE"
result="$status"
chmod 700 "$AUDITDIR/$slug"
[ "$result" -ne 124 ]
[ "$result" -ne 124 ] || { echo "rotation did not terminate"; return 1; }
# And it gave up rather than evicting: an unwritable directory means every
# planted report is still there. Without this the test says only that the
# command exited, which a rotation that silently did nothing also satisfies.
[ "$(find "$AUDITDIR/$slug" -maxdepth 1 -name '2026-01-01-*.txt' | wc -l)" -eq 12 ] \
|| { echo "reports were evicted from a directory that cannot be written"; return 1; }
}
# One `ack` run used to walk one report and exit, saying nothing about the
@@ -2101,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
+1519 -89
View File
File diff suppressed because it is too large Load Diff
+120 -18
View File
@@ -29,17 +29,25 @@ teardown() {
# A real HTTP server standing in for Loki, appending each pushed body to a
# file. Real because the shipper reaches it with curl: a stub that replaced
# curl would stop testing the thing that formats and sends the payload.
# The server binds port 0 itself and reports what it got, rather than a helper
# picking a free port and handing the number over.
#
# That handover was a race, and `bats --jobs` is what makes it fire: the helper
# bound port 0, read the number, CLOSED the socket, and only then did the
# server bind it. Another test starting in that window takes the same port, and
# the loser dies with "Address already in use" - but the readiness probe below
# still succeeds, because something IS listening on that port. The test then
# ships to the other test's Loki, finds its own log empty, and reports the
# failure as a lost transcript. Seen in CI, not locally: it needs enough cores
# for two tests to be in that window at once.
start_loki() {
LOKI_PORT="$(python3 - <<'EOF'
import socket
s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()
EOF
)"
python3 - "$LOKI_PORT" "$LOKI_LOG" <<'EOF' &
import sys
local portfile="$BATS_TEST_TMPDIR/loki-port"
rm -f "$portfile"
python3 - "$portfile" "$LOKI_LOG" <<'EOF' &
import sys, os
from http.server import BaseHTTPRequestHandler, HTTPServer
port, path = int(sys.argv[1]), sys.argv[2]
portfile, path = sys.argv[1], sys.argv[2]
class H(BaseHTTPRequestHandler):
def do_POST(self):
@@ -51,12 +59,26 @@ class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
HTTPServer(("127.0.0.1", port), H).serve_forever()
srv = HTTPServer(("127.0.0.1", 0), H)
# Written only once the socket is bound and listening, and atomically, so the
# shell can never read a half-written number or one for a socket that is gone.
tmp = portfile + ".tmp"
with open(tmp, "w") as f:
f.write(str(srv.server_address[1]))
os.replace(tmp, portfile)
srv.serve_forever()
EOF
LOKI_PID=$!
local i
for i in $(seq 1 50); do
(exec 3<>/dev/tcp/127.0.0.1/"$LOKI_PORT") 2>/dev/null && return 0
for i in $(seq 1 100); do
if [ -s "$portfile" ]; then
LOKI_PORT="$(cat "$portfile")"
return 0
fi
# A server that died on startup will never write the file, and waiting
# the full timeout for a process that is already gone only delays the
# real message.
kill -0 "$LOKI_PID" 2>/dev/null || break
sleep 0.1
done
echo "the fake Loki never came up" >&2
@@ -106,6 +128,23 @@ await_shipped() {
return 1
}
# Blocks until the shipper has finished a poll and is asleep between polls.
# The two SIGTERM tests below need exactly that state - the trap installed, one
# scan done - and both used to state it by sleeping for a second. That is a
# statement about the machine rather than about the shipper: under load the
# startup that precedes the trap takes longer than the sleep, the TERM lands
# before there is a trap to catch it, and the process dies flushing nothing.
# The nap is a child `sleep` of the shipper, which makes the state observable.
await_napping() {
local i
for i in $(seq 1 200); do
pgrep -P "$SHIP_PID" -x sleep >/dev/null 2>&1 && return 0
sleep 0.05
done
echo "the shipper never reached its first nap" >&2
return 1
}
@test "a line is shipped to Loki while the session runs" {
start_loki
write_config
@@ -156,10 +195,11 @@ await_shipped() {
write_config
mkdir -p "$(slug_dir)"
start_ship
# Long enough for one poll to read the line into the batch, short enough
# that the 2s batch interval has not flushed it on its own.
# Written before the first poll, so the poll reads it into the batch; the
# kill then lands while the shipper is asleep with it still in there,
# because the batch interval has not elapsed since it started.
say "already batched" >> "$(slug_dir)/s1.jsonl"
sleep 1
await_napping
kill -TERM "$SHIP_PID"
wait "$SHIP_PID" 2>/dev/null || true
SHIP_PID=""
@@ -172,10 +212,11 @@ await_shipped() {
write_config
mkdir -p "$(slug_dir)"
start_ship
# Written and killed inside one poll interval, so nothing has read it yet:
# only the trap's own rescan can find it. A final_flush that flushed the
# batch without rescanning would pass the test above and fail this one.
sleep 1
# Written after a poll has already run and killed before the next one, so
# nothing has read it yet: only the trap's own rescan can find it. A
# final_flush that flushed the batch without rescanning would pass the test
# above and fail this one.
await_napping
say "the final answer" >> "$(slug_dir)/s1.jsonl"
kill -TERM "$SHIP_PID"
wait "$SHIP_PID" 2>/dev/null || true
@@ -352,3 +393,64 @@ EOF
run grep -c "should not ship" "$LOKI_LOG"
[ "$output" -eq 0 ] || { echo "shipped without a resolvable layout: $(shipped)"; return 1; }
}
@test "the tail survives a kill even when the harness declares an optional key with no default" {
# #284, and it is worth pinning by SHAPE rather than by the key that
# exposed it. Any optional keyspec with an empty default in the selected
# harness's `keys` used to make final_flush's config lookup fail: it
# re-resolves the transcript paths from the SIGTERM handler, and bash
# reports 128+n for commands completing while it delivers a signal, so a
# perfectly valid manifest was rejected and the tail - usually the agent's
# final answer, which is the whole reason final_flush exists - was dropped
# with no error the operator would ever see.
#
# Nothing shipped for claude-code had that shape until #171 added one, so
# this adds one of its own rather than depending on a real key keeping it.
# A provider root of its own, so the shipped manifests are not edited and
# a concurrent run is unaffected.
local proot="$BATS_TEST_TMPDIR/proot"
mkdir -p "$proot"
# Loudly: a partial copy here produces exactly the symptom this test is
# about - a lookup that fails - and swallowing the error would report the
# fixture's fault as the code's.
cp -a "$REPO_ROOT/forges" "$REPO_ROOT/workflows" "$REPO_ROOT/harnesses" \
"$REPO_ROOT/backends" "$proot/" \
|| { echo "could not build the probe provider root"; return 1; }
local axis
for axis in forges workflows harnesses backends; do
[ -d "$proot/$axis" ] || { echo "the probe provider root has no $axis/"; return 1; }
done
sed -i 's/^keys \( *\)=/keys \1= probe_only_key:?identifier/' \
"$proot/harnesses/claude-code/harness.conf"
grep -q 'probe_only_key:?identifier' "$proot/harnesses/claude-code/harness.conf" \
|| { echo "the probe key was not injected"; return 1; }
export DEJIMA_PROVIDER_ROOT="$proot"
start_loki
write_config
mkdir -p "$(slug_dir)"
start_ship
# Wait for the shipper to be demonstrably running rather than sleeping at
# it. A fixed sleep is a second race on a loaded machine: if the process
# is still starting when the signal arrives, the handler runs before the
# key schema has been resolved, which fails for the ORIGINAL #284 reason
# and would read as this fix not working.
say "a first line" >> "$(slug_dir)/s1.jsonl"
await_shipped "a first line" || { echo "the shipper never started"; return 1; }
say "the final answer" >> "$(slug_dir)/s1.jsonl"
kill -TERM "$SHIP_PID"
wait "$SHIP_PID" 2>/dev/null || true
SHIP_PID=""
# Wait for the line rather than reading the log the instant the shipper
# exits. `wait` returns when the SHIPPER is gone, which says nothing about
# the collector on the other end having written what it was sent - so
# reading immediately is a race the loaded machine loses, and it fails as
# "the tail was lost", which is precisely this test's bug signature (#295).
await_shipped "the final answer" \
|| { echo "the unread tail was lost: $(shipped)"; return 1; }
# And the lookup did not merely survive - it never failed. That message
# reaching an operator's terminal is the visible half of this bug.
[[ "$(shipped)" != *"unknown config key"* ]] || { echo "$(shipped)"; return 1; }
}
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Runs a command on a real pty and answers its prompts.
The credential scan only asks when stdin is a terminal, which is the whole
point - an unattended launch must not block on a question nobody will see. So
a test that drives the interactive path needs a pty rather than a pipe, or it
exercises the non-interactive branch while claiming to test the other one.
Usage: answer-prompt.py [--marker TEXT] <answer-per-prompt>... -- <command> [args...]
Each answer is sent when the child writes a line ending in the prompt marker.
Everything the child wrote is printed on exit, and the child's exit status is
this program's.
"""
import os
import pty
import signal
import re
import select
import sys
import time
# The default is the credential scan's prompt. --marker takes another, so a
# test for a different prompt does not need a second copy of this.
MARKER = "[s/a/o/r]"
# dejima pauses before the image build when it printed a notice worth reading.
# That is not the prompt any test here is about - it is ambient, and every
# launch fixture that sets [core] ssh_key raises it - so it is answered
# automatically rather than costing every caller an extra argument.
AMBIENT = "Press Enter to continue"
# An IDLE timeout: how long to wait with no output before giving up. Generous
# because the thing it guards against is a command that never finishes, and
# any finite bound catches that - while a tight one just times the machine.
# Sixty seconds was enough on an idle box and not enough under the full gate,
# where every suite runs at once (#295).
TIMEOUT = 240.0
def main():
if "--" not in sys.argv:
print("usage: answer-prompt.py <answer>... -- <command>...", file=sys.stderr)
return 2
split = sys.argv.index("--")
args = list(sys.argv[1:split])
global MARKER
if "--marker" in args:
i = args.index("--marker")
MARKER = args[i + 1]
del args[i:i + 2]
answers = args
command = sys.argv[split + 1:]
if not command:
print("no command given", file=sys.stderr)
return 2
pid, fd = pty.fork()
if pid == 0:
# The child: a session leader with this pty as its controlling
# terminal, which is what makes `[ -t 0 ]` true inside dejima.
try:
os.execvp(command[0], command)
except OSError as e:
print("exec failed: %s" % e, file=sys.stderr)
os._exit(127)
out = []
pending = list(answers)
deadline = TIMEOUT
while True:
r, _, _ = select.select([fd], [], [], deadline)
if not r:
out.append("\n[answer-prompt: timed out waiting for output]\n")
break
try:
chunk = os.read(fd, 4096)
except OSError:
break
if not chunk:
break
text = chunk.decode("utf-8", "replace")
out.append(text)
# Answer only when the prompt is the most recent thing written, so a
# marker appearing inside prose does not consume an answer.
if pending and MARKER in text:
os.write(fd, (pending.pop(0) + "\n").encode())
elif AMBIENT in text and AMBIENT != MARKER:
os.write(fd, b"\n")
# The read loop ends when the child stops WRITING, which is not when it
# stops running: dejima does its exit audit and stops its shipper after
# the last thing it prints. So give it a grace period to finish before
# concluding anything - killing it there reported a child that was merely
# busy as a hang, and left it taking SIGPIPE on a pty nobody was reading.
#
# Only then kill, because an unanswered prompt is the other way this ends
# and waiting on that turns a failing test into a hung suite. The caller
# is told which it was.
stuck = False
status = 0
grace = time.time() + 60.0
try:
while True:
done, status = os.waitpid(pid, os.WNOHANG)
if done != 0:
break
if time.time() >= grace:
stuck = True
os.kill(pid, signal.SIGKILL)
_, status = os.waitpid(pid, 0)
break
time.sleep(0.1)
except OSError:
status = 0
sys.stdout.write("".join(out))
if pending:
sys.stdout.write("\n[answer-prompt: %d answer(s) never used]\n" % len(pending))
if stuck:
sys.stdout.write("\n[answer-prompt: the command never exited - killed. "
"It was probably waiting at a prompt nothing answered.]\n")
return os.waitstatus_to_exitcode(status) if hasattr(os, "waitstatus_to_exitcode") \
else (status >> 8)
if __name__ == "__main__":
sys.exit(main())
+51 -2
View File
@@ -6,6 +6,8 @@
# records the request body)
# GET /oauth-hits -> number of refresh calls so far (text/plain)
# GET /oauth-last -> JSON body of the most recent refresh request
# * /status-429 -> 429, chunked, retry-after: 41 (a rate limiter's shape)
# * /status-429-gzip -> the same, gzip-encoded (what a client asking for gzip gets)
# * /status-503 -> 503, body "upstream-error"
# * /echo-401-once -> 401 on the first call ever, then echoes
# GET /sse -> two SSE events 2s apart, no Content-Length
@@ -15,7 +17,7 @@ import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
STATE = {"oauth_hits": 0, "oauth_last": {}, "returned_401": False}
STATE = {"oauth_hits": 0, "oauth_last": {}, "returned_401": False, "oauth_reject": False}
LOCK = threading.Lock()
@@ -48,11 +50,26 @@ class Handler(BaseHTTPRequestHandler):
}).encode())
def _handle(self):
if self.path == "/v1/oauth/token":
if self.path == "/oauth-reject":
# Make the next refresh fail the way a finished refresh token does.
# Set rather than always-on, so the ordinary refresh tests keep the
# same endpoint.
with LOCK:
STATE["oauth_reject"] = True
self._send(200, b"ok", "text/plain")
elif self.path == "/v1/oauth/token":
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
with LOCK:
STATE["oauth_hits"] += 1
STATE["oauth_last"] = req
reject = STATE.get("oauth_reject")
if reject:
# Verbatim from a real overnight expiry.
self._send(400, json.dumps({
"error": "invalid_grant",
"error_description": "Refresh token expired",
}).encode())
return
self._send(200, json.dumps({
"access_token": "refreshed-access-token",
"refresh_token": "rotated-refresh-token",
@@ -62,6 +79,38 @@ class Handler(BaseHTTPRequestHandler):
self._send(200, str(STATE["oauth_hits"]).encode(), "text/plain")
elif self.path == "/oauth-last":
self._send(200, json.dumps(STATE["oauth_last"]).encode())
elif self.path == "/status-429-gzip":
# gzip + chunked + retry-after: what a rate limiter answers a
# client that asked for gzip, which every Node harness does. The
# proxy forwards the client's Accept-Encoding verbatim, so this is
# the shape it actually meets - and the first version wrote those
# bytes to the operator's terminal.
import gzip as _gzip
b = _gzip.compress(
b'{"type":"error","error":{"type":"rate_limit_error","message":"slow down"}}')
self.send_response(429)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Encoding", "gzip")
self.send_header("retry-after", "41")
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
self.wfile.write(b"%x\r\n" % len(b) + b + b"\r\n0\r\n\r\n")
elif self.path == "/status-429":
# Chunked, with no Content-Length, and a retry-after - the framing
# a rate limiter actually uses, and the one the proxy's first
# attempt at logging could not read a body out of.
self.send_response(429)
self.send_header("Content-Type", "application/json")
self.send_header("retry-after", "41")
self.send_header("request-id", "req_011CehAZUPXFbQGikXfM3Mp6")
# The ids a real response also carries, so the test that they stay
# out of the log has something to stay out of.
self.send_header("anthropic-organization-id", "e528bbab-dead-beef")
self.send_header("anthropic-workspace-id", "wrkspc_01MpMcAs9xg5")
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
b = b'{"type":"error","error":{"type":"rate_limit_error","message":"slow down"}}'
self.wfile.write(b"%x\r\n" % len(b) + b + b"\r\n0\r\n\r\n")
elif self.path == "/status-503":
self._send(503, b"upstream-error", "text/plain")
elif self.path == "/echo-401-once":
+51 -9
View File
@@ -246,6 +246,28 @@ def await_reclaim(timeout=10.0):
return reclaimed()
def raw_restored():
"""True once the resumed client has put the terminal back into raw mode."""
flags = lflag()
return not (flags & termios.ICANON) and not (flags & termios.ECHO)
def await_raw(timeout=15.0):
"""Wait for that, rather than reading the flags at a fixed moment after fg.
`fg` returns as soon as the job is continued; the client sets raw mode some
scheduling later. Sampling the flags right after fg was timing the machine,
and on a loaded one it read the shell's own canonical mode and reported it
as a failure to restore - the same shape as the reclaim wait above.
"""
end = time.time() + timeout
while time.time() < end:
if raw_restored():
return True
pump(0.05)
return raw_restored()
def received():
try:
with open(RECEIVED, "rb") as fh:
@@ -254,6 +276,28 @@ def received():
return b""
def await_received(predicate, timeout=15.0):
"""Wait for what the container received to satisfy predicate.
The third instance of the same bug in this file - see await_raw and the
reclaim wait above. A byte written to the master pty has to reach the
client, cross into the container's stand-in and be appended to a file
before the check can see it, and pumping for a fixed 0.8s and then looking
was timing the machine rather than waiting for the thing. It failed in CI
exactly that way: the check for ^C saw the tail of the line typed before
it, because ^C had not arrived yet.
Returns the predicate's answer either way, so a real failure still fails
rather than hanging until the suite's own timeout.
"""
end = time.time() + timeout
while time.time() < end:
if predicate(received()):
return True
pump(0.05)
return predicate(received())
def check(name, ok, detail=""):
print(("PASS " + name) if ok else ("FAIL %s %s" % (name, detail)))
if not ok:
@@ -301,23 +345,21 @@ try:
expect("SHELL-ALIVE-%d" % cycle, 5.0))
forget()
send("fg\n")
expect("CONTAINER-REDRAW", 5.0)
restored = await_raw()
flags = lflag()
check("cycle%d-raw-mode-restored" % cycle,
not (flags & termios.ICANON) and not (flags & termios.ECHO),
check("cycle%d-raw-mode-restored" % cycle, restored,
"ICANON=%s ECHO=%s" % (bool(flags & termios.ICANON), bool(flags & termios.ECHO)))
check("cycle%d-redraw-poked" % cycle, "CONTAINER-REDRAW" in "".join(seen))
check("cycle%d-redraw-poked" % cycle, expect("CONTAINER-REDRAW", 15.0))
check("cycle%d-session-alive" % cycle, "LAUNCHER-DONE" not in "".join(seen))
before = received()
send("typed\n")
pump(0.8)
check("input-reaches-the-resumed-session", received() != before)
check("input-reaches-the-resumed-session",
await_received(lambda got: got != before))
before = received()
send("\x03")
pump(0.8)
check("ctrl-c-still-reaches-the-session", received().endswith(b"\x03"),
check("ctrl-c-still-reaches-the-session",
await_received(lambda got: got.endswith(b"\x03")),
repr(received()[-4:]))
# The finding, asserted: the client is stopped by SIGSTOP, which it cannot
+506
View File
@@ -0,0 +1,506 @@
#!/usr/bin/env bats
# How a launch is arranged: the containers it starts, in what order, on what
# network, and what it takes down again.
#
# The subject is bin/dejima's side of the filter container - the extra
# container per launch that owns the network namespace and the packet filter,
# which the sandbox joins and has no capability to alter. Whether the filter
# actually filters is runtime.bats's question and needs a real Docker; whether
# the launcher builds the arrangement that puts one there is this file's, and
# needs only a stub.
#
# Everything here runs against a `docker` that records its argv and exits 0, so
# a launch completes without a daemon. That makes the ORDER of the calls, and
# the flags on each, the thing being asserted.
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
SANDBOX_DIR="$REPO_ROOT/docker"
LIB="$REPO_ROOT/bin/dejima-project-lib.sh"
CONFIG_DIR="$(mktemp -d)"
RUNTIME_DIR="$(mktemp -d)"
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
export XDG_RUNTIME_DIR="$RUNTIME_DIR"
}
teardown() {
rm -rf "$CONFIG_DIR" "$RUNTIME_DIR" ${LAUNCH_DIR:+"$LAUNCH_DIR"} ${PROJ:+"$PROJ"}
}
# A `docker` that logs one line per invocation and succeeds at everything,
# including the readiness probe. Tests that need a failure override it.
stub_launch() {
LAUNCH_DIR="$(mktemp -d)"
mkdir -p "$LAUNCH_DIR/home/.claude"
echo '{}' > "$LAUNCH_DIR/home/.claude/settings.json"
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
# `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"
: > "$LAUNCH_DIR/sandbox-key"
chmod 600 "$LAUNCH_DIR/sandbox-key"
PROJ="$(mktemp -d)"
}
launch() {
local conf
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
mkdir -p "$(dirname "$conf")"
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
"$LAUNCH_DIR/sandbox-key" >> "$conf"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_DOCKER_LOG="$LAUNCH_DIR/docker-log" \
SANDBOX_TEST_FILTER_ENV="$LAUNCH_DIR/filter-env" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS= \
"${DEJIMA_BIN:-$REPO_ROOT/bin/dejima}" "$@"
}
log_line() { grep -- "$1" "$LAUNCH_DIR/docker-log" | tail -1; }
# 1-indexed position of the last line matching a pattern, for ordering claims.
log_pos() { grep -n -- "$1" "$LAUNCH_DIR/docker-log" | tail -1 | cut -d: -f1; }
@test "a launch creates a network of its own and a filter container on it" {
stub_launch
launch "$PROJ"
local net; net="$(log_line 'network create')"
[ -n "$net" ] || { echo "no network was created"; return 1; }
[[ "$net" =~ dejima-run-[0-9a-f]{16} ]] \
|| { echo "the network is not named for this run: $net"; return 1; }
[[ "$net" == *"--label dejima.run="* ]] \
|| { echo "the network carries no run label, so nothing can reconcile it: $net"; return 1; }
local run; run="$(log_line 'run -d --init')"
[ -n "$run" ] || { echo "no filter container was started"; return 1; }
[[ "$run" =~ --name\ dejima-filter-[0-9a-f]{16} ]] \
|| { echo "the filter container is not named for this run: $run"; return 1; }
[[ "$run" == *"--entrypoint /usr/local/bin/filter-entrypoint.sh"* ]] \
|| { echo "the filter container does not run the filter entrypoint: $run"; return 1; }
# One network per launch, and the filter container on that one - not on
# whatever bridge Docker would have picked. 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")"
[[ "$run" == *"--network dejima-run-$id"* ]] \
|| { echo "the filter container is not on this launch's network: $run"; return 1; }
}
@test "the sandbox joins the filter container's namespace rather than getting one" {
stub_launch
launch "$PROJ"
# The compose file is what carries it, from an environment variable the
# launcher exports - so both halves are asserted. Either alone passes on an
# arrangement where the session still has a network of its own.
grep -qE '^\s*network_mode: \$\{SANDBOX_NETNS_CONTAINER' "$SANDBOX_DIR/docker-compose.yml" \
|| { echo "the compose file does not join a namespace"; return 1; }
# And what it falls back to when nothing supplies one. Only a `docker
# compose` invocation by hand reaches this, but a default naming a real
# network is a sandbox that comes up working with unfiltered egress, and
# nothing about it looks wrong. `none` fails the same case visibly.
grep -qE '^\s*network_mode: \$\{SANDBOX_NETNS_CONTAINER:-none\}\s*$' \
"$SANDBOX_DIR/docker-compose.yml" \
|| { echo "the network_mode default is not fail-closed"; return 1; }
local run; run="$(log_line 'run -d --init')"
local id; id="$(sed -E 's/.*--name dejima-filter-([0-9a-f]{16}).*/\1/' <<< "$run")"
[ -n "$id" ]
run grep -c "SANDBOX_NETNS_CONTAINER" "$REPO_ROOT/bin/dejima"
[ "$status" -eq 0 ]
grep -q 'export SANDBOX_NETNS_CONTAINER="container:\$SANDBOX_FILTER_CONTAINER"' \
"$REPO_ROOT/bin/dejima" \
|| { echo "the launcher does not point the sandbox at the filter container"; return 1; }
}
@test "the filter container comes up before the session, not beside it" {
# The ordering IS the control. A namespace that gets its rules after
# something is already running in it has a window where a session holds an
# interface and no filter, which is what this arrangement exists to remove
# by construction rather than to bound by argument.
stub_launch
launch "$PROJ"
local net_at filter_at ready_at session_at
net_at="$(log_pos 'network create')"
filter_at="$(log_pos 'run -d --init')"
ready_at="$(log_pos 'dejima-firewall-ready')"
session_at="$(log_pos ' run --rm ')"
[ -n "$net_at" ] && [ -n "$filter_at" ] && [ -n "$ready_at" ] && [ -n "$session_at" ] \
|| { echo "one of the four steps never happened"; cat "$LAUNCH_DIR/docker-log"; return 1; }
[ "$net_at" -lt "$filter_at" ] \
|| { echo "the filter container started before its network existed"; return 1; }
[ "$filter_at" -lt "$ready_at" ] \
|| { echo "readiness was probed before the filter container was started"; return 1; }
[ "$ready_at" -lt "$session_at" ] \
|| { echo "the session started before the filter was confirmed up"; return 1; }
}
@test "a filter container that never comes up refuses the launch" {
# The failure this is really about is the quiet one: a launcher that waited,
# gave up, and started the session anyway would produce a working sandbox
# with no egress control, and nothing downstream would notice.
stub_launch
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
case "$*" in
# The marker never appears, and the container is reported gone - which is
# what a firewall that refused to start leaves behind.
*dejima-firewall-ready*) exit 1 ;;
*inspect*State.Running*) echo false; exit 0 ;;
*logs*) echo "[egress-policy] FAILED: registry.npmjs.org did not resolve"; exit 0 ;;
esac
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
run launch "$PROJ"
[ "$status" -ne 0 ] \
|| { echo "the launch succeeded with no egress control"; return 1; }
[[ "$output" == *"did not come up"* ]] \
|| { echo "it refused without saying why: $output"; return 1; }
# And it hands over what the filter container said, or the operator is left
# with a refusal and nowhere to look.
[[ "$output" == *"registry.npmjs.org"* ]] \
|| { echo "the refusal does not carry the filter container's own output: $output"; return 1; }
run grep -c -- ' run --rm ' "$LAUNCH_DIR/docker-log"
[ "$output" = "0" ] \
|| { echo "the session was started anyway"; return 1; }
}
@test "the launch takes its own container and network down again" {
stub_launch
launch "$PROJ"
local run id
run="$(log_line 'run -d --init')"
id="$(sed -E 's/.*--name dejima-filter-([0-9a-f]{16}).*/\1/' <<< "$run")"
[ -n "$id" ]
grep -q -- "rm -f dejima-filter-$id" "$LAUNCH_DIR/docker-log" \
|| { echo "the filter container was left running"; return 1; }
grep -q -- "network rm dejima-run-$id" "$LAUNCH_DIR/docker-log" \
|| { echo "the network was left behind"; return 1; }
# After the session, not before it. Docker permits removing a namespace
# owner from under a live joiner, so this order is the launcher's to keep.
local session_at rm_at
session_at="$(log_pos ' run --rm ')"
rm_at="$(log_pos "rm -f dejima-filter-$id")"
[ "$session_at" -lt "$rm_at" ] \
|| { echo "the filter container was removed before the session ran"; return 1; }
}
@test "the filter container runs the base image, never a project's derived one" {
# It is the privileged half of the pair. A project's declared dependencies
# are installed into the derived image for the session to use, and there is
# no reason for any of them to be present in the container holding
# NET_ADMIN.
stub_launch
launch "$PROJ"
local run; run="$(log_line 'run -d --init')"
[[ "$run" =~ localhost/dejima:[^[:space:]-]+$ ]] \
|| { echo "the filter container's image is not the plain base tag: $run"; return 1; }
}
@test "the filter container declares the network capabilities and drops the rest" {
stub_launch
launch "$PROJ"
local run; run="$(log_line 'run -d --init')"
[[ "$run" == *"--cap-drop ALL"* ]] \
|| { echo "the filter container keeps Docker's default set: $run"; return 1; }
[[ "$run" == *"--security-opt no-new-privileges:true"* ]] \
|| { echo "no-new-privileges is missing from the filter container: $run"; return 1; }
local cap
for cap in NET_ADMIN NET_RAW KILL SETPCAP; do
[[ "$run" == *"--cap-add $cap"* ]] \
|| { echo "the filter container cannot do its job without $cap: $run"; return 1; }
done
}
@test "every variable the firewall path reads is passed to the filter container" {
# The drift guard this arrangement needs most. The sandbox gets its
# environment from docker-compose.yml; the filter container is started with
# `docker run` and gets nothing that is not named on that command line.
#
# So a firewall script that starts reading a new variable does not fail - it
# reads an empty one, and 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 "${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 '^(SANDBOX_TEST.*)$')
[ -z "$missing" ] \
|| { echo "the firewall path reads these, and the filter container is not given them:$missing"; return 1; }
# And the other half of the same failure, which the flag alone cannot show.
# `docker run -e NAME` takes the value from the launcher's environment, so
# a name that is assigned but never exported passes an empty one - the
# filter container then permits less than it was told to, and what an
# operator sees is a domain they approved being unreachable, with nothing
# pointing here.
#
# Not hypothetical: `export DEJIMA_FORGE_HOSTS` is a line that had to be
# added by hand when the filter container was introduced, because until
# then that variable had only ever needed to reach the sandbox as
# `-e NAME=value`, which carries its own value.
#
# Continuations are flattened first, because the exports here run to
# several lines; and `export` is matched anywhere on a line rather than at
# its start, because two of these are exported after a `;` on the line that
# assigns them.
local exported unexported=""
exported="$(sed -z 's/\\\n[[:space:]]*/ /g' "$REPO_ROOT/bin/dejima" \
| grep -oE 'export [A-Za-z_][A-Za-z0-9_ ]*')"
[ -n "$exported" ] || { echo "found no exports in bin/dejima at all"; return 1; }
while read -r v; do
[ -n "$v" ] || continue
grep -qE "(^|[[:space:]])$v([[:space:]]|\$)" <<< "$exported" \
|| unexported="$unexported $v"
done < <(grep -oE '^\s+-e [A-Z_]+' "$REPO_ROOT/bin/dejima" | awk '{print $2}' | sort -u)
[ -z "$unexported" ] \
|| { echo "passed as -e NAME but never exported, so the value is empty:$unexported"; return 1; }
}
@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.
#
# Each of these tests is about a branch that decides NOT to remove something,
# and a reconciler that removed nothing at all would pass every one of them -
# so the first test here is the one that proves the mechanism fires.
stub_orphan() { # <age-seconds> <joined-by|"">
local age="$1" joiner="$2"
stub_launch
# Each query answers in the format Docker really answers it in, and the two
# differ: `Created` is a string field on a container and a time.Time on a
# network, so a template prints the second through String() and appends a
# timezone NAME. One format for both is what let the network half of the
# reconciler pass a test while reaping nothing.
cat > "$LAUNCH_DIR/docker" <<EOF
#!/bin/bash
echo "\$*" >> "\${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
epoch=\$(( \$(date +%s) - $age ))
made=\$(date -u -d "@\$epoch" +%Y-%m-%dT%H:%M:%S.000000000Z)
made_go=\$(date -u -d "@\$epoch" '+%Y-%m-%d %H:%M:%S.000000000 +0000 UTC')
case "\$*" in
# The running containers, and what namespace each is in.
"ps -q") [ -n "$joiner" ] && echo j0 ; exit 0 ;;
*HostConfig.NetworkMode*) [ -n "$joiner" ] && echo "container:$joiner" ; exit 0 ;;
# The candidates.
*"ps -aq --filter label=dejima.filter"*) echo orphan0 ; exit 0 ;;
*"{{.Id}} {{.Name}} {{.Created}}"*) echo "orphan0 /dejima-filter-old \$made" ; exit 0 ;;
*"network ls -q --filter label=dejima.run"*) echo net0 ; exit 0 ;;
*"{{.Name}} {{.Created}}"*) echo "dejima-run-old \$made_go" ; exit 0 ;;
esac
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
}
@test "a filter container nothing is joined to, left over from before, is removed" {
stub_orphan 6000 ""
launch "$PROJ"
grep -q -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log" \
|| { echo "the orphaned filter container was left running"; return 1; }
grep -q -- 'network rm dejima-run-old' "$LAUNCH_DIR/docker-log" \
|| { echo "its network was left behind"; return 1; }
}
@test "the reconcile says what it removed, so a leak is not silently routine" {
stub_orphan 6000 ""
run launch "$PROJ"
[[ "$output" == *"left behind"* ]] \
|| { echo "an orphan was removed with nothing said about it: $output"; return 1; }
}
@test "a filter container with a session still in its namespace is left alone" {
# The one that matters on a host running several sessions: reaping this is
# taking the network out from under somebody's running agent.
stub_orphan 6000 orphan0
launch "$PROJ"
run grep -c -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log"
[ "$output" = "0" ] \
|| { echo "a filter container with a live session was removed"; return 1; }
}
@test "a filter container younger than the grace is left alone" {
# A pair being built right now by a concurrent launch: between `docker run`
# for the filter and `docker compose run` for the session, a healthy filter
# container has no joiner. Age is what tells that apart from abandonment.
stub_orphan 5 ""
launch "$PROJ"
run grep -c -- 'rm -f orphan0' "$LAUNCH_DIR/docker-log"
[ "$output" = "0" ] \
|| { echo "a filter container still being paired was removed"; return 1; }
run grep -c -- 'network rm dejima-run-old' "$LAUNCH_DIR/docker-log"
[ "$output" = "0" ] \
|| { echo "a network still being attached to was removed"; return 1; }
}
@test "a docker that cannot answer the reconcile does not fail the launch" {
# It runs before the launch's own network and container, so an error here
# would refuse a session over tidying up. The launch that follows reports
# its own failures.
stub_launch
cat > "$LAUNCH_DIR/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
case "$*" in
ps*|*inspect*|*network\ ls*) echo "Cannot connect to the Docker daemon" >&2; exit 1 ;;
esac
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
run launch "$PROJ"
[ "$status" -eq 0 ] \
|| { echo "a failing reconcile refused the launch: $output"; return 1; }
grep -q -- ' run --rm ' "$LAUNCH_DIR/docker-log" \
|| { echo "the session never started"; return 1; }
}
@test "a single-label host in a firewall-facing key stops the launch, naming the key" {
# The policy refuses a dotless permitted entry at container start. Meeting
# that as a sandbox that will not boot, on a value every check on the way in
# accepted, is the failure this check exists to move: it happens here, on
# the host, before anything starts, and says which key it came from.
stub_launch
local conf
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
mkdir -p "$(dirname "$conf")"
printf '[logging]\nloki_url = http://loki:3100\n' >> "$conf"
run launch "$PROJ"
[ "$status" -ne 0 ] || { echo "the launch proceeded with a dotless host"; return 1; }
[[ "$output" == *"loki_url"* ]] \
|| { echo "the error does not name the key it came from: $output"; return 1; }
[[ "$output" == *"at least two"* && "$output" == *labels* ]] \
|| { echo "the error does not say what the rule is: $output"; return 1; }
# And nothing was started, rather than started and then torn down.
[ ! -s "$LAUNCH_DIR/docker-log" ] || {
echo "docker was invoked before the check refused:"; cat "$LAUNCH_DIR/docker-log"; return 1; }
}
@test "a derived host that is not a host name is refused, not only a dotless one" {
# The check exists for a derivation that went wrong, and a URL slip does not
# always leave a bare label: userinfo and a trailing dot both survive into
# the derived name, and both have a dot in them. Counting dots would pass
# them here and the container would refuse them at boot instead.
stub_launch
local conf
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
mkdir -p "$(dirname "$conf")"
printf '[logging]\nloki_url = http://user@loki.example.com:3100\n' >> "$conf"
run launch "$PROJ"
[ "$status" -ne 0 ] || { echo "a userinfo URL launched"; return 1; }
[[ "$output" == *"user@loki.example.com"* ]] \
|| { echo "the error does not show the derived host: $output"; return 1; }
[ ! -s "$LAUNCH_DIR/docker-log" ] || {
echo "docker was invoked before the check refused"; return 1; }
}
@test "a fully qualified host in the same key launches" {
# The control for the test above: same key, same shape, one dot more.
stub_launch
local conf
conf="$(bash -c 'source "$1"; config_path' _ "$LIB")"
mkdir -p "$(dirname "$conf")"
printf '[logging]\nloki_url = http://loki.example.com:3100\n' >> "$conf"
run launch "$PROJ"
[ "$status" -eq 0 ] || { echo "a fully qualified loki_url was refused: $output"; return 1; }
}
+222 -23
View File
@@ -31,6 +31,11 @@ if [ "\$1" = ps ]; then
exit 0
fi
case "\$*" in
# A session container shares a filter container's network namespace, and
# that is where the 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 ;;
*dig*) echo 10.0.0.1; exit 0 ;;
*curl*) exit $2 ;;
*) exit 0 ;;
@@ -98,7 +103,8 @@ launch() {
# launch is what triggers it. Its cache needs nothing further: it lands
# under $HOME, which every launch fixture already points somewhere of its
# own.
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n' \
# backend is stated, not defaulted: it has none and never will (#171).
printf '[core]\nharness = claude-code\nupdate_check = off\nssh_key = %s\n[harness.claude-code]\nbackend = anthropic\n' \
"$LAUNCH_DIR/sandbox-key" >> "$conf"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
@@ -120,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".
@@ -147,6 +153,67 @@ github.com'; do
done
}
@test "a domain a project may ask to reach needs two labels, though a host name does not" {
# A permitted name covers everything under it: the resolver is configured
# per name and matches the name and its subdomains, with no exact-name form
# to use instead. So a bare label grants its whole suffix, and `org` asks
# for every .org name. This floor is what keeps one entry from being that
# large - and it is also why the two validators differ, since valid_domain
# gates host-side consumers where a single label is ordinary.
local one
for one in loki gitbox org com localhost a; do
run bash -c 'source "$1"; valid_domain "$2"' _ "$LIB" "$one"
[ "$status" -eq 0 ] \
|| { echo "premise wrong: valid_domain already refuses '$one'"; return 1; }
run bash -c 'source "$1"; valid_permitted_domain "$2"' _ "$LIB" "$one"
[ "$status" -ne 0 ] \
|| { echo "'$one' was accepted as a domain a project may reach"; return 1; }
done
local ok
for ok in crates.io static.crates.io llm-backend.example.com a.b; do
run bash -c 'source "$1"; valid_permitted_domain "$2"' _ "$LIB" "$ok"
[ "$status" -eq 0 ] || { echo "refused a fully qualified name: $ok"; return 1; }
done
# And it still refuses everything valid_domain does, rather than only
# counting dots.
local bad
for bad in 'a..b.com' '-bad.com' 'a_b.com' '' 'github.com.'; do
run bash -c 'source "$1"; valid_permitted_domain "$2"' _ "$LIB" "$bad"
[ "$status" -ne 0 ] || { echo "accepted: $(printf %q "$bad")"; return 1; }
done
# Granting is where it applies. Reading deliberately does not - see the
# test below.
run bash -c 'source "$1"; approve_entry "$2" allowed-domains loki' _ "$LIB" "$BATS_TEST_TMPDIR"
[ "$status" -ne 0 ] || { echo "a single-label domain was granted"; return 1; }
[[ "$output" == *"two labels"* ]] \
|| { echo "the refusal does not say why: $output"; return 1; }
run bash -c 'source "$1"; approve_entry "$2" allowed-domains ok.example.com' _ "$LIB" "$BATS_TEST_TMPDIR"
[ "$status" -eq 0 ] || { echo "a fully qualified domain was refused: $output"; return 1; }
}
@test "an approval granted before the two-label rule is still read, not called corruption" {
# approved_entries validates on every read, and bin/dejima assigns from it
# under set -e on every launch. Applying a rule that tightened afterwards
# there would turn an approval that was valid when it was made into the
# store's tampering message - on every launch, in every project, for an
# operator whose only mistake was upgrading. The last release accepted a
# single-label domain, so that store is the ordinary one.
printf 'crates.io\nloki\n' > "$BATS_TEST_TMPDIR/allowed-domains"
run bash -c 'source "$1"; approved_entries "$2" allowed-domains' _ "$LIB" "$BATS_TEST_TMPDIR"
[ "$status" -eq 0 ] \
|| { echo "a pre-existing approval now fails the read: $output"; return 1; }
[[ "$output" == *loki* ]] \
|| { echo "the entry was dropped rather than read: $output"; return 1; }
# The shape check that guards against real tampering still fires.
printf 'crates.io\na..b.com\n' > "$BATS_TEST_TMPDIR/allowed-domains"
run bash -c 'source "$1"; approved_entries "$2" allowed-domains' _ "$LIB" "$BATS_TEST_TMPDIR"
[ "$status" -ne 0 ] || { echo "a malformed entry was read as valid"; return 1; }
}
@test "valid_domain accepts real domains and rejects shell-active input" {
run bash -c 'source "$1"; valid_domain "crates.io"' _ "$LIB"
[ "$status" -eq 0 ]
@@ -650,6 +717,66 @@ EOF
grep -qx 'crates.io' "$(store_of "$PROJ")/allowed-domains"
}
@test "dejima-allow takes the filter container's id, which is what a session reads" {
# A session asked for its own container id runs `cat /etc/hostname`, and
# Docker names a container that joined another's network namespace after
# the container it joined - so the id an agent hands its operator is the
# FILTER container's. That id has to work, or the one command every
# briefing tells an agent to ask for fails on the id the briefing gave it.
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/bash
echo "$*" >> "${DOCKER_CALL_LOG:-/dev/null}"
case "$*" in
# Named container is a filter: it owns a network rather than joining one.
*inspect*NetworkMode*c1*) echo "dejima-run-abcd" ;;
*inspect*'{{.Id}}'*c1*) echo "filterid" ;;
# And one running container is in its namespace.
"ps -q") echo sessionid ;;
*'{{.Id}} {{.HostConfig.NetworkMode}}'*) echo "sessionid container:filterid" ;;
esac
exit 0
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH" DOCKER_CALL_LOG="$STUB_DIR/calls" \
run "$REPO_ROOT/bin/dejima-allow" --container c1 crates.io
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# Asserted on the calls rather than the messages: which container each
# half runs in is the whole point, and a message could name either while
# the exec went to the wrong one.
grep -q 'exec -u root c1 /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"
}
@test "dejima-allow refuses a container that is neither half of a pair" {
# A launch predating the split has a namespace of its own and no filter
# container in it. The useful answer is what to do instead, not a docker
# exec that fails somewhere inside a heredoc.
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/bash
case "$*" in
*inspect*NetworkMode*) echo "bridge"; exit 0 ;;
*inspect*'{{.Id}}'*) echo "loneid"; exit 0 ;;
"ps -q") exit 0 ;;
esac
exit 0
EOF
chmod +x "$STUB_DIR/docker"
PATH="$STUB_DIR:$PATH" run "$REPO_ROOT/bin/dejima-allow" --container c1 crates.io
[ "$status" -ne 0 ]
[[ "$output" == *"neither a running session nor the filter container"* ]] \
|| { echo "$output"; return 1; }
[[ "$output" == *"dejima-allow --project crates.io"* ]] \
|| { echo "the refusal does not say what to do instead: $output"; return 1; }
rm -rf "$STUB_DIR"
}
@test "dejima-allow --global persists without needing a container" {
run "$REPO_ROOT/bin/dejima-allow" --global example.com
[ "$status" -eq 0 ]
@@ -694,54 +821,126 @@ 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
*dig*@127.0.0.11*) echo 10.0.0.1 ;;
*inspect*NetworkMode*) echo "container:dejima-test-filter"; exit 0 ;;
*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)"
printf '#!/bin/bash\nexit 0\n' > "$STUB_DIR/docker"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/bash
case "$*" in
*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
chmod +x "$STUB_DIR/docker"
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" {
@@ -981,7 +1180,7 @@ ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]" ]]
# no domains at all, or by the env prefix below never reaching the child.
printf 'good.example.com\n' > "$CONFIG_DIR/allowed-domains"
launch_setup
printf '[core]\nharness = claude-code\nssh_key = %s\n' "$LAUNCH_DIR/sandbox-key" >> "$CONFIG_DIR/config"
printf '[core]\nharness = claude-code\nssh_key = %s\n[harness.claude-code]\nbackend = anthropic\n' "$LAUNCH_DIR/sandbox-key" >> "$CONFIG_DIR/config"
HOME="$LAUNCH_DIR/home" PATH="$LAUNCH_DIR:$PATH" \
SANDBOX_TEST_RUN_ENV="$LAUNCH_ENV" \
SSH_AUTH_SOCK= ALLOWED_DOMAINS=evil.example \
+338 -1
View File
@@ -222,6 +222,34 @@ assert_remedy_forges_exist() {
[ -x "$REPO_ROOT/forges/gitea/bin/gitea-issue" ]
}
@test "a sandbox missing its forge token is not told to create one in the container" {
# The token reaches a sandbox as GITEA_TOKEN, read host-side from whatever
# the forge's token_file names. The file these tools fall back to is
# deliberately absent in the container, so a message naming it as the remedy
# sends an agent to write a credential into a disposable filesystem - or, if
# it cannot, to report that it is unable to work when nothing is wrong with
# the arrangement. That happened.
# One subcommand per tool that actually reaches the token check.
local spec t sub
for spec in gitea-pr:create gitea-issue:create gitea-actions:runs; do
t=${spec%%:*}; sub=${spec#*:}
run env -u GITEA_TOKEN DEJIMA=1 GITEA_SSH_HOST=x GITEA_API=http://x/api/v1 \
HOME="$BATS_TEST_TMPDIR/nohome" bash "$REPO_ROOT/forges/gitea/bin/$t" "$sub"
[ "$status" -ne 0 ] || { echo "$t proceeded with no token"; return 1; }
[[ "$output" == *"arrives as GITEA_TOKEN"* ]] \
|| { echo "$t does not say where the token comes from: $output"; return 1; }
[[ "$output" != *"save it to"* ]] \
|| { echo "$t still tells a sandbox to save a token file: $output"; return 1; }
done
# And the host-side message is unchanged, where writing the file IS the
# remedy and nothing exports the variable.
run env -u GITEA_TOKEN -u DEJIMA GITEA_SSH_HOST=x GITEA_API=http://x/api/v1 \
HOME="$BATS_TEST_TMPDIR/nohome" bash "$REPO_ROOT/forges/gitea/bin/gitea-pr" create
[[ "$output" == *"save it to"* ]] \
|| { echo "the host-side remedy is gone: $output"; return 1; }
}
@test "a workflow requiring a capability its forge lacks is refused" {
FAKE_ROOT="$(mktemp -d)"
mkdir -p "$FAKE_ROOT/forges/bare" "$FAKE_ROOT/workflows/needy" "$FAKE_ROOT/harnesses/h"
@@ -1115,7 +1143,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.
@@ -1345,3 +1373,312 @@ assert_remedy_forges_exist() {
done
done
}
# --- the model provider axis (#171) ------------------------------------------
@test "the anthropic backend manifest validates and answers for every field" {
lib manifest_validate backend anthropic
[ "$(lib manifest_get backend anthropic upstream)" = "https://api.anthropic.com" ]
[ "$(lib manifest_get backend anthropic proxied_domain)" = "api.anthropic.com" ]
[ "$(lib manifest_get backend anthropic credential)" = "anthropic-oauth" ]
[ "$(lib manifest_get backend anthropic auth_format)" = "Bearer {token}" ]
[ "$(lib manifest_get backend anthropic strip_headers)" = "x-api-key" ]
[ "$(lib manifest_get backend anthropic ensure_headers)" = "anthropic-beta=oauth-2025-04-20" ]
}
@test "a backend's own keys become [backend.<name>] config keys" {
# The same machinery a forge's keys go through, so a backend declaring
# where its credential lives needs no row hardcoded in core's schema.
[ "$(lib config_type backend.anthropic.credential_file)" = "path" ]
run lib config_type backend.anthropic.not_a_key
[ "$status" -ne 0 ]
}
@test "an auth_format with no token placeholder is refused by the schema" {
# The proxy refuses it too. Refusing it here as well is the difference
# between finding out at launch and finding out from a provider's 401:
# a format without {token} produces a proxy that starts, answers, and
# sends every request unauthenticated.
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/authfmt.XXXXXX")"
mkdir -p "$d/backends/probe"
_probe() {
printf 'upstream = https://x.example\nproxied_domain = x.example\ncredential = static\nauth_format = %s\n' "$1" \
> "$d/backends/probe/backend.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate backend probe' _ "$LIB"
}
_probe 'Bearer'
[ "$status" -ne 0 ] || { echo "a format with no {token} was accepted"; return 1; }
_probe 'Bearer {token}'
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
_probe '{token}'
[ "$status" -eq 0 ] || { echo "a bare-key format was refused: $output"; return 1; }
}
@test "an ensure_headers entry has to be NAME=VALUE with a header-shaped name" {
# envpair would refuse anthropic-beta outright: it wants an upper-case
# environment variable, and a header name is lower case with dashes.
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/ensure.XXXXXX")"
mkdir -p "$d/backends/probe"
_ensure() {
printf 'upstream = https://x.example\nproxied_domain = x.example\ncredential = static\nensure_headers = %s\n' "$1" \
> "$d/backends/probe/backend.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate backend probe' _ "$LIB"
}
local bad
for bad in 'anthropic-beta' '=value' 'name=' 'has space=v'; do
_ensure "$bad"
[ "$status" -ne 0 ] || { echo "accepted ensure_headers = $bad"; return 1; }
done
_ensure 'anthropic-beta=oauth-2025-04-20'
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
}
@test "a backend name that is not a directory is refused by the config type" {
# The same provider:<axis> check core.forge and core.harness go through,
# so a typo names nothing rather than resolving somewhere.
run lib _config_valid_value provider:backend anthropic
[ "$status" -eq 0 ] || { echo "the shipped provider was refused: $output"; return 1; }
run lib _config_valid_value provider:backend no-such-provider
[ "$status" -ne 0 ] || { echo "a provider with no directory was accepted"; return 1; }
run lib _config_valid_value provider:backend ../etc
[ "$status" -ne 0 ] || { echo "a traversal was accepted"; return 1; }
}
@test "the backend is selected per harness, not globally" {
# An operator running Claude Code against Anthropic and Cline against
# something else is the ordinary case, and it is why this is not one
# [credentials] key. Each harness declares its own, with its own default.
[ "$(lib config_type harness.claude-code.backend)" = "?provider:backend" ]
[ "$(lib config_type harness.cline.backend)" = "?provider:backend" ]
# And NO default, on either. One that could change later would move a
# session to another endpoint without its config changing, which is the
# reason [core] harness has none either.
[ -z "$(lib config_default harness.claude-code.backend)" ] \
|| { echo "claude-code ships a default backend"; return 1; }
[ -z "$(lib config_default harness.cline.backend)" ] \
|| { echo "cline ships a default backend"; return 1; }
# And there is no global one to disagree with them.
run lib config_type credentials.backend
[ "$status" -ne 0 ] || { echo "a global backend key is back"; return 1; }
}
@test "a harness that declares no backend names no endpoint to proxy" {
# The self-served case: a model on the operator's own network needs no
# credential withheld because there is none, so a harness may decline to
# name a provider and the launch runs without a proxy.
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/noprov.XXXXXX")"
mkdir -p "$d/harnesses/bare"
printf 'command = true\n' > "$d/harnesses/bare/harness.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; config_type harness.bare.backend' _ "$LIB"
[ "$status" -ne 0 ] || { echo "a harness that declares nothing got a provider key"; return 1; }
}
@test "openrouter is declared as a static-key, openai-style backend" {
# The second shipped provider, and the case #171 was filed about: a hosted
# endpoint behind a static API key, which dejima previously had no answer
# for other than refusing it.
lib manifest_validate backend openrouter
[ "$(lib manifest_get backend openrouter upstream)" = "https://openrouter.ai/api/v1" ]
[ "$(lib manifest_get backend openrouter proxied_domain)" = "openrouter.ai" ]
[ "$(lib manifest_get backend openrouter api_style)" = "openai" ]
[ "$(lib manifest_get backend openrouter credential)" = "static" ]
# Both spellings an OpenAI-compatible client might use for a key, so a
# placeholder cannot travel beside the injected Authorization.
[ "$(lib manifest_get backend openrouter strip_headers)" = "x-api-key api-key" ]
# No default for the key's location and no assumed value: an unset one is
# a refusal at launch, not a guess at a path.
[ "$(lib manifest_get backend openrouter requires)" = "credential_file" ]
[ -z "$(lib config_default backend.openrouter.credential_file)" ]
# proxied_domain is the HOST alone. upstream carries the path; a
# certificate and a DNS pin are about a name.
[[ "$(lib manifest_get backend openrouter proxied_domain)" != */* ]]
}
@test "a self-served endpoint is a backend like any other" {
# Where the model is belongs to the provider, including when the operator
# serves it. That is what lets a local endpoint stay configured while a
# hosted API is selected: it is [backend.self-hosted] base_url, not a
# setting on the harness that has to be unset to get out of the way.
lib manifest_validate backend self-hosted
[ "$(lib manifest_get backend self-hosted credential)" = "none" ]
[ "$(lib manifest_get backend self-hosted api_style)" = "openai" ]
[ "$(lib manifest_get backend self-hosted requires)" = "base_url" ]
[ "$(lib config_type backend.self-hosted.base_url)" = "?url" ]
[ -z "$(lib config_default backend.self-hosted.base_url)" ] \
|| { echo "self-hosted ships a default endpoint"; return 1; }
# upstream and proxied_domain describe an endpoint dejima stands in front
# of, and it stands in front of nothing here.
[ -z "$(lib manifest_get backend self-hosted upstream)" ]
[ -z "$(lib manifest_get backend self-hosted proxied_domain)" ]
# And the harness no longer carries one of its own to disagree with it.
run lib config_type harness.cline.base_url
[ "$status" -ne 0 ] || { echo "cline still declares base_url"; return 1; }
}
@test "a harness and a backend have to speak the same API" {
# Anthropic's Messages API is not OpenAI-compatible. Pairing a harness
# with a provider it has no client for is knowable at launch and produces
# nothing but confusion later, so both sides declare what they speak.
[ "$(lib manifest_get backend anthropic api_style)" = "anthropic" ]
[ "$(lib manifest_get harness claude-code api_styles)" = "anthropic" ]
# cline is the provider-agnostic one and says so.
[ "$(lib manifest_get harness cline api_styles)" = "anthropic openai" ]
# A dialect outside the two is refused by the schema. An ABSENT one is not
# - manifest_validate only type-checks the keys a file actually has, which
# is true of every axis and not something this one changed - so bin/dejima
# names the four a provider cannot work without where it reads them. That
# refusal is covered in config.bats, at the launch.
local d; d="$(mktemp -d "$BATS_TEST_TMPDIR/style.XXXXXX")"
mkdir -p "$d/backends/probe"
printf 'upstream = https://x.example\nproxied_domain = x.example\ncredential = static\napi_style = sideways\n' \
> "$d/backends/probe/backend.conf"
run env DEJIMA_PROVIDER_ROOT="$d" \
bash -c 'source "$1"; manifest_validate backend probe' _ "$LIB"
[ "$status" -ne 0 ] || { echo "an unknown api_style validated"; return 1; }
}
@test "the backend/harness matrix in the docs agrees with the manifests" {
# A compatibility table is derived data: every cell is the provider's
# api_style against the harness's api_styles. Written by hand it goes stale
# the first time a provider is added, and a stale one is worse than none -
# a reader trusts it and dejima refuses the launch.
#
# So it is recomputed here and compared cell by cell, rather than spot
# -checked. A new backend or harness that nobody adds a row for fails
# this, which is the point.
# Both copies, because the README carries a shorter one and two copies of
# derived data is two things to drift. Checked against the manifests
# rather than against each other, so neither can be right by agreeing with
# a wrong one.
local doc rows
for doc in "$REPO_ROOT/docs/providers.md" "$REPO_ROOT/README.md"; do
rows="$(sed -n '/^| Backend | `claude-code` | `cline` |/,/^$/p' "$doc")"
[ -n "$rows" ] || { echo "the matrix is gone from $doc, or its header changed"; return 1; }
local p h styles want got row
for p in $(lib provider_list backend); do
row="$(grep -E "^\| \`$p\` \|" <<< "$rows")" \
|| { echo "no row for backend '$p'"; return 1; }
# Column count first. A row with a doubled separator still answers
# the cells this asks for by index, so without this a malformed row
# passes - which is how one got in.
local want_cols cols
want_cols="$(awk -F'|' 'NR==1 {print NF}' <<< "$rows")"
cols="$(awk -F'|' '{print NF}' <<< "$row")"
[ "$cols" = "$want_cols" ] \
|| { echo "$p's row has $cols columns, the header has $want_cols"; return 1; }
local i=0
for h in claude-code cline; do
i=$((i + 1))
styles="$(lib manifest_get harness "$h" api_styles)"
if [[ " $styles " == *" $(lib manifest_get backend "$p" api_style) "* ]]; then
want=yes
else
want=no
fi
got="$(awk -F'|' -v n=$((i + 2)) '{gsub(/^ +| +$/,"",$n); print $n}' <<< "$row")"
[ "$got" = "$want" ] \
|| { echo "$p x $h: the table says '$got', the manifests say '$want'"; return 1; }
done
done
# And every shipped harness has a column, or the table is answering for
# fewer pairs than exist.
for h in $(lib provider_list harness); do
grep -q "\`$h\`" <<< "$rows" || { echo "no column for harness '$h' in $doc"; return 1; }
done
done
}
@test "an Anthropic API key is a backend, so it stays on the host too" {
# The setup most organisations use: a console key on API billing, rather
# than a subscription login. Without a backend to declare it, dejima has
# no claudeAiOauth block to protect, skips the proxy, and mounts the
# operator's real key into the container - so the common arrangement
# would be the one where a long-lived, account-wide credential sits where
# a compromised session can read it.
lib manifest_validate backend anthropic-api-key
[ "$(lib manifest_get backend anthropic-api-key credential)" = "static" ]
[ "$(lib manifest_get backend anthropic-api-key upstream)" = "https://api.anthropic.com" ]
[ "$(lib manifest_get backend anthropic-api-key api_style)" = "anthropic" ]
# Anthropic's own header, bare - no scheme prefix. That pair is the whole
# difference from the subscription backend: a token is a Bearer, a key is
# x-api-key.
[ "$(lib manifest_get backend anthropic-api-key auth_header)" = "x-api-key" ]
[ "$(lib manifest_get backend anthropic-api-key auth_format)" = "{token}" ]
[ "$(lib manifest_get backend anthropic auth_header)" = "Authorization" ]
# And the other slot stripped, so a placeholder cannot travel beside the
# injected key and be the one the API reads.
[ "$(lib manifest_get backend anthropic-api-key strip_headers)" = "authorization" ]
[ "$(lib manifest_get backend anthropic-api-key requires)" = "credential_file" ]
}
@test "one API-billing backend serves both places the key can live" {
# Claude Code's console login keeps its key as primaryApiKey in
# ~/.claude.json; a key you hold yourself is a file that is nothing but
# the key. Same endpoint, same header, same static credential - one
# backend, with WHERE the key sits left to the operator.
lib manifest_validate backend anthropic-api-key
[ "$(lib manifest_get backend anthropic-api-key credential)" = "static" ]
[ "$(lib manifest_get backend anthropic-api-key auth_header)" = "x-api-key" ]
[ "$(lib manifest_get backend anthropic-api-key auth_format)" = "{token}" ]
[ "$(lib manifest_get backend anthropic-api-key strip_headers)" = "authorization" ]
# Defaulted to the console login, so the common case needs no config.
[ "$(lib config_default backend.anthropic-api-key.credential_file)" = "~/.claude.json" ]
[ "$(lib config_default backend.anthropic-api-key.credential_field)" = "primaryApiKey" ]
# And clearable, which is what serves a bare key file.
[ "$(lib config_type backend.anthropic-api-key.credential_field)" = "?jsonfield" ]
# Two backends for Anthropic, not three: the split is the wire
# arrangement, not where a file happens to be.
run lib provider_dir backend anthropic-console
[ "$status" -ne 0 ] || { echo "anthropic-console is back"; return 1; }
[ "$(lib manifest_get backend anthropic credential)" = "anthropic-oauth" ]
}
@test "the endpoint is identical across the anthropic backends" {
# Three values describe Anthropic's API rather than any credential, so
# they must not drift between the two backends that reach it. This is what
# the duplication costs, and it is cheaper to test than to deduplicate.
local field a b
for field in upstream proxied_domain api_style; do
a="$(lib manifest_get backend anthropic "$field")"
b="$(lib manifest_get backend anthropic-api-key "$field")"
[ -n "$a" ] || { echo "anthropic declares no $field"; return 1; }
[ "$a" = "$b" ] \
|| { echo "$field differs: anthropic=$a anthropic-api-key=$b"; return 1; }
done
}
@test "a credential_field is dot-separated names, not an expression" {
# The proxy walks these segments itself, so a value that could name an
# expression would be one that could name a computation. It is a config
# type now, because where a key sits in a file is the operator's.
local good bad
for good in primaryApiKey a.b.c _x1; do
lib _config_valid_value jsonfield "$good" \
|| { echo "refused '$good'"; return 1; }
done
for bad in '.leading' 'trailing.' 'a..b' 'a[0]' 'a|keys' '$env' 'a b' '1abc'; do
run lib _config_valid_value jsonfield "$bad"
[ "$status" -ne 0 ] || { echo "accepted '$bad'"; return 1; }
done
}
@test "every backend section takes a proxy key, whatever its manifest says" {
# Whether to withhold a credential is dejima's question, not the backend
# author's, so core appends it rather than each manifest declaring it - a
# backend that forgot would silently lose the setting.
local b
for b in $(lib provider_list backend); do
[ "$(lib config_type "backend.$b.proxy")" = "?bool" ] \
|| { echo "backend '$b' has no proxy key"; return 1; }
done
# No default, because unset is a third state: inherit [credentials] proxy.
# A default of either value would decide for every backend at once.
[ -z "$(lib config_default backend.anthropic.proxy)" ] \
|| { echo "the per-backend proxy key ships a default"; return 1; }
}
+415 -18
View File
@@ -7,7 +7,7 @@ PLACEHOLDER="sandbox-credential-proxy-placeholder"
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
PROXY="$REPO_ROOT/bin/dejima-anthropic-proxy"
PROXY="$REPO_ROOT/bin/dejima-credential-proxy"
TD="$(mktemp -d)"
SOCK="$TD/proxy.sock"
CREDS="$TD/credentials.json"
@@ -27,10 +27,21 @@ EOF
chmod 600 "$CREDS"
}
# The Anthropic arrangement, spelled the way bin/dejima spells it. The proxy
# itself now defaults none of this (#171), so a test that omitted a flag would
# be exercising a configuration no launch produces - which is why these are
# here in full rather than left to defaults, and why static.bats pins that
# bin/dejima still passes the same set.
start_proxy() {
"$PROXY" --socket "$SOCK" --credentials "$CREDS" \
--upstream "http://127.0.0.1:$PORT" \
--oauth-endpoint "http://127.0.0.1:$PORT/v1/oauth/token" "$@" 2>"$TD/stderr" &
--credential-kind anthropic-oauth \
--oauth-endpoint "http://127.0.0.1:$PORT/v1/oauth/token" \
--oauth-client-id "9d1c250a-e61b-44d9-88ed-5944d1962f5e" \
--auth-header Authorization \
--auth-format "Bearer {token}" \
--strip-header x-api-key \
--ensure-header "anthropic-beta=oauth-2025-04-20" "$@" 2>"$TD/stderr" &
PROXY_PID=$!
for _ in $(seq 1 50); do [ -S "$SOCK" ] && break; sleep 0.1; done
[ -S "$SOCK" ]
@@ -268,17 +279,22 @@ teardown() {
@test "an already-expired refresh token is fatal at startup, before the socket exists" {
write_creds 7200
sed -i 's/"refreshTokenExpiresAt":[0-9]*/"refreshTokenExpiresAt":1000/' "$CREDS"
run "$PROXY" --socket "$SOCK" --credentials "$CREDS" --upstream "http://127.0.0.1:$PORT"
[ "$status" -ne 0 ]
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$CREDS" \
--upstream "http://127.0.0.1:$PORT" --credential-kind anthropic-oauth \
--oauth-endpoint "http://127.0.0.1:$PORT/v1/oauth/token" \
--oauth-client-id probe
# 1, not just nonzero: an argument mistake exits 2, and this test would
# otherwise pass on one while proving nothing about the expiry check.
[ "$status" -eq 1 ]
[[ "$output" == *"log in"* ]]
[ ! -S "$SOCK" ]
}
@test "forwarder bridges TCP to the proxy's unix socket" {
start_proxy
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_PROXY_PORT=18399 \
SANDBOX_ANTHROPIC_TRANSPORT=loopback \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js" &
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT=18399 \
SANDBOX_CREDENTIAL_TRANSPORT=loopback \
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" &
FWD_PID=$!
ok=""
for _ in $(seq 1 50); do
@@ -342,12 +358,12 @@ PYEOF
# ANTHROPIC_BASE_URL at all, then pipes raw bytes into the same unix socket
# the loopback mode uses - the host proxy cannot tell the two apart.
start_forwarder() { # start_forwarder <transport> <port>; extra env passes through
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" \
SANDBOX_ANTHROPIC_TRANSPORT="$1" \
SANDBOX_ANTHROPIC_PROXY_PORT="$2" \
SANDBOX_ANTHROPIC_TLS_CERT="$TD/cert.pem" \
SANDBOX_ANTHROPIC_TLS_KEY="$TD/key.pem" \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js" 2>"$TD/fwd-stderr" &
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" \
SANDBOX_CREDENTIAL_TRANSPORT="$1" \
SANDBOX_CREDENTIAL_PROXY_PORT="$2" \
SANDBOX_CREDENTIAL_TLS_CERT="$TD/cert.pem" \
SANDBOX_CREDENTIAL_TLS_KEY="$TD/key.pem" \
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" 2>"$TD/fwd-stderr" &
FWD_PID=$!
for _ in $(seq 1 50); do
(exec 3<>"/dev/tcp/127.0.0.1/$2") 2>/dev/null && return 0
@@ -419,12 +435,12 @@ make_cert() {
#
# The port override is belt-and-braces: the cert check precedes the listen
# today, so a fall-through lands on an unused port if that ever changes.
run env -u SANDBOX_ANTHROPIC_TLS_CERT -u SANDBOX_ANTHROPIC_TLS_KEY \
SANDBOX_ANTHROPIC_PROXY_SOCKET="$SOCK" SANDBOX_ANTHROPIC_TRANSPORT=tls \
SANDBOX_ANTHROPIC_PROXY_PORT=18398 \
node "$REPO_ROOT/docker/anthropic-proxy-forwarder.js"
run env -u SANDBOX_CREDENTIAL_TLS_CERT -u SANDBOX_CREDENTIAL_TLS_KEY \
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_TRANSPORT=tls \
SANDBOX_CREDENTIAL_PROXY_PORT=18398 \
node "$REPO_ROOT/docker/credential-proxy-forwarder.js"
[ "$status" -ne 0 ]
[[ "$output" == *"SANDBOX_ANTHROPIC_TLS_CERT"* ]]
[[ "$output" == *"SANDBOX_CREDENTIAL_TLS_CERT"* ]]
}
@test "an oversized request body is refused rather than read into host RAM" {
@@ -464,3 +480,384 @@ make_cert() {
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "200" ] || { echo "a 2MiB body was refused with $output"; return 1; }
}
# --- backend-generic behaviour (#171) ----------------------------------------
#
# The proxy knows no backend's name: upstream, credential kind, auth
# header and shape, what to strip and what to ensure are all arguments. These
# exercise the second kind - a static key, which is what an OpenAI-compatible
# hosted backend has - and the parts of the shape a backend can move.
# A static-key proxy against the same fake upstream. $1.. are extra flags.
# Startup refusals run the proxy in the FOREGROUND and expect it to exit, so
# every one of them is bounded by `timeout`. Without it, a check that stopped
# refusing does not fail the test - the proxy starts, serves forever, and the
# test hangs until the whole suite is killed, which reports as no result rather
# than as a wrong one. Found by mutating two of these and watching them time
# out instead of going red.
start_static() {
printf 'sk-test-static-key\n' > "$TD/key"
chmod 600 "$TD/key"
"$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" \
--credential-kind static "$@" 2>"$TD/stderr" &
PROXY_PID=$!
for _ in $(seq 1 50); do [ -S "$SOCK" ] && break; sleep 0.1; done
[ -S "$SOCK" ]
}
@test "a static key is injected, and the placeholder never reaches upstream" {
start_static
run pcurl -d '{"hello":1}' http://proxy/v1/chat/completions
[ "$status" -eq 0 ]
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer sk-test-static-key" ]
[[ "$output" != *"$PLACEHOLDER"* ]]
[ "$(jq -r '.body' <<<"$output")" = '{"hello":1}' ]
}
@test "a static key file's trailing newline does not reach the header" {
# Written by an editor or a shell redirection as often as by a tool. A
# newline in a header value is not cosmetic: it would be rejected or split
# the block.
start_static
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer sk-test-static-key" ]
}
@test "a static credential is never refreshed, so a 401 is reported once" {
# Refreshing needs somewhere to refresh against, and a static key has
# none. Retrying would send the same bytes and turn one rejection into
# two, which reads as a flapping upstream rather than a wrong key.
# The same route the OAuth retry test uses: 401 on the first call, echo
# after. An OAuth credential refreshes and the second call succeeds; a
# static one has nowhere to refresh against, so the 401 is what the
# session sees.
start_static
run pcurl -o /dev/null -w '%{http_code}' -d '{}' http://proxy/echo-401-once
[ "$output" = "401" ]
# And no refresh was attempted anywhere, which the fake counts for us.
[ "$(curl -s "http://127.0.0.1:$PORT/oauth-hits")" = "0" ]
}
@test "the auth header and its format are the backend's, not Bearer by fiat" {
# A backend that wants its key in a header of its own, unprefixed, needs
# no code here. Asserted together because the pair is the shape.
start_static --auth-header x-goog-api-key --auth-format '{token}'
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers["x-goog-api-key"]' <<<"$output")" = "sk-test-static-key" ]
# And the client's own Authorization placeholder is left alone rather than
# silently rewritten: it is not this backend's auth header. Stripping it
# is a separate declaration, which the next test covers.
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer $PLACEHOLDER" ]
}
@test "a header named in strip-header does not travel beside the injected one" {
start_static --auth-header x-goog-api-key --auth-format '{token}' \
--strip-header authorization
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers.authorization // "absent"' <<<"$output")" = "absent" ]
[ "$(jq -r '.headers["x-goog-api-key"]' <<<"$output")" = "sk-test-static-key" ]
}
@test "the auth header cannot be stripped out from under itself" {
# Naming it in both places is a backend's mistake, and the safe reading
# is that the credential still goes: dropping it would send an
# unauthenticated request and report the backend's 401 as the fault.
#
# Position as well as presence, because presence alone does not
# distinguish the two paths. A stripped-then-re-added header arrives at
# the END of the block from the supplied-names pass; a replaced one keeps
# the slot the client gave it. Both send the credential, and only one of
# them leaves the request looking like the client wrote it - which is the
# property the whole header-emission dance exists for.
start_static --strip-header authorization
run pcurl -H "Content-Type: application/json" -H "X-Stainless-Lang: js" \
-d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer sk-test-static-key" ]
local auth_at type_at
auth_at="$(jq -r '[.header_lines[][0] | ascii_downcase] | index("authorization")' <<<"$output")"
type_at="$(jq -r '[.header_lines[][0] | ascii_downcase] | index("content-type")' <<<"$output")"
[ "$auth_at" -lt "$type_at" ] \
|| { echo "Authorization moved to position $auth_at, after Content-Type at $type_at"; return 1; }
}
@test "no ensure-header means none is invented" {
# anthropic-beta is one backend's requirement. A proxy that added it
# anyway would put an Anthropic tell on every other backend's wire.
start_static
run pcurl -d '{}' http://proxy/v1/chat/completions
[ "$(jq -r '.headers["anthropic-beta"] // "absent"' <<<"$output")" = "absent" ]
}
@test "an unusable static key is fatal at startup, before the socket exists" {
: > "$TD/key"
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" --credential-kind static
[ "$status" -eq 1 ]
[[ "$output" == *"empty"* ]]
[ ! -S "$SOCK" ]
}
@test "a key carrying a control character is refused rather than sanitised" {
# A key that needs sanitising was pasted wrong. Trimming it would send
# mangled bytes and report the backend's rejection as the fault.
printf 'sk-good\x1bbad\n' > "$TD/key"
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" --credential-kind static
[ "$status" -eq 1 ]
[[ "$output" == *"control character"* ]]
[ ! -S "$SOCK" ]
}
@test "an auth-format with no token placeholder is refused" {
# It would start, answer requests, and send every one of them without a
# credential. Refusing at parse time is the difference between a
# configuration error and a silently unauthenticated session.
printf 'k\n' > "$TD/key"
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$TD/key" \
--upstream "http://127.0.0.1:$PORT" --credential-kind static \
--auth-format 'Bearer'
[ "$status" -eq 2 ]
[[ "$output" == *"{token}"* ]]
[ ! -S "$SOCK" ]
}
@test "anthropic-oauth without its endpoint or client id is refused" {
write_creds 7200
run timeout 5 "$PROXY" --socket "$SOCK" --credentials "$CREDS" \
--upstream "http://127.0.0.1:$PORT" --credential-kind anthropic-oauth
[ "$status" -eq 2 ]
[[ "$output" == *"oauth-endpoint"* ]]
[ ! -S "$SOCK" ]
}
# The proxy is the only thing that sees both the request the session made and
# the answer the API gave. It forwarded a rejection verbatim and recorded
# nothing, so a session that spun and then errored left the operator with
# nothing to act on - reported from a real cline launch against the anthropic
# backend.
@test "an upstream rejection is recorded, and still reaches the session whole" {
start_proxy --log "$TD/proxy.log"
run pcurl -i "http://localhost/status-503"
[ "$status" -eq 0 ]
# /status-503 answers without a Content-Length. The first version read the
# body only where upstream declared a small one, and the first real
# rejection it met - a rate limiter - recorded the status and nothing
# else, which is the half an operator cannot act on.
# Byte for byte: what the session sees is what the API said, not the
# proxy's summary of it.
[[ "$output" == *"503"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"upstream-error"* ]] \
|| { echo "the body did not reach the client: $output"; return 1; }
[ -s "$TD/proxy.log" ] || { echo "nothing was recorded"; return 1; }
grep -q '503' "$TD/proxy.log" || { echo "$(cat "$TD/proxy.log")"; return 1; }
grep -q 'upstream-error' "$TD/proxy.log" \
|| { echo "recorded the status without the reason: $(cat "$TD/proxy.log")"; return 1; }
}
@test "a request the API accepted records nothing at all" {
# The success path must stay silent, and must not read the body to decide:
# logging every request would put the operator's traffic in a file and
# would buffer a streaming response to do it.
start_proxy --log "$TD/proxy.log"
run pcurl "http://localhost/v1/messages" -d '{"model":"x"}'
[ "$status" -eq 0 ]
[ ! -s "$TD/proxy.log" ] \
|| { echo "a successful request was logged: $(cat "$TD/proxy.log")"; return 1; }
}
@test "with no log file the diagnostics still go somewhere" {
# A proxy run by hand has no file to read afterwards, so stderr is right
# there. Losing the message because no --log was passed would make the
# by-hand case the one with no diagnostics.
start_proxy
run pcurl "http://localhost/status-503"
[ "$status" -eq 0 ]
grep -q '503' "$TD/stderr" \
|| { echo "nothing on stderr: $(cat "$TD/stderr")"; return 1; }
}
@test "a rate limiter's retry-after is named, not left among the headers" {
# Three 429s four seconds apart is what a real launch produced, and the
# actionable part of one is not in the body.
start_proxy --log "$TD/proxy.log"
run pcurl -i "http://localhost/status-429"
[ "$status" -eq 0 ]
grep -q 'retry-after=41' "$TD/proxy.log" \
|| { echo "$(cat "$TD/proxy.log")"; return 1; }
# The identifier Anthropic support asks for. A real rate-limited call came
# back with a body of {"message":"Error"} - the word Error and nothing
# else - so on that response this is the only actionable thing there is.
grep -q 'request-id=req_011CehAZUPXFbQGikXfM3Mp6' "$TD/proxy.log" \
|| { echo "no request id: $(cat "$TD/proxy.log")"; return 1; }
# And not the ids that name the operator's org. This line gets pasted
# into bug reports.
grep -qi 'workspace\|organization' "$TD/proxy.log" \
&& { echo "an account identifier reached the log"; return 1; }
grep -q 'rate_limit_error' "$TD/proxy.log" \
|| { echo "the body did not reach the log: $(cat "$TD/proxy.log")"; return 1; }
# And the client still gets both.
[[ "$output" == *"retry-after: 41"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"rate_limit_error"* ]] || { echo "$output"; return 1; }
}
@test "a compressed rejection is logged as text, not as bytes" {
# The client's own Accept-Encoding is forwarded verbatim, so a harness
# that asks for gzip gets a compressed error body - and the first version
# of this logging wrote those bytes out, which reached the operator as
# mojibake and a bash warning about a null byte in a command
# substitution. Reported from a real launch.
start_proxy --log "$TD/proxy.log"
run pcurl -i --compressed "http://localhost/status-429-gzip"
[ "$status" -eq 0 ]
grep -q 'slow down' "$TD/proxy.log" \
|| { echo "the message did not survive decompression: $(cat "$TD/proxy.log")"; return 1; }
# Nothing unprintable reaches the file at all. This is the half that has
# to hold whatever the encoding: upstream text landing on the operator's
# terminal is what dejima-audit already refuses to do unescaped.
[ -z "$(tr -d '\11\12\15\40-\176' < "$TD/proxy.log")" ] \
|| { echo "unprintable bytes reached the log"; od -c "$TD/proxy.log" | head -3; return 1; }
# And the client still received the compressed bytes untouched.
[[ "$output" == *"slow down"* ]] || { echo "$output"; return 1; }
}
@test "an encoding the stdlib cannot read says so rather than printing bytes" {
# br and zstd have no stdlib decoder. Naming the encoding is more use than
# the bytes, and the bytes are the thing this exists to keep off a
# terminal.
run python3 -c '
import sys; sys.path.insert(0, "'"$REPO_ROOT"'/bin")
import importlib.util, importlib.machinery
# The proxy has no .py suffix, so the loader has to be named rather than
# inferred from the extension.
loader = importlib.machinery.SourceFileLoader("p", "'"$REPO_ROOT"'/bin/dejima-credential-proxy")
spec = importlib.util.spec_from_loader("p", loader)
m = importlib.util.module_from_spec(spec); loader.exec_module(m)
print(m._readable(b"\x1f\x8b\x00\x01\x02", "br"))
print(m._readable(b"\x00\x01\x02not gzip at all", "gzip"))
print(m._readable(b"plain \x00\x07text", ""))
'
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"br-encoded"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"undecodable"* ]] || { echo "$output"; return 1; }
# Control characters become spaces rather than being dropped, so a
# message is not silently re-joined into a different one. The exact run
# length is not the point; that none of them survives is.
[[ "$output" == *"plain"*"text"* ]] || { echo "$output"; return 1; }
}
@test "brotli is read when the host can, and named when it cannot" {
# What a Node client actually negotiates. gzip was the first guess and
# the real one came back br - so the encoding this could not read was the
# common case, not an edge.
#
# Optional on purpose: this runs on the host as the operator, and a
# diagnostic is not worth a hard dependency. So the test asserts whichever
# half this host can demonstrate, and the note has to name the fix.
run python3 -c '
import sys, importlib.util, importlib.machinery
l = importlib.machinery.SourceFileLoader("p", "'"$REPO_ROOT"'/bin/dejima-credential-proxy")
m = importlib.util.module_from_spec(importlib.util.spec_from_loader("p", l)); l.exec_module(m)
try:
import brotli as b
except ImportError:
try:
import brotlicffi as b
except ImportError:
b = None
if b is None:
print("ABSENT " + m._readable(b"\x1b\x0e\x00\xf8\x25", "br"))
else:
print("PRESENT " + m._readable(b.compress(b"slow down"), "br"))
'
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
case "$output" in
PRESENT*) [[ "$output" == *"slow down"* ]] \
|| { echo "a decoder was available and the message did not survive: $output"; return 1; } ;;
ABSENT*) [[ "$output" == *"br-encoded"* ]] \
|| { echo "$output"; return 1; }
# Naming the encoding is not enough on its own: the operator
# is one install away from the message, and the line has to
# say so.
[[ "$output" == *"brotli"* ]] \
|| { echo "the note does not say what would fix it: $output"; return 1; } ;;
*) echo "unexpected: $output"; return 1 ;;
esac
# Either way, no bytes.
[ -z "$(printf '%s' "$output" | tr -d '\11\12\15\40-\176')" ] \
|| { echo "unprintable output survived"; return 1; }
}
@test "nothing unprintable reaches the diagnostics file, whatever wrote it" {
# Asserted at the sink rather than on one producer. bin/dejima prints this
# file to the operator's terminal after a session ends, so it carries text
# an upstream API chose and - through the request path and exception
# messages - text the session chose. Filtering it where it is written
# covers a producer added later; filtering it in one formatter does not.
#
# CPython refuses a control character in a URL today, in two places, and
# reprs what it refuses. That is a coincidence this should not rest on.
run python3 -c '
import importlib.util, importlib.machinery, tempfile, os
l = importlib.machinery.SourceFileLoader("p", "'"$REPO_ROOT"'/bin/dejima-credential-proxy")
m = importlib.util.module_from_spec(importlib.util.spec_from_loader("p", l)); l.exec_module(m)
d = tempfile.mkdtemp(); f = os.path.join(d, "log")
m._diag(f, "path=/v1/\x1b[2J\x1b[1;31mred\x1b[0m\x07 null=\x00 tab=\t")
raw = open(f, "rb").read()
print("BYTES", sum(1 for b in raw if b < 32 and b != 10))
print(raw.decode().strip())
'
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"BYTES 0"* ]] \
|| { echo "control bytes reached the file: $output"; return 1; }
# And the text around them survives, so a sanitised line is still a
# readable one rather than a row of spaces.
[[ "$output" == *"path=/v1/"* ]] && [[ "$output" == *"red"* ]] \
|| { echo "$output"; return 1; }
}
@test "a refresh the provider rejects names the login that fixes it, in the session" {
# What an overnight session meets in the morning: the access token has
# expired, the refresh token with it, and the provider answers the refresh
# with invalid_grant. That is not transient and no amount of retrying or
# waiting improves it - the operator has to log in on the host.
#
# The session is where the failure is met and the host terminal is behind a
# window the agent cannot read, so "see host terminal" costs a trip to find
# one sentence. This message is written in the proxy rather than taken from
# the provider's response, which is what makes it safe to send.
write_creds -60
start_proxy
curl -s "http://127.0.0.1:$PORT/oauth-reject" >/dev/null
run pcurl -o /dev/null -w '%{http_code}' -X POST --data '{}' http://x/v1/messages
[ "$output" = 502 ] || { echo "status was $output"; return 1; }
run pcurl -X POST --data '{}' http://x/v1/messages
[[ "$output" == *"log in again"* ]] \
|| { echo "the session was not told how to fix it: $output"; return 1; }
[[ "$output" == *"on the host"* ]] \
|| { echo "the session was not told where to fix it: $output"; return 1; }
# The provider's own bytes stay on the host, which is the rule this is
# working within rather than around.
[[ "$output" != *"invalid_grant"* ]] \
|| { echo "an upstream response body reached the session: $output"; return 1; }
grep -q 'invalid_grant' "$TD/stderr" \
|| { echo "the host log lost the detail:"; cat "$TD/stderr"; return 1; }
# And the host log carries the remedy as well as the detail. The exit audit
# prints the last line of this file when a session ends, so an operator who
# was away while it failed reads it there - which is where this was found
# wanting: it showed the provider's error code and nothing to do about it.
# The LAST line specifically. bin/dejima's exit audit prints that one when a
# session ends, so it is what an operator who was away while it failed reads
# without going looking - and it was the provider's error code with no
# remedy in it, which is what sent one looking in the first place.
run tail -n 1 "$TD/stderr"
[[ "$output" == *"log in again"* ]] \
|| { echo "the last host line is not the actionable one: $output"; return 1; }
[[ "$output" != *"HTTP 400"* ]] \
|| { echo "the last host line still leads with the provider's answer: $output"; return 1; }
}
+101 -9
View File
@@ -10,13 +10,43 @@
set -uo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
base="${1:-main}"
# The remote-tracking ref by default, not the local branch. Everything this
# prints is derived from base..head - the manual checks from which paths the
# diff touches, the rollback list from which config keys it adds - so a base
# behind the real one silently reports a different release. That is not
# hypothetical: two passes ran against a local `main` 260 commits behind
# origin/main and reported keys and a trust-anchor domain that had shipped
# releases earlier. A gate answering confidently about the wrong diff is worse
# than one that does not run.
base="${1:-}"
if [ -z "$base" ]; then
if git rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
base=origin/main
else
base=main
fi
fi
head="${2:-HEAD}"
# Guarded, because everything below is relative to it: a cd that failed
# silently would have this reporting on some other directory's suites and
# diff, which for a release gate is the worst way to be wrong.
cd "$root" || { echo "cannot enter $root" >&2; exit 1; }
# A named base that is behind its own upstream is refused rather than used.
# Passing `main` explicitly is the ordinary thing to do and is exactly how the
# stale-base runs happened, so this has to catch the argument as well as the
# default.
base_upstream="$(git rev-parse --abbrev-ref --symbolic-full-name "$base@{upstream}" 2>/dev/null || true)"
if [ -n "$base_upstream" ]; then
behind="$(git rev-list --count "$base..$base_upstream" 2>/dev/null || echo 0)"
if [ "${behind:-0}" -gt 0 ]; then
echo "release-pass: '$base' is $behind commit(s) behind $base_upstream." >&2
echo "Everything below is derived from $base..$head, so this would report on" >&2
echo "a release that is not the one being made. Fetch, or name $base_upstream." >&2
exit 1
fi
fi
case "${1:-}" in
-h|--help)
echo "Usage: test/release-pass.sh [base] [head]"
@@ -65,9 +95,11 @@ if docker info >/dev/null 2>&1; then
else
fail "no Docker here - test/runtime.bats did NOT run"
echo " It is the only thing that exercises mounts, the firewall and"
echo " the entrypoint end to end. Run it on the host, or check that"
echo " the privileged CI job RAN rather than queued: it is pinned to"
echo " one self-hosted runner and queues silently when that is down."
echo " the entrypoint end to end. Run it on the host, or read the"
echo " pull request's checks: GREEN means every job finished and"
echo " passed, this one among them. Yellow or grey means the"
echo " privileged job is still queued - it is pinned to one"
echo " self-hosted runner and queues silently when that is down."
fi
# --- what a person has to do, chosen by what the release touches -------------
@@ -105,6 +137,9 @@ touches '^(harnesses/|bin/harness/|docker/harness-wrapper)' &&
manual "Launch each affected harness against a real model."
touches '^(bin/dejima-attach|bin/dejima-ttyd|systemd/)' &&
manual "Attach over the web front door and detach again."
touches '^tools/' &&
manual "Launch with the affected tool included and use it. A tool is
layered into the image and put on PATH; nothing here runs one."
touches '^\.dejima/allowed-domains$' &&
manual "A domain here is read from main by the CI hardener and widens every
future CI job on that runner. Say in the PR what it is for."
@@ -132,8 +167,30 @@ added="$(comm -13 \
# itself the thing that makes the rollback refuse.
# The singular is spelled out rather than derived: "harnesses" does not lose
# an s to become "harness", and a manifest is named for the singular.
#
# Every axis there is, and the list is checked against the tree below rather
# than trusted: the newest axis is exactly the one a hand-kept list misses,
# because a list is only ever updated by whoever remembers. tools is an axis
# like the rest - `[core] tools = rtk` on a dejima that does not ship rtk is
# refused the same way an unknown harness is.
DEJIMA_RELEASE_AXES="harnesses:harness forges:forge workflows:workflow backends:backend tools:tool"
# The singular is spelled out above rather than derived - "harnesses" does not
# lose an s to become "harness", and a manifest is named for the singular - so
# this is the check that the spelling out has not fallen behind the tree.
for _d in */; do
_d="${_d%/}"
case "$_d" in
harnesses|forges|workflows|backends|tools) ;;
*) continue ;;
esac
case " $DEJIMA_RELEASE_AXES " in
*" $_d:"*) ;;
*) fail "$_d/ is a provider axis this script does not check for rollback" ;;
esac
done
new_providers=""
for pair in harnesses:harness forges:forge workflows:workflow; do
for pair in $DEJIMA_RELEASE_AXES; do
axis="${pair%%:*}"
one="${pair#*:}"
[ -d "$axis" ] || continue
@@ -144,14 +201,49 @@ for pair in harnesses:harness forges:forge workflows:workflow; do
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
done
if [ -n "$added" ] || [ -n "$new_providers" ]; then
# And the keys a provider DECLARES, which are config keys an operator writes
# and an older dejima refuses - but which live in a manifest rather than in
# core's schema, so the diff above cannot see them. `[harness.claude-code]
# backend` is one: adopting it is what makes the rollback refuse, and nothing
# said so.
new_keys=""
for pair in $DEJIMA_RELEASE_AXES; do
axis="${pair%%:*}"
one="${pair#*:}"
[ -d "$axis" ] || continue
while IFS= read -r name; do
[ -n "$name" ] || continue
now="$(sed -n 's/^keys *= *//p' "$axis/$name/$one.conf" 2>/dev/null \
| tr ' ' '\n' | sed 's/:.*//' | sort -u)"
was="$(git show "$base:$axis/$name/$one.conf" 2>/dev/null \
| sed -n 's/^keys *= *//p' | tr ' ' '\n' | sed 's/:.*//' | sort -u)"
while IFS= read -r k; do
[ -n "$k" ] || continue
new_keys="$new_keys [$one.$name]|$k"
done < <(comm -13 <(printf '%s\n' "$was") <(printf '%s\n' "$now"))
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
done
if [ -n "$added" ] || [ -n "$new_providers" ] || [ -n "$new_keys" ]; then
[ -z "$added" ] || printf ' %s\n' $added
[ -z "$new_providers" ] || printf ' [%s] as a config section\n' $new_providers
# How an older dejima refuses a new provider differs by axis, and saying
# "as a config section" for all of them was wrong for tools: a tool has no
# keys and so no section. It is refused through the value that names it.
for entry in $new_providers; do
case "$entry" in
tool.*) printf ' [core] tools = %s\n' "${entry#tool.}" ;;
*) printf ' [%s] as a config section\n' "$entry" ;;
esac
done
for entry in $new_keys; do
printf ' %s %s\n' "${entry%%|*}" "${entry#*|}"
done
else
echo " (none - a rollback accepts any config this release accepts)"
fi
note "Then"
echo " Say in the release PR what was run and what was not. A privileged job"
echo " that queued is not a job that passed."
echo " Say in the release PR what was run and what was not. Green checks mean"
echo " the privileged job ran; a queued one is yellow or grey, and is not a"
echo " job that passed."
exit "$status"
+74 -23
View File
@@ -215,6 +215,30 @@ run_line() { grep -- ' run --rm ' "$LAUNCH_DIR/docker-log" | tail -1; }
rm -rf "$PROJ"
}
@test "an inherited state dir and run id are not what the exit trap deletes" {
# cleanup() removes "$DEJIMA_STATE_DIR/debounce/$DEJIMA_RUN_ID-{waiting,done}".
# With remote access off nothing used to assign either name, so both came
# from the caller's environment if it had them - and the trap then removed
# a path of the environment's choosing. Both are bound by dejima before the
# trap is installed now, so the join can only ever name a path it made.
#
# Two canaries, because either name alone being dejima's is enough to miss
# the path: the removal needs both halves to come from the environment.
stub_launch
local canary="$LAUNCH_DIR/canary"
mkdir -p "$canary/debounce"
: > "$canary/debounce/deadbeefdeadbeef-waiting"
: > "$canary/debounce/deadbeefdeadbeef-done"
DEJIMA_STATE_DIR="$canary" DEJIMA_RUN_ID=deadbeefdeadbeef launch "$PROJ"
[ -f "$canary/debounce/deadbeefdeadbeef-waiting" ] \
|| { echo "the trap removed a file named by the environment"; return 1; }
[ -f "$canary/debounce/deadbeefdeadbeef-done" ] \
|| { echo "the trap removed a file named by the environment"; return 1; }
rm -rf "$PROJ"
}
@test "with remote access configured a registry entry is written and removed on exit" {
stub_launch
write_remote_config
@@ -420,11 +444,21 @@ EOF
write_remote_config
# The EXIT trap removes the entry, so snapshot it from inside the run:
# the stub stands in for the container, and runs while it still exists.
#
# Matched on the session's own compose run rather than on every docker
# call. A launch makes several - a build, this launch's filter container,
# and two removals from the exit trap - and snapshotting on each meant the
# last one won. Those two removals run after the entry is gone, so what
# this read was an empty directory.
cat > "$LAUNCH_DIR/docker" <<EOF
#!/bin/bash
echo "\$*" >> "\${SANDBOX_TEST_DOCKER_LOG:-/dev/null}"
rm -rf "$LAUNCH_DIR/sessions-snapshot"
cp -r "$RUNTIME_DIR/dejima/sessions" "$LAUNCH_DIR/sessions-snapshot" 2>/dev/null || true
case "\$*" in
*" run --rm "*)
rm -rf "$LAUNCH_DIR/sessions-snapshot"
cp -r "$RUNTIME_DIR/dejima/sessions" "$LAUNCH_DIR/sessions-snapshot" 2>/dev/null || true
;;
esac
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
@@ -2514,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
@@ -2734,10 +2768,14 @@ nginx_blocks() {
}
@test "install.sh links the remote scripts and the notify hook, registers neither, and enables nothing" {
# The hook is linked by the harness plugin now - which file it is, and
# where the harness reads it from, is the harness's business - while the
# CLIs stay install.sh's, being dejima's own.
grep -q '_link_hook dejima-notify.sh' "$REPO_ROOT/bin/harness/claude-code.sh"
# The hook is baked into the image and registered by absolute path, so
# install.sh puts nothing of it on the host - which file it is, and where
# a session reads it from, is the harness plugin's business. The CLIs stay
# install.sh's, being dejima's own.
grep -q 'COPY hooks/dejima-notify.sh /usr/local/lib/dejima/hooks/dejima-notify.sh' \
"$REPO_ROOT/docker/Dockerfile"
grep -q '_link_hook' "$REPO_ROOT/bin/harness/claude-code.sh" \
&& { echo "install.sh still links hooks into ~/.claude/hooks"; return 1; }
grep -q '^link_bin dejima-attach$' "$REPO_ROOT/install.sh"
grep -q '^link_bin dejima-ttyd$' "$REPO_ROOT/install.sh"
grep -q '^link_bin dejima-notify-watch$' "$REPO_ROOT/install.sh"
@@ -2769,13 +2807,25 @@ nginx_blocks() {
# $output, and this is a statement about what THIS run printed.
[[ "$output" == *"nothing is listening"* ]]
# bin/dejima registers $HOME/.claude/hooks/dejima-notify.sh per session,
# and ~/.claude/hooks is what gets mounted read-only into the container.
# Without this link every notification hook is an exit-127.
[ -L "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
[ "$(readlink -f "$FAKE_HOME/.claude/hooks/dejima-notify.sh")" \
= "$(readlink -f "$REPO_ROOT/hooks/dejima-notify.sh")" ]
[ -x "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
# The notify hook is baked into the image and registered per session by
# its absolute path there, so nothing about it lands on the host. Without
# the baked copy every notification hook would be an exit-127.
[ ! -e "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ] \
|| { echo "install.sh still puts the notify hook on the host"; return 1; }
# Asserted on what the registration PRODUCES rather than on the source
# text: the path is a variable there, and a grep for the literal passes or
# fails on how it happens to be spelled rather than on where it points.
local st; st="$(mktemp)"; echo '{}' > "$st"
bash -c '
source "'"$REPO_ROOT"'/bin/dejima-provider-lib.sh"
source "'"$REPO_ROOT"'/bin/dejima-harness-lib.sh"
harness_plugin_load claude-code
harness_hook harness_register_notify "$1"
' _ "$st" >/dev/null 2>&1
[ "$(jq -r '.hooks.Stop[0].hooks[0].command' "$st")" \
= "/usr/local/lib/dejima/hooks/dejima-notify.sh done" ] \
|| { echo "notify is not registered at its baked path: $(cat "$st")"; rm -f "$st"; return 1; }
rm -f "$st"
local b
for b in dejima-attach dejima-ttyd dejima-notify-watch; do
[ -L "$FAKE_HOME/.local/bin/$b" ]
@@ -2783,14 +2833,15 @@ nginx_blocks() {
[ -x "$FAKE_HOME/.local/bin/$b" ]
done
# Positive first: an absence assertion against a settings.json that did
# not get written would pass on its own.
run jq -e '[.hooks[]?[]?.hooks[]?.command] | any(test("sandbox-context.sh"))' \
"$FAKE_HOME/.claude/settings.json"
[ "$status" -eq 0 ]
run jq -e '[.hooks[]?[]?.hooks[]?.command] | any(test("dejima-notify"))' \
"$FAKE_HOME/.claude/settings.json"
[ "$status" -ne 0 ]
# install.sh registers nothing in the harness's config now, so there is
# nothing positive to check there - the settings file may not exist at
# all. The positive assertion that matters moved above: the notify hook IS
# registered, at its baked path, in the sandbox's own overlay.
if [ -f "$FAKE_HOME/.claude/settings.json" ]; then
run jq -e '[.hooks[]?[]?.hooks[]?.command] | any(test("dejima-notify"))' \
"$FAKE_HOME/.claude/settings.json"
[ "$status" -ne 0 ]
fi
# Nothing was enabled, and nothing under the runtime dir was created: the
# feature stays off until the operator sets [remote-access] enabled = on.
@@ -2801,7 +2852,7 @@ nginx_blocks() {
run env HOME="$FAKE_HOME" PATH="$STUB_DIR:$PATH" "$REPO_ROOT/install.sh"
[ "$status" -eq 0 ]
[ ! -e "$FAKE_HOME/.dejima-backup" ]
[ -L "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
[ ! -e "$FAKE_HOME/.claude/hooks/dejima-notify.sh" ]
# And with one, it may not: re-running the installer after a git pull is
# exactly what an operator with a front door already up does, and telling
+216 -19
View File
@@ -1,13 +1,16 @@
#!/bin/bash
# Every confined suite at once, for the case where the whole set is genuinely
# wanted - before opening a pull request, or after touching bin/dejima, which
# nearly every suite reads.
# The confined suites, in one of two lanes.
#
# The suites are separate files that share no state, which is what makes this
# safe: each writes into its own BATS_*_TMPDIR, and the two that bind a socket
# ask the kernel for a free port rather than naming one. Run sequentially they
# take about twenty-five minutes on ten cores; run together, about seven, which
# is the difference between checking the whole set and not bothering.
# test/run-confined.sh every confined suite - the pull-request gate
# test/run-confined.sh --changed only the suites the working diff calls for
#
# The suites are separate files that share no state, which is what makes running
# them together safe: each writes into its own BATS_*_TMPDIR, and the two that
# bind a socket ask the kernel for a free port rather than naming one. Within a
# file, bats' own --jobs parallelism is used too; it needs flock rather than GNU
# parallel, which is why --no-parallelize-across-files is passed alongside it
# (bats refuses --jobs without GNU parallel unless across-files is off, and this
# script is already doing the across-files half itself).
#
# CI does not use this. It runs the same suites as a matrix, one job each, so
# that a failure names the suite it came from without anyone reading a log -
@@ -26,22 +29,216 @@ cd "$root" || exit 1
# this repo is usually worked on.
PRIVILEGED=" runtime ci-runner "
suites=()
# Which suites a changed path calls for. This is the table .dejima.md used to
# carry as prose; it is here so that it runs, and so that an agent iterating
# cannot map a change to the wrong suite by hand.
#
# Anything that matches no rule selects every suite. A path nobody has mapped
# is a path nobody has decided about, and guessing "none" there is how a change
# ships having been checked by nothing.
suites_for() {
case "$1" in
# Read by nearly every other suite, so a change to one is the whole set.
bin/dejima|bin/dejima-config-lib.sh) echo ALL ;;
bin/dejima-audit) echo audit ;;
bin/dejima-update-check) echo config ;;
bin/dejima-epitaph) echo config ;;
bin/dejima-tty-suspend) echo static ;;
bin/dejima-color-lib.sh) echo 'remote project' ;;
bin/dejima-tui-lib.sh) echo 'remote project' ;;
bin/dejima-conversation-ship) echo conversation ;;
bin/dejima-share) echo share ;;
# The slug scheme keys the transcript mounts, the epitaph and audit
# stores, and the share layout, so a change to it is not one area's.
bin/dejima-slug-lib.sh) echo 'share conversation audit static' ;;
bin/dejima-credential-proxy|docker/credential-proxy-forwarder.js) echo proxy ;;
bin/dejima-project-lib.sh|bin/dejima-project|bin/dejima-dep|bin/dejima-allow) echo project ;;
bin/dejima-attach|bin/dejima-ttyd|bin/dejima-notify-watch) echo remote ;;
web/*|systemd/*) echo remote ;;
bin/dejima-provider-lib.sh|bin/dejima-harness-lib.sh) echo providers ;;
bin/harness/*) echo providers ;;
forges/*|workflows/*|harnesses/*|tools/*) echo providers ;;
install.sh) echo config ;;
# static.bats asserts on files it does not own, so a shipped doc or a
# docker/ file needs it as well as whatever else the change touched -
# which for docker/ is most things, since the entrypoint and compose
# file are read by nearly every suite that builds a launch.
docker/*) echo ALL ;;
hooks/*) echo 'static config providers project' ;;
docs/*|README.md|*.md) echo static ;;
.gitea/*|.shellcheckrc) echo static ;;
.dejima/*) echo 'static config project providers' ;;
test/*.bats) local n="${1##*/}"; echo "${n%.bats}" ;;
test/*) echo static ;;
*) echo ALL ;;
esac
}
lane=all
jobs=""
base=""
list_only=0
given_paths=()
while [ "$#" -gt 0 ]; do
case "$1" in
--changed) lane=changed ;;
--base) shift; base="${1:-}" ;;
--list) list_only=1 ;;
-j|--jobs) shift; jobs="${1:-}" ;;
-h|--help)
sed -n '2,5p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
printf ' --list print the selection and stop\n'
printf ' --jobs N tests to run at once within each suite\n'
printf ' --base REF what --changed diffs against\n'
exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
# A path given here stands in for one git would have reported, so the
# map can be asked what it does with a path without the answer
# depending on what happens to be uncommitted.
*) given_paths+=("$1") ;;
esac
shift
done
if [ "$lane" != changed ] && [ "${#given_paths[@]}" -gt 0 ]; then
echo "paths only mean something with --changed: ${given_paths[*]}" >&2
exit 2
fi
all_suites=()
for f in test/*.bats; do
[ -f "$f" ] || continue
name="$(basename "$f" .bats)"
case "$PRIVILEGED" in *" $name "*) continue ;; esac
suites+=("$name")
all_suites+=("$name")
done
[ "${#suites[@]}" -gt 0 ] || { echo "found no confined suites under test/" >&2; exit 1; }
[ "${#all_suites[@]}" -gt 0 ] || { echo "found no confined suites under test/" >&2; exit 1; }
# Within-file parallelism is bats' own, and it needs flock. Without it bats
# exits before running anything, so the fallback is stated rather than left to
# look like a broken suite. The size of it is decided after the selection,
# below, because it depends on how many suites there are.
if command -v flock >/dev/null 2>&1; then
can_parallelize=1
else
can_parallelize=0
fi
selected=()
changed_paths=()
if [ "$lane" = changed ]; then
if [ "${#given_paths[@]}" -gt 0 ]; then
changed_paths=("${given_paths[@]}")
else
if [ -z "$base" ]; then
for candidate in origin/development development origin/main main; do
if git rev-parse --verify --quiet "$candidate" >/dev/null; then
base="$candidate"
break
fi
done
fi
# Committed work plus what is still in the working tree, including
# files git has never seen: iteration happens before a commit, and a
# lane that only saw committed changes would report on the wrong thing.
mapfile -t changed_paths < <(
{
[ -z "$base" ] || git diff --name-only \
"$(git merge-base "$base" HEAD 2>/dev/null || echo "$base")" 2>/dev/null
git diff --name-only HEAD 2>/dev/null
git ls-files --others --exclude-standard 2>/dev/null
} | sort -u
)
fi
wanted=" "
for p in "${changed_paths[@]}"; do
[ -n "$p" ] || continue
for s in $(suites_for "$p"); do
[ "$s" != ALL ] || { wanted=ALL; break 2; }
case "$wanted" in *" $s "*) ;; *) wanted="$wanted$s " ;; esac
done
done
if [ "$wanted" = ALL ]; then
selected=("${all_suites[@]}")
else
for s in "${all_suites[@]}"; do
case "$wanted" in *" $s "*) selected+=("$s") ;; esac
done
fi
else
selected=("${all_suites[@]}")
fi
skipped=()
for s in "${all_suites[@]}"; do
for t in ${selected[@]+"${selected[@]}"}; do [ "$t" = "$s" ] && continue 2; done
skipped+=("$s")
done
# Which lane ran, before anything else, because the whole point of having two
# is that a run can be read back later without guessing which one it was. To
# stderr, so --list's stdout is the suite names and nothing else.
# How much concurrency to actually ask for.
#
# Every selected suite runs at once, so what lands on the machine is
# suites x jobs. Asking each suite for `nproc` made that ten times the cores
# on a ten-core box, and #295 is what that cost: the tests that wait on a
# bounded deadline - a pty reaching raw mode, a shipper polling, an audit
# finishing - blow through it on a machine that oversubscribed, and fail for
# the load rather than for the thing they check.
#
# Oversubscription still helps, because these tests fork more than they
# compute. It is a property of the whole run rather than of each suite, so it
# is budgeted once and divided: roughly twice the cores, in total.
if [ -z "$jobs" ]; then
if [ "$can_parallelize" = 1 ] && [ "${#selected[@]}" -gt 0 ]; then
cores="$(nproc 2>/dev/null || echo 4)"
budget=$(( cores * 2 ))
jobs=$(( (budget + ${#selected[@]} - 1) / ${#selected[@]} ))
[ "$jobs" -ge 1 ] || jobs=1
else
jobs=1
fi
fi
bats_flags=()
[ "$jobs" -gt 1 ] && bats_flags=(--jobs "$jobs" --no-parallelize-across-files)
{
if [ "$lane" = changed ]; then
printf 'lane: CHANGED - %d of %d confined suites, from %d changed path(s)\n' \
"${#selected[@]}" "${#all_suites[@]}" "${#changed_paths[@]}"
[ "${#skipped[@]}" -eq 0 ] || printf ' not run: %s\n' "${skipped[*]}"
printf ' An iteration check. NOT the pull-request gate - for that run\n'
printf ' test/run-confined.sh with no arguments.\n'
else
printf 'lane: ALL - every confined suite (the pull-request gate)\n'
printf ' not run: runtime, ci-runner - they need Docker and a real runner.\n'
fi
[ "$list_only" = 1 ] || printf ' %d test(s) at a time within each suite, %d suites at once\n' \
"$jobs" "${#selected[@]}"
printf '\n'
} >&2
if [ "$list_only" = 1 ]; then
[ "${#selected[@]}" -eq 0 ] || printf '%s\n' "${selected[@]}"
exit 0
fi
if [ "${#selected[@]}" -eq 0 ]; then
echo "nothing to run: no changed path maps to a confined suite"
exit 0
fi
# One process per suite. There are fewer suites than cores on anything this
# repo is developed on, and they are shell rather than CPU-bound, so a job
# limit would cost wall clock for no benefit.
out="$(mktemp -d)"
trap 'rm -rf "$out"' EXIT
for s in "${suites[@]}"; do
( bats "test/$s.bats" > "$out/$s" 2>&1; printf '%s' "$?" > "$out/$s.status" ) &
for s in "${selected[@]}"; do
( bats ${bats_flags[@]+"${bats_flags[@]}"} "test/$s.bats" > "$out/$s" 2>&1
printf '%s' "$?" > "$out/$s.status" ) &
done
wait
@@ -51,7 +248,7 @@ wait
# that as "0 failures" is how a green board comes to mean nothing.
failed=0
printf '%-16s %6s %8s\n' suite ran failed
for s in "${suites[@]}"; do
for s in "${selected[@]}"; do
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
bad="$(grep -cE '^not ok' "$out/$s")"
status="$(cat "$out/$s.status" 2>/dev/null || echo 1)"
@@ -69,7 +266,7 @@ done
if [ "$failed" -ne 0 ]; then
echo
for s in "${suites[@]}"; do
for s in "${selected[@]}"; do
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
bad="$(grep -cE '^not ok' "$out/$s")"
{ [ "$bad" -gt 0 ] || [ "$ran" -eq 0 ]; } || continue
@@ -77,7 +274,7 @@ if [ "$failed" -ne 0 ]; then
if [ "$ran" -eq 0 ]; then
head -20 "$out/$s"
else
grep -E '^not ok' -A 6 "$out/$s"
grep -E '^not ok' -A 20 "$out/$s"
fi
echo
done
+1055 -362
View File
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env bats
# The per-project share directory: what a launch mounts at ~/shared, and the
# host-side CLI that says where it is.
#
# `[core] share_dir` used to be mounted whole, so every session saw every other
# session's handoff files, across projects. What is asserted here is that the
# mount is now one project's subdirectory, that an operator can still find it,
# and that the files left over from the old layout are neither hidden silently
# nor moved without being asked about.
setup() {
REPO_ROOT="$BATS_TEST_DIRNAME/.."
LIB="$REPO_ROOT/bin/dejima-provider-lib.sh"
SLUG_LIB="$REPO_ROOT/bin/dejima-slug-lib.sh"
HOST="$(mktemp -d)"
CONFIG_DIR="$HOST/config"
SHARE="$HOST/share"
WS="$HOST/ws"
mkdir -p "$CONFIG_DIR" "$SHARE" "$WS" "$HOST/home/.claude"
echo '{}' > "$HOST/home/.claude/settings.json"
KEY="$HOST/sandbox-key"
: > "$KEY"
chmod 600 "$KEY"
export DEJIMA_CONFIG_DIR="$CONFIG_DIR"
# A dejima session exports several of these into every shell it starts, so
# a test that meant "unset" by not setting them would be reading the
# invoking environment rather than stating its own precondition.
unset SANDBOX_SHARE_DIR SANDBOX_CMD SANDBOX_EXTRA_MOUNTS LOKI_URL \
ENABLE_FIREWALL ALLOWED_DOMAINS
}
teardown() {
rm -rf "$HOST" ${STUB_DIR:+"$STUB_DIR"}
}
write_config() {
{
printf '[core]\nharness = claude-code\nforge = none\nworkflow = local-only\n'
printf 'update_check = off\nssh_key = %s\nshare_dir = %s\n' "$KEY" "$SHARE"
[ "$#" -eq 0 ] || printf '%s\n' "$@"
} > "$CONFIG_DIR/config"
}
slug() { bash -c 'source "$1"; ws_slug "$2"' _ "$SLUG_LIB" "$1"; }
share() { "$REPO_ROOT/bin/dejima-share" "$@"; }
# Drives the real launch with a stubbed docker, and records what
# SANDBOX_SHARE_DIR was when compose was invoked. Reading --print-config
# instead would pass against a version that resolved the path correctly and
# then mounted something else.
launch() {
STUB_DIR="$(mktemp -d)"
cat > "$STUB_DIR/docker" <<'EOF'
#!/bin/sh
printf '%s\n' "${SANDBOX_SHARE_DIR-}" >> "$SANDBOX_TEST_SHARE_LOG"
exit 0
EOF
chmod +x "$STUB_DIR/docker"
SHARE_LOG="$HOST/share-log"
: > "$SHARE_LOG"
HOME="$HOST/home" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
SANDBOX_TEST_SHARE_LOG="$SHARE_LOG" \
run "$REPO_ROOT/bin/dejima" "${1:-$WS}"
}
# What the launch actually handed compose, deduplicated - compose is invoked
# more than once per launch and every call must agree.
mounted_share() { sort -u "$SHARE_LOG" | grep -v '^$' | head -1; }
@test "a launch mounts one project's subdirectory, not share_dir itself" {
write_config
launch
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(mounted_share)" = "$SHARE/projects/$(slug "$WS")" ] \
|| { echo "mounted '$(mounted_share)'"; return 1; }
[ -d "$SHARE/projects/$(slug "$WS")" ] \
|| { echo "the launch did not create the directory it mounts"; return 1; }
}
@test "two projects get two directories" {
local other="$HOST/other"
mkdir -p "$other"
write_config
launch "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
local first="$(mounted_share)"
launch "$other"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$first" != "$(mounted_share)" ] \
|| { echo "both projects mounted $first"; return 1; }
}
@test "dejima-share path prints the directory a launch mounts, and creates it" {
write_config
run share path "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -d "$output" ] || { echo "path did not create $output"; return 1; }
local printed="$output"
launch
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# The point of the shared layout helpers: an operator dropping a file where
# `path` says would otherwise be dropping it where nothing reads it.
[ "$printed" = "$(mounted_share)" ] \
|| { echo "path said $printed, the launch mounted $(mounted_share)"; return 1; }
}
@test "dejima-share path defaults to the current directory" {
write_config
cd "$WS"
run share path
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$output" = "$SHARE/projects/$(slug "$WS")" ] || { echo "$output"; return 1; }
}
@test "dejima-share path refuses a directory that does not exist" {
# It would otherwise answer a typo with a share directory nothing will
# ever mount, which is worse than refusing: the file goes somewhere real
# and no session sees it.
write_config
run share path "$HOST/no-such-project"
[ "$status" -ne 0 ]
[[ "$output" == *"no such directory"* ]] || { echo "$output"; return 1; }
}
@test "a launch says which files the split left behind, every time until they are gone" {
write_config
echo secret > "$SHARE/left-over.txt"
launch
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"left-over.txt"* ]] \
|| { echo "the launch hid a file it stopped mounting: $output"; return 1; }
[[ "$output" == *"dejima-share adopt"* ]] \
|| { echo "it does not say what to do about it: $output"; return 1; }
# Said again rather than once: a file handed to a session yesterday and
# silently not arriving today is the failure this exists against.
launch
[[ "$output" == *"left-over.txt"* ]] || { echo "$output"; return 1; }
rm "$SHARE/left-over.txt"
launch
[[ "$output" != *"no session can see"* ]] \
|| { echo "still warning with nothing left over: $output"; return 1; }
}
@test "the per-project root is not itself reported as left behind" {
write_config
launch
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *"no session can see"* ]] \
|| { echo "a clean share_dir warned about its own projects dir: $output"; return 1; }
}
@test "dejima-share list names each project and flags what is loose" {
write_config
share path "$WS" > /dev/null
echo x > "$SHARE/loose-file"
run share list
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"$(slug "$WS")"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"loose-file"* ]] || { echo "$output"; return 1; }
# The retention story is the only one there is: nothing prunes this.
[[ "$output" == *"Nothing prunes this"* ]] || { echo "$output"; return 1; }
}
@test "adopt moves nothing without a yes" {
write_config
echo keep > "$SHARE/left-over.txt"
run bash -c 'printf "n\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ -f "$SHARE/left-over.txt" ] || { echo "declining still moved it"; return 1; }
# And an empty answer is a no, not a prompt loop or a yes.
run bash -c 'printf "\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
[ -f "$SHARE/left-over.txt" ] || { echo "an empty answer moved it"; return 1; }
}
@test "adopt moves what is loose into one project's share" {
write_config
echo one > "$SHARE/a.txt"
mkdir -p "$SHARE/adir"
echo two > "$SHARE/adir/b.txt"
run bash -c 'printf "y\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
local dest="$SHARE/projects/$(slug "$WS")"
[ -f "$dest/a.txt" ] || { echo "a.txt did not arrive"; return 1; }
[ -f "$dest/adir/b.txt" ] || { echo "the directory did not arrive whole"; return 1; }
[ ! -e "$SHARE/a.txt" ] || { echo "a.txt is still loose"; return 1; }
[ "$(cat "$dest/a.txt")" = one ] || { echo "contents changed"; return 1; }
}
@test "adopt never overwrites" {
# Both files are the operator's and this cannot know which one they meant,
# so the loose one stays loose and says so.
write_config
local dest="$SHARE/projects/$(slug "$WS")"
mkdir -p "$dest"
echo kept > "$dest/a.txt"
echo incoming > "$SHARE/a.txt"
run bash -c 'printf "y\n" | "$1" adopt "$2"' _ "$REPO_ROOT/bin/dejima-share" "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[ "$(cat "$dest/a.txt")" = kept ] || { echo "it overwrote the destination"; return 1; }
[ -f "$SHARE/a.txt" ] || { echo "it removed the source it refused to move"; return 1; }
[[ "$output" == *"already has one"* ]] || { echo "$output"; return 1; }
}
@test "adopt says so when there is nothing loose" {
write_config
share path "$WS" > /dev/null
run share adopt "$WS"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"Nothing loose"* ]] || { echo "$output"; return 1; }
}
@test "a writable mount naming the share root is refused" {
# The ~/shared mount is per project only while nothing else hands the whole
# of it over. A session's own share arrives without going through
# [mounts] read_write at all, so refusing costs nothing here.
write_config '[mounts]' "read_write = $SHARE/projects"
mkdir -p "$SHARE/projects"
launch
[ "$status" -ne 0 ] || { echo "mounted every project's handoff files"; return 1; }
[[ "$output" == *"Refusing"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"handoff"* ]] || { echo "the refusal does not say why: $output"; return 1; }
}
@test "a read-only mount naming the share root is refused too" {
write_config
mkdir -p "$SHARE/projects"
launch_ro() {
STUB_DIR="$(mktemp -d)"
printf '#!/bin/sh\nexit 0\n' > "$STUB_DIR/docker"
chmod +x "$STUB_DIR/docker"
HOME="$HOST/home" PATH="$STUB_DIR:$PATH" SSH_AUTH_SOCK= \
run "$REPO_ROOT/bin/dejima" --extra-readonly "$SHARE/projects" "$WS"
}
launch_ro
[ "$status" -ne 0 ] || { echo "read-only was enough to hand it over"; return 1; }
[[ "$output" == *"Refusing"* ]] || { echo "$output"; return 1; }
}
@test "the briefing tells the session the share directory is this project's" {
# The agent is told to leave things in ~/shared and to pick things up
# there. The path inside the container did not change, so what has to
# change is what it is told about who else can read it.
local out flat
out="$(bash -c 'source "$1"; briefing_compose none local-only claude-code' _ "$LIB")" \
|| { echo "$out"; return 1; }
[[ "$out" == *"~/shared"* ]] || { echo "the briefing lost ~/shared"; return 1; }
# Flattened, because the sentence wraps: a phrase spanning a line break
# matches nothing and the test fails on a reflow that changed no words.
flat="$(tr '\n' ' ' <<< "$out" | tr -s ' ')"
[[ "$flat" == *"this project's own"* ]] \
|| { echo "the briefing still reads as one directory for the host"; return 1; }
[[ "$flat" == *"every session against this project shares it"* ]] \
|| { echo "the briefing does not say who else can read it"; return 1; }
}
+18 -7
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
@@ -27,12 +32,18 @@ list_scripts() {
ls test/*.sh 2>/dev/null || true
} | sort -u | while read -r f; do
[ -f "$f" ] || continue
# Harness plugins are sourced, not executed, so they carry no shebang
# and the discovery below would drop them - silently, which is the
# worst version of that. They are in the trusted computing base, so
# they are included by name and the -s bash below tells shellcheck
# what they are.
case "$f" in bin/harness/*.sh) printf '%s\n' "$f"; continue ;; esac
# Sourced fragments are not executed, so they carry no shebang and the
# discovery below would drop them - silently, which is the worst
# version of that. Harness plugins are one kind; docker/ has two more,
# and they are not incidental: caps-lib.sh holds the drop that decides
# what a session's capability set is, and fixed-domains.sh is the seam
# keeping the 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
# computing base; the -s bash below tells shellcheck what these are.
case "$f" in bin/harness/*.sh|docker/*.sh) printf '%s\n' "$f"; continue ;; esac
head -1 "$f" | grep -qE '^#!.*(bash|sh)' && printf '%s\n' "$f"
done
}
+2673 -425
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
require('../src/gestures.js');
const { createGestures } = globalThis.DEJIMA.gestures;
const CELL = 20; // rows are 20px tall in every world below
// An element that records the listeners createGestures attaches, so a test can
// fire a touch sequence at it without a DOM.
function makeElement() {
const listeners = {};
return {
addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); },
fire: (type, event) => { (listeners[type] || []).forEach((fn) => fn(event)); }
};
}
function touchEvent(points) {
return {
touches: points.map((p) => ({ clientX: p.x, clientY: p.y })),
preventDefault: function () { this.defaultPrevented = true; },
defaultPrevented: false
};
}
// reporting: whether the application has asked for mouse reports, which is
// what decides who receives a notch and so what one is worth.
function makeWorld(reporting) {
const element = makeElement();
const scrolled = []; // this page's own scrollback, one row per entry
const wheeled = []; // notches sent to the application
const gestures = createGestures({
element: element,
term: {
scrollLines: (n) => scrolled.push(n),
getSelection: () => ''
},
mouse: {
cellHeight: () => CELL,
enabled: () => !!reporting,
cellAt: () => ({ col: 1, row: 1 }),
wheel: (cell, up) => wheeled.push(up ? 'up' : 'down'),
altScreen: () => false,
press: () => {}, motion: () => {}, release: () => {}
},
dragMode: () => false,
onCopy: () => {}
});
gestures.attach();
return {
element, scrolled, wheeled,
// A one-finger drag of `rows` rows, downward, which pulls earlier output
// into view. Delivered as one touchmove: scrollBy accumulates travel, so
// the split into events is not what the count under test depends on.
dragDown: function (rows) {
element.fire('touchstart', touchEvent([{ x: 100, y: 100 }]));
element.fire('touchmove', touchEvent([{ x: 100, y: 100 + rows * CELL }]));
element.fire('touchend', touchEvent([]));
}
};
}
// One notch per row of finger travel, whichever side receives the notch.
// What a notch is then WORTH belongs to the receiver: this page moves one row,
// and tmux's copy mode moves five until its binding says otherwise.
test('scrollback moves one row per row of finger travel', () => {
const w = makeWorld(false);
w.dragDown(10);
assert.equal(w.scrolled.length, 10);
assert.ok(w.scrolled.every((n) => n === -1), 'dragging down goes back in history');
assert.equal(w.wheeled.length, 0, 'no application is listening');
});
test('an application gets one notch per row of finger travel', () => {
const w = makeWorld(true);
w.dragDown(10);
assert.equal(w.wheeled.length, 10);
assert.ok(w.wheeled.every((d) => d === 'up'), 'dragging down scrolls up');
assert.equal(w.scrolled.length, 0, 'the page did not also scroll itself');
});
test('travel shorter than a row sends nothing rather than rounding up', () => {
const w = makeWorld(true);
w.element.fire('touchstart', touchEvent([{ x: 100, y: 200 }]));
w.element.fire('touchmove', touchEvent([{ x: 100, y: 200 + CELL / 2 }]));
assert.equal(w.wheeled.length, 0);
});
test('dragging up scrolls forward again', () => {
const w = makeWorld(true);
w.element.fire('touchstart', touchEvent([{ x: 100, y: 300 }]));
w.element.fire('touchmove', touchEvent([{ x: 100, y: 300 - 3 * CELL }]));
assert.deepEqual(w.wheeled, ['down', 'down', 'down']);
});