Compare commits

...
225 Commits
Author SHA1 Message Date
branderson 2d8c86ea44 Take several domains at once, up to twenty, confirmed before any is added
An agent that needs a toolchain's hosts asks for them together, and this took
one domain per command - so the operator ran it four times for four hosts, or
the agent asked four times.

Up to twenty now. One still goes through as it always did: that is a decision
the operator made by typing it, and a prompt on every ordinary use is a prompt
nobody reads. More than one is printed back one per line and confirmed first,
defaulting to no.

The cap and the confirmation are the same point. A list is where an unwanted
name rides along unread, the agent that suggested the command is what might
have put it there, and neither mechanism helps if the list is too long to
finish reading - so twenty, which is enough for the real case and short enough
to take in at once.

Every name is validated before any of them is shown, and one bad entry refuses
the whole command. The list being confirmed has to be the list that gets added,
or the operator approves names and a prefix of them lands; and the printout can
only be read if no entry carries a carriage return or an escape sequence that
rewrites the lines above it, which the pattern already forbids. All the invalid
ones are named, because fixing a list of ten should not mean running it ten
times to find the second mistake.

Confirming needs a terminal, so a script does them one at a time - which is
what it did before and still does for one. Repeats collapse, case-folded, so
the count the operator reads is honest.

$domain is only set inside the per-domain loops now, so the container picker's
prompt takes a label for the whole request instead. Under set -u that one
reference was fatal rather than merely wrong, which is how it was found.

The help page stayed within its thirty lines by earning them: the picker is
described once under the scope that finds it rather than twice, and two
paragraphs that both said a scope flag is an approval for later became one.
2026-10-09 21:51:45 -07:00
branderson 60a319b3ba Merge pull request 'Give the resolver checks a permitted name when the meta URL has no host' (#386) from egress-subject into development
Reviewed-on: #386
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 21:49:44 -07:00
branderson c0bc131fd4 Read the workflow for the sh -n sweep without PyYAML
The sweep added in #382 parsed .gitea/workflows/tests.yml with PyYAML,
which is not in the test image: in CI the test died with
ModuleNotFoundError having checked nothing, and failed confined (static).

It is an indentation scan over the two shapes the file uses now, with
nothing imported from outside the standard library. Bodies come from the
file as written rather than from the copy keys are normalised in, so a
shell line beginning with a dash is not rewritten on its way to sh -n,
and a job setting defaults: is refused rather than guessed at.

A hand-rolled scan that meets a shape it was not written for finds
nothing and reports no failures, so the count it read is checked against
a plain grep of the run: keys in the file.
2026-10-09 21:43:48 -07:00
branderson 00a2df3b0c Give the resolver checks a permitted name when the meta URL has no host
tests / confined (audit) (pull_request_target) Successful in 3m55s
tests / confined (config) (pull_request_target) Successful in 5m21s
tests / confined (conversation) (pull_request_target) Successful in 1m17s
tests / confined (launch) (pull_request_target) Successful in 2m32s
tests / confined (project) (pull_request_target) Successful in 3m37s
tests / confined (providers) (pull_request_target) Successful in 2m16s
tests / confined (proxy) (pull_request_target) Successful in 1m29s
tests / confined (remote) (pull_request_target) Successful in 3m9s
tests / confined (share) (pull_request_target) Successful in 2m17s
tests / confined (shellcheck) (pull_request_target) Successful in 1m35s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (static) (pull_request_target) Failing after 2m45s
tests / privileged (pull_request_target) Successful in 7m53s
tests / runner-env (privileged) (pull_request_target) Successful in 1m2s
The cached-ranges fixture points EGRESS_FORGE_META_URL at a file:// path, and a
file URL has no host - so checks 3 and 4, which resolve "the meta URL's host",
were handed an empty string. Check 3 asked the resolver to look up "" and
failed the container's boot on it, which took down every privileged run as soon
as the cache started working.

The property those checks state is about the policy: that a permitted name
resolves through the local resolver, and that its address lands in that name's
own set. The meta URL's host was an instance of such a name, not the point of
it - so the subject falls back to a permitted forge-class name, which keeps
checks 3, 4 and 6 doing exactly what they claim. Forge-class specifically,
because check 4 looks the answer up in the forge class's own set and check 6
asserts port 22 is refused on it.

Nothing at all to check is a refusal naming that, rather than a lookup of "".

Check 5 is skipped for a file source, and says so in the log. It asserts that a
permitted destination is reachable THROUGH the policy, and a local file is not
one: reading it again would pass while proving nothing. That is a real
reduction on the runs that use the cache, so it is a line an operator can see
rather than a silent pass - restoring it means choosing a permitted destination
cheap enough to request once per container, which is a separate decision.
2026-10-09 20:54:18 -07:00
branderson e7769bb3d6 Merge pull request 'Write the ranges-cache step in the shell the runner actually gives it' (#382) from ci-sh-fix into development
Reviewed-on: #382
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 18:25:43 -07:00
branderson e346ad3ae2 Write the ranges-cache step in the shell the runner actually gives it
tests / privileged (pull_request_target) Failing after 1m7s
tests / runner-env (privileged) (pull_request_target) Successful in 58s
tests / confined (conversation) (pull_request_target) Successful in 1m52s
tests / confined (proxy) (pull_request_target) Successful in 2m6s
tests / confined (providers) (pull_request_target) Successful in 3m7s
tests / confined (launch) (pull_request_target) Successful in 3m29s
tests / confined (share) (pull_request_target) Successful in 2m43s
tests / runner-env (confined) (pull_request_target) Successful in 1m11s
tests / confined (shellcheck) (pull_request_target) Successful in 1m41s
tests / confined (remote) (pull_request_target) Successful in 4m16s
tests / confined (project) (pull_request_target) Successful in 4m47s
tests / confined (audit) (pull_request_target) Successful in 5m11s
tests / confined (static) (pull_request_target) Failing after 4m12s
tests / confined (config) (pull_request_target) Successful in 6m34s
The step used a bash array. The runner's default shell is `sh -e`, where that
is a syntax error - so the whole step died before any of it ran, and the
privileged job failed on every pull request from the moment #378 merged.

Rewritten as POSIX sh: two curl branches rather than one with a built-up
argument list, and the ranges read once into a variable so the success test and
the count are the same answer. Driven under real dash in all four paths - a
good fetch, a failed fetch, a 200 carrying a rate-limit message instead of
ranges, and no token - with the cache written only in the first.

The sweep that would have caught it goes in beside it: every run: block in the
workflow that does not name its own shell has to parse under `sh -n`. A YAML
parse says only that the string is a string, and nothing local runs the
workflow, so this reached CI green from a machine where the gate passed.
2026-10-09 17:44:17 -07:00
branderson 3f5dae88b5 Merge pull request 'Fetch the forge ranges once per run, and wait out a rate limit when one happens' (#378) from meta-cache into development
Reviewed-on: #378
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 14:15:22 -07:00
branderson 5569e8d2fe Put these tests beside their relatives, not at the end of the file
tests / confined (conversation) (pull_request_target) Successful in 1m52s
tests / confined (proxy) (pull_request_target) Successful in 1m58s
tests / confined (providers) (pull_request_target) Successful in 2m45s
tests / confined (launch) (pull_request_target) Successful in 3m7s
tests / confined (share) (pull_request_target) Successful in 2m17s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 3m40s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (project) (pull_request_target) Successful in 4m6s
tests / confined (audit) (pull_request_target) Successful in 4m28s
tests / confined (static) (pull_request_target) Successful in 3m34s
tests / confined (config) (pull_request_target) Successful in 5m51s
tests / privileged (pull_request_target) Successful in 12m11s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
Appending to test/static.bats is why four branches in a row have had to resolve
a conflict that was only ever about position. The fetch tests go with the rest
of the fetch tests, and the one that reads the workflow file goes next to the
other one that reads it - which is a better home for both and one nothing else
is competing for.
2026-10-09 13:38:36 -07:00
branderson e900c51e20 Say in docs/ci.md why the ranges are fetched once per run
The secondary rate limit is the part that surprises: it is not the hourly
ceiling a token raises, so a valid credential does not prevent it, and the
failure arrives as one container failing to boot rather than as anything about
the API. It sits beside the note on the token, which is where a reader meets
the endpoint.
2026-10-09 13:37:29 -07:00
branderson e91b5771f6 Fetch the forge ranges once per CI run instead of once per container
test/runtime.bats starts twenty-seven container pairs, and every filter fetched
GitHub's /meta for itself - roughly twenty-seven requests in five minutes. That
is well inside the hourly ceiling a token buys, and exactly the burst the
endpoint's SECONDARY limit answers with a 403, which takes one container's boot
down and the job with it (#357).

The privileged job now fetches once into the directory it already shares with
the containers it starts, and runtime.bats points every filter at the result.
One request cannot be a burst.

Best-effort on both sides. The job writes the file only once it parses and
carries ranges, because a rate-limit body is a 200 with a message and no ranges
and a cached one would fail every container instead of one; and the suite falls
back to per-container fetches when the file is absent, which is what happens
outside CI. The worst case is the behaviour this replaces, and each side says
which of the two the run is getting.

egress-policy refuses a file:// source unless SANDBOX_TEST_MODE is set. Those
ranges decide which addresses the forge class reaches, so a launch that could
name its own source could name its own ranges - and that variable is set by
this suite and by nothing in bin/dejima. The mount is read-only and goes to the
filter alone; the session half has no business holding a path that names them.
2026-10-09 13:37:29 -07:00
branderson 3b17604178 Wait as long as a rate limit asks, and say which failure it was
Five attempts over twenty seconds is short for a limit measured in tens of
seconds: the whole sequence could sit inside one window and exhaust itself
without ever reaching the other side (#357).

A rate limit is now recognised from the status - 403 or 429 - or from the body,
because the endpoint will report one with a message and no ranges over a 200.
Those back off at attempt*15 rather than attempt*2, so three attempts cover 45
seconds instead of six. A connection failure keeps the short backoff, because no
wait fixes it.

Retry-After beats both, being the only number here that is not an estimate, and
only in its numeric form: the header may carry an HTTP date instead, and parsing
one to compare against a clock is a wrong answer away from a sleep nobody chose.
A cap stops a value nobody expected turning a boot into a hang.

And the message names the failure. An HTTP error from this endpoint carries a
body saying why - "API rate limit exceeded" rather than "curl exit 0, http 403"
- which was being discarded in favour of curl's stderr, where there is nothing.
curl's own message is what is left when no response carried a body.
2026-10-09 13:37:29 -07:00
branderson 6511a0537c Merge pull request 'Ctrl-Z does not pause the agent, and eleven other documented facts that had drifted' (#377) from review-docs into development
Reviewed-on: #377
Reviewed-by: branderson <brad@branderson.io>
2026-10-09 13:34:20 -07:00
branderson 023efb3171 Merge pull request 'The suite map's blind spot, a test that only skipped, and three cleanups' (#376) from review-tests into development
Reviewed-on: #376
Reviewed-by: branderson <brad@branderson.io>
2026-10-08 17:01:28 -07:00
branderson 23462a3c71 Merge pull request 'The host ssh-agent, a half-landed permit, and four things around them' (#375) from review-sec into development
Reviewed-on: #375
Reviewed-by: branderson <brad@branderson.io>
2026-10-08 16:54:42 -07:00
branderson 2ddcf964c3 Start the log shipper again, and catch the shape that stopped it
tests / confined (audit) (pull_request_target) Successful in 3m57s
tests / confined (config) (pull_request_target) Successful in 5m20s
tests / confined (conversation) (pull_request_target) Successful in 1m18s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (project) (pull_request_target) Successful in 3m36s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (remote) (pull_request_target) Successful in 3m8s
tests / confined (share) (pull_request_target) Successful in 2m14s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / runner-env (confined) (pull_request_target) Successful in 1m6s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
tests / privileged (pull_request_target) Successful in 6m46s
`env -u GH_TOKEN -u EGRESS_FORGE_META_AUTH drop_caps ...` cannot run: drop_caps
is a shell function from caps-lib.sh and env can only exec a binary, so the line
was exit 127. The `&` kept that from set -e, so the filter container came up
reporting ready with no shipper and Loki logging went dead without a word.

The prefix is gone. What covers the shipper is order, which is what the test now
asserts: both names are out of this script's environment before the line that
starts it, the raw token before the policy run and the header straight after it.

And the error class has its own test, because the one I wrote only grepped the
start line for the prefix's text - it would have passed on a line that could not
execute. It flattens continuations first, since the prefix was written across two
lines and a per-line grep did not see it, and drops comments after that, or the
line explaining why there is no prefix matches itself. Both spellings verified
to fail it.

runtime.bats already asserts the shipper is running when LOKI_URL is set, which
is the check that would have caught this in the privileged job.
2026-10-08 00:24:05 -07:00
branderson faeafa7f2d Merge pull request 'Three forms that walked past the push and api guards' (#374) from guard-boundary into development
Reviewed-on: #374
Reviewed-by: branderson <brad@branderson.io>
2026-10-08 00:18:26 -07:00
branderson 2cc6057c88 Reflow the two paragraphs this branch left long
tests / confined (audit) (pull_request_target) Successful in 4m53s
tests / confined (config) (pull_request_target) Successful in 6m29s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (launch) (pull_request_target) Successful in 2m36s
tests / confined (project) (pull_request_target) Successful in 4m1s
tests / confined (providers) (pull_request_target) Successful in 2m21s
tests / confined (proxy) (pull_request_target) Successful in 1m35s
tests / confined (remote) (pull_request_target) Successful in 3m0s
tests / confined (share) (pull_request_target) Successful in 2m16s
tests / confined (static) (pull_request_target) Successful in 3m19s
tests / confined (shellcheck) (pull_request_target) Successful in 1m36s
tests / runner-env (confined) (pull_request_target) Successful in 1m1s
tests / runner-env (privileged) (pull_request_target) Successful in 59s
tests / privileged (pull_request_target) Successful in 6m47s
Both were edited in place without rewrapping, so a line in each ran past the
column the rest of the file keeps to. The link stays on one line rather than
being wrapped mid-label.
2026-10-07 23:15:05 -07:00
branderson 1d8725f089 Move the CI-install test beside the other one that reads the workflow
tests / confined (audit) (pull_request_target) Successful in 4m48s
tests / confined (config) (pull_request_target) Successful in 9m30s
tests / confined (conversation) (pull_request_target) Successful in 1m21s
tests / confined (launch) (pull_request_target) Successful in 2m43s
tests / confined (project) (pull_request_target) Successful in 4m2s
tests / confined (providers) (pull_request_target) Successful in 2m23s
tests / confined (proxy) (pull_request_target) Successful in 1m29s
tests / confined (remote) (pull_request_target) Successful in 3m5s
tests / confined (share) (pull_request_target) Successful in 2m13s
tests / confined (static) (pull_request_target) Successful in 4m55s
tests / confined (shellcheck) (pull_request_target) Successful in 1m38s
tests / runner-env (confined) (pull_request_target) Successful in 1m6s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
tests / privileged (pull_request_target) Successful in 6m43s
Three branches appended to the end of test/static.bats, so whichever merged
last had to resolve a conflict that was only ever about position. This one
belongs next to the test that reads the same workflow file for the suite matrix,
which is both a better home and one nothing else is competing for.

Also drops a count that drifts from the suite map's docs rule, and a line in the
comment-stripping block that described doing it the other way.
2026-10-07 23:14:26 -07:00
branderson aa40e0c455 State what the spellings have in common, not what one used to do
tests / confined (conversation) (pull_request_target) Successful in 1m12s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (providers) (pull_request_target) Successful in 2m11s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (share) (pull_request_target) Successful in 2m16s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 3m8s
tests / confined (shellcheck) (pull_request_target) Successful in 1m37s
tests / confined (project) (pull_request_target) Successful in 3m44s
tests / confined (audit) (pull_request_target) Successful in 4m31s
tests / confined (static) (pull_request_target) Successful in 4m49s
tests / privileged (pull_request_target) Successful in 6m18s
tests / runner-env (privileged) (pull_request_target) Successful in 58s
tests / confined (config) (pull_request_target) Successful in 9m33s
The comment above the refused forms explained the `+` case by what the guard
used to compare. What a reader needs is why every form in the list names the
same branch, which is the two rules the loop exists to cover.
2026-10-07 23:11:57 -07:00
branderson d180581293 Keep the credential from the one daemon the policy does not start
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (providers) (pull_request_target) Failing after 42s
tests / confined (proxy) (pull_request_target) Successful in 1m33s
tests / confined (launch) (pull_request_target) Successful in 2m36s
tests / confined (shellcheck) (pull_request_target) Successful in 1m35s
tests / confined (share) (pull_request_target) Successful in 2m14s
tests / confined (audit) (pull_request_target) Successful in 4m46s
tests / confined (remote) (pull_request_target) Successful in 3m14s
tests / confined (project) (pull_request_target) Successful in 3m58s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (static) (pull_request_target) Successful in 4m50s
tests / confined (config) (pull_request_target) Successful in 9m50s
tests / privileged (pull_request_target) Failing after 6m37s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
log-shipper.sh is started by firewall-up.sh rather than by egress-policy, so
NO_CREDENTIAL never reached it. The unset above it took the raw token away and
left EGRESS_FORGE_META_AUTH exported - and that header carries the same token
in another wrapper, so the shipper held the credential after all.

It now starts under the same `env -u`, and the header is dropped as soon as the
policy run that needed it returns. The raw token still goes before that run,
which is why the test asserts one on each side of it.
2026-10-07 23:11:27 -07:00
branderson f61795381d Stop the README's Ctrl-Z line contradicting the sentence after it
tests / confined (audit) (pull_request_target) Successful in 4m48s
tests / confined (config) (pull_request_target) Successful in 8m56s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (launch) (pull_request_target) Successful in 2m35s
tests / confined (project) (pull_request_target) Successful in 4m0s
tests / confined (providers) (pull_request_target) Successful in 2m18s
tests / confined (proxy) (pull_request_target) Successful in 1m30s
tests / confined (remote) (pull_request_target) Successful in 2m58s
tests / confined (share) (pull_request_target) Successful in 2m12s
tests / confined (shellcheck) (pull_request_target) Successful in 1m36s
tests / privileged (pull_request_target) Successful in 6m31s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
tests / confined (static) (pull_request_target) Successful in 2m39s
"Backgrounds all of dejima" and "the container keeps running" cannot both be
read as written. The host side is what backgrounds, which is also the
distinction the paragraph exists to draw.

And docs/configuration.md said "this script", which was lifted from
dejima-tty-suspend's own header and has no referent in a document.
2026-10-07 20:45:06 -07:00
branderson 805d9a2c35 Say what the two runner assertions do and do not establish
tests / confined (conversation) (pull_request_target) Successful in 1m18s
tests / confined (launch) (pull_request_target) Successful in 2m27s
tests / confined (project) (pull_request_target) Successful in 3m54s
tests / confined (providers) (pull_request_target) Successful in 2m16s
tests / confined (proxy) (pull_request_target) Successful in 1m33s
tests / confined (remote) (pull_request_target) Successful in 3m15s
tests / confined (share) (pull_request_target) Successful in 2m13s
tests / confined (static) (pull_request_target) Successful in 3m24s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / runner-env (confined) (pull_request_target) Successful in 1m0s
tests / runner-env (privileged) (pull_request_target) Successful in 1m2s
tests / confined (audit) (pull_request_target) Successful in 4m33s
tests / confined (config) (pull_request_target) Successful in 5m58s
tests / privileged (pull_request_target) Successful in 6m42s
The comment left where a test used to be described the test. What a reader
needs is the property: each assertion is one job's answer, that the two jobs
land on different runners follows from neither, and a suite cannot see it
because it runs in one job.

The suite map's docs rule gains the same kind of note - a doc-only edit now
runs four suites rather than one, which is the cost of answering for what reads
a file and the direction to err in.
2026-10-07 20:44:48 -07:00
branderson 5884930592 Strip each suite's comments once instead of once per tracked file
The widened walk did a grep pass over every suite for every tracked file, which
took about three times as long as the narrow version it replaced. .dejima.md
warns what a slow test does to a bounded deadline under load, and static.bats
is the long pole in both lanes.

The comment-stripped copies are made once, before the loop. About 14s against
about 32s, for the same answers - verified by reverting the test/*.bats rule and
watching it name the two suites that miss static.

The header described the behaviour before the comment exclusion went in, so it
now says what counts as reading a file is defined below and is narrower than
mentioning it.
2026-10-07 20:44:48 -07:00
branderson 452d49394f State the --repo rule as a property rather than as a correction
tests / confined (audit) (pull_request_target) Successful in 4m31s
tests / confined (config) (pull_request_target) Successful in 9m24s
tests / confined (conversation) (pull_request_target) Successful in 1m12s
tests / confined (launch) (pull_request_target) Successful in 2m29s
tests / confined (project) (pull_request_target) Successful in 3m42s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / confined (proxy) (pull_request_target) Successful in 1m28s
tests / confined (share) (pull_request_target) Successful in 2m12s
tests / confined (static) (pull_request_target) Successful in 4m43s
tests / confined (shellcheck) (pull_request_target) Successful in 1m31s
tests / privileged (pull_request_target) Successful in 6m44s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
tests / confined (remote) (pull_request_target) Successful in 1m46s
The comment explained git's precedence and then described the reading that had
it backwards. A reader debugging this line needs the rule and what it decides
about an invocation, which is what it says now.
2026-10-07 20:43:00 -07:00
branderson 1cda465898 Read a destination the way git resolves it, not only its full spelling
A refspec destination that does not start with refs/ is matched against the
remote's refs by git's rev-parse rules, so `heads/main` and `main` both name
refs/heads/main. Only the full prefix was stripped, which left
`feature:heads/main` naming a branch no policy entry matched - and
`+feature:heads/main` with it.

Both prefixes come off now, in that order. Every strip has to leave an
unprotected destination alone, which the test asserts for each of them: a
stripping that refuses is not a policy that refuses.
2026-10-07 20:42:59 -07:00
branderson cacb74fb28 Refuse a graphql call whose body is not in the command
--input takes the whole request body from a file, or from stdin with `-`, so
the document is not in argv at any spelling: not as a field value the mutation
check can read, and not in the `=@` shape the file-indirection check catches.
The positional graphql beside it still counted as a readable target, which made
this the most direct spelling left on the path #365 closed for inline bodies.

Refused under the declared subcommand only, in both the separate-value and
attached forms. Outside it, --input is a flag this has nothing to say about,
which the test asserts - otherwise the refusal is about the word rather than
about the body.
2026-10-07 20:42:42 -07:00
branderson 4a9e789ba1 Drop a test call that asserted nothing, and two comments that narrate history
tests / confined (conversation) (pull_request_target) Successful in 1m19s
tests / confined (launch) (pull_request_target) Successful in 2m33s
tests / confined (proxy) (pull_request_target) Successful in 1m33s
tests / confined (providers) (pull_request_target) Successful in 2m17s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (audit) (pull_request_target) Successful in 4m41s
tests / confined (project) (pull_request_target) Successful in 3m54s
tests / confined (share) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m3s
tests / confined (remote) (pull_request_target) Successful in 3m1s
tests / confined (static) (pull_request_target) Successful in 4m52s
tests / confined (config) (pull_request_target) Successful in 9m36s
tests / privileged (pull_request_target) Successful in 6m32s
tests / runner-env (privileged) (pull_request_target) Successful in 58s
The empty-key test ran a launch with no key and never looked at the result; the
direction it was standing in for is its own test above it.

permit_address's comment said what the code used to leave standing. It now
states the property - the two adds are not one transaction - and says why the
unconditional delete is the safe direction when the element came from
somewhere else: the record has already rolled back, so a narrower live set
agrees with what `entries` reports.
2026-10-07 20:39:33 -07:00
branderson 9b96d5781b Document that a configured key withdraws the forwarded agent
docs/configuration.md said a key means a forwarded agent "is no longer required
at all", which is now an understatement: it is not mounted. An operator who
also relied on the agent inside the session - a second remote, a private
submodule over ssh - loses it, and that belongs where they choose the key
rather than in a commit message.

docs/security.md says the same in the two places it describes the key as an
alternative to the agent, because "alternative" now means the other one is
closed.
2026-10-07 20:39:33 -07:00
branderson bac3d45814 Scrub the credential where each daemon starts, not upstream of one call path
The unset in firewall-up.sh only covered `apply`. bin/dejima starts the filter
container with -e GH_TOKEN, so the token is in that container's configured
environment - and `dejima-allow` reaches the policy through `docker exec`,
which gets that environment. permit_name and rollback_entries both restart the
resolver, so the first approval of a name put the credential back into dnsmasq.
The ordering test could not see it, because it only read the apply path.

All three daemon starts now carry `env -u GH_TOKEN -u EGRESS_FORGE_META_AUTH`,
so the property holds wherever they were reached from and is visible on the
line that starts them. The post-fetch unset stays, but is no longer the only
thing standing there.

The test asserts the start lines rather than their order, which is what makes
it independent of the call path, and counts them so a daemon dropped from the
sweep is not a daemon that passed it.
2026-10-07 20:39:33 -07:00
branderson fa29e2ac00 Correct eleven documented facts that had drifted from the code
tests / confined (conversation) (pull_request_target) Successful in 1m11s
tests / confined (proxy) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 2m11s
tests / confined (share) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / confined (static) (pull_request_target) Successful in 3m31s
tests / confined (launch) (pull_request_target) Successful in 2m27s
tests / confined (remote) (pull_request_target) Successful in 3m5s
tests / confined (project) (pull_request_target) Successful in 3m32s
tests / confined (audit) (pull_request_target) Successful in 3m53s
tests / confined (config) (pull_request_target) Successful in 5m13s
tests / privileged (pull_request_target) Successful in 6m7s
tests / runner-env (privileged) (pull_request_target) Successful in 1m2s
Each was verified against what ships rather than taken from the report:

docs/development.md said a harness manifest declares eleven keys; the schema
has eighteen. The count is gone rather than corrected, since nothing checks it
and it was wrong within a release. The same file's no-Docker suite list omitted
launch.bats and share.bats and put ci-runner.bats among the suites that run
anywhere, when it needs a real runner of either tier; the list now says
run-confined.sh discovers the set, which is why one is not kept there. It also
listed bin/dejima-color-lib.sh twice, with two different descriptions.

docs/configuration.md described the push guard as refusing a remote that is not
the Gitea host. It reads DEJIMA_PUSH_HOSTS and DEJIMA_BLOCKED_BRANCHES, both
resolved from the selected forge and workflow, so it is the same rule whichever
forge is in use.

docs/ci.md said none of the runner posture is checked by anything.
ci-runner.bats checks what a job can see from where it stands - which tier it
landed on, the docker socket in one tier and not the other, the egress rules in
front of the confined job. What it cannot see is the runner's own configuration
file, and that is now what the bullet says.

docs/security.md cited a README section that no longer exists for the forge
token's scopes, and pointed Loki readers at the README's Prerequisites, which
say nothing about logging.

install.sh wrote a config header describing the every-key-commented format it
retired - twelve lines below a comment explaining that defaults.conf is the
complete reference now and this file holds only what the operator decided.

README.md omitted jq from its prerequisites, which install.sh hard-requires and
exits over.

docs/remote-access.md named /run/dejima-notify as the marker root, while its
own diagram twenty lines up has it right: ${XDG_RUNTIME_DIR}/dejima/notify. It
also attributed a "scriptable" claim to the README; it lives in
dejima-attach's own help.

The audit's time-window caveat was stated twice in consecutive sentences in the
README; the one that stays is the one static.bats pins.

The report also called the README's GitHub-push rationale a duplicate of
docs/security.md's, under the "one place" rule. It is not, and the test named
for it says why: the README has to name both properties the port-22 claim rests
on, because the claim stays readable only if a reader can see what would have
to stop being true. Left as it was.
2026-10-07 19:36:31 -07:00
branderson b3f8252c73 Stop two documents saying Ctrl-Z pauses the agent
bin/dejima-tty-suspend's own header says what happens: everything host-side is
in one process group - the script, the container run, the credential proxy, the
conversation shipper - so the kernel stops and continues all of it together.
The container is not in that group. It keeps running, with its mounts, its
firewall grants and its forge token.

docs/security.md says so. The README said "it makes no progress while
suspended" and docs/configuration.md said "a suspended session is stopped, so
it makes no progress until you resume it" - and the operator most likely to
reach for Ctrl-Z is one uneasy about what the agent is doing, which is the
reader those two sentences were worst for.
2026-10-07 19:21:01 -07:00
branderson 720b91a569 Say the resolver-check rationale once per step rather than twice
tests / confined (conversation) (pull_request_target) Successful in 1m16s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (providers) (pull_request_target) Successful in 2m14s
tests / confined (launch) (pull_request_target) Successful in 2m35s
tests / confined (share) (pull_request_target) Successful in 2m15s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 3m10s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (project) (pull_request_target) Successful in 3m38s
tests / confined (audit) (pull_request_target) Successful in 3m58s
tests / confined (static) (pull_request_target) Successful in 3m40s
tests / confined (config) (pull_request_target) Successful in 5m21s
tests / privileged (pull_request_target) Successful in 6m37s
tests / runner-env (privileged) (pull_request_target) Successful in 56s
The paragraph explaining why the lookup runs before apt was pasted twice inside
each of the four steps that do it. The second copy carried the retry reasoning
and the first carried one detail the second lacked, so the kept paragraph is the
longer one with that detail folded in.
2026-10-07 19:01:46 -07:00
branderson 3086bb884e Stop a test claiming a property one job cannot see
"The two runners are not the same runner" ran in one job, so the branch it took
was whichever of the two assertions above it already applies - one job's answer
restated, under a name claiming both. Comparing the two jobs' results is the
workflow's job, not a suite's, and the comment left in its place says so.

Both directions stay covered, one per job: the confined runner must not have the
docker socket and the privileged one must.
2026-10-07 19:01:46 -07:00
branderson 02f5dd8b19 Make the release pass read its head argument instead of the working tree
The rollback scan diffed $base against the working tree while reporting on
$base...$head, so an explicit head reported on whatever happened to be checked
out. Driven against two refs with base equal to head, the old form named five
providers as added; the new one correctly names none.

All three sites now read the head side through git: the config schema with
`git show`, and the provider directories with `git ls-tree` rather than `find`.
2026-10-07 19:01:46 -07:00
branderson 990ab94a40 Install the tool the port-collision test needs, so it stops skipping in CI
remote.bats skips the dejima-ttyd port-collision refusal without `ss`, and the
confined job installed no iproute2 - so the one check guarding a reverse-proxy
port takeover was a permanent `ok # skip` on every run, which is the same as
not having it.

The test pins both halves: the install line in that job, and that remote.bats
still keys on the tool - otherwise this is an install nothing needs.
2026-10-07 18:50:44 -07:00
branderson 940f632447 Let the suite map's own test see the paths it was rotting under
The meta-test iterated bin/, hooks/, web/, systemd/ and install.sh, so the map
could go narrow under test/, forges/, harnesses/, workflows/, tools/, .dejima/
and docs/ with nothing watching. It now walks every tracked file, and the three
gaps that were there are closed:

A change to test/runtime.bats selected zero confined suites, while static.bats
carries invariants over that file's text - so the --changed lane exited green
having run nothing. A suite file is read as well as run, and now selects static
beside itself.

test/fixtures/* selected proxy and static, but config.bats executes
answer-prompt.py to drive its prompts.

forges/, workflows/ and harnesses/ selected providers and static, while the
audit reads a workflow's briefing, conversation.bats sed-injects a harness.conf
and config.bats copies the whole set in.

The docs rule also moved below the test/ and .dejima/ rules, where it belongs:
a .md under either of those is read by whatever reads that directory, and the
pattern answered for it first.

The test itself now ignores comment lines when asking which suites read a file.
Suites cite each other in prose constantly, and counting those pushed every
rule towards selecting everything - which is a different failure from the one
this guards. Over-selection is still the safe direction; this is about what
"reads it" means.
2026-10-07 18:49:53 -07:00
branderson c8fd2dc605 Treat an empty ssh_key file as no key, rather than refusing it
tests / confined (conversation) (pull_request_target) Successful in 1m12s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (launch) (pull_request_target) Successful in 2m33s
tests / confined (share) (pull_request_target) Successful in 2m17s
tests / runner-env (confined) (pull_request_target) Successful in 1m7s
tests / confined (remote) (pull_request_target) Successful in 3m9s
tests / confined (shellcheck) (pull_request_target) Successful in 1m34s
tests / confined (project) (pull_request_target) Successful in 3m36s
tests / confined (audit) (pull_request_target) Successful in 3m56s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (config) (pull_request_target) Successful in 5m23s
tests / privileged (pull_request_target) Successful in 6m42s
tests / runner-env (privileged) (pull_request_target) Successful in 56s
Refusing it outright was the first shape of the commit above and it broke most
of the suite: a great many fixtures use an empty file as a stand-in for a path,
which is a configuration dejima used to wave through.

An empty file is not a key - it loads nothing in the container - so it cannot be
what withdraws the forwarded agent either. It now counts as no key at all, which
costs nothing for a workflow that never pushes and lands in the refusal that
already exists for having neither, with the file named.

The fixtures write a byte into those files, because what they were describing
is a path to a key and an empty one no longer is.
2026-10-07 18:44:40 -07:00
branderson ba2158e0e8 Say what the gitea CLIs confine, and what the push hook does not see
"The gitea CLIs refuse an origin that is not on the configured host, so they
can only act on the repo the sandbox was launched against" overstates it by one
step. origin lives in .git/config, inside the read-write project mount, and the
gitea token is account-wide - so a session that rewrites origin reaches another
repository on the same host. What bounds that is the token's scope, as it is
for gh.

And the push hook's comment claimed git's own options were the only thing a
spelling could vary. A segment's first word still has to be git, so a
`FOO=bar git push` prefix is a push this does not see - the PATH shadow does,
which is the division of labour the pair exists for rather than a gap in it.
2026-10-07 18:44:33 -07:00
branderson 51c0a8bc65 Render the briefing as the session user, and test the pty tty(1) reports on
The renderer reads the project's own .dejima.md and asks the package managers
what is installed, with its cwd inside the workspace a session can write.
Nothing about that needs root - and root got the answer wrong too: the
dependency layers install npm and cargo packages as the sandbox user, into that
user's HOME, so root's cargo home held none of them and every declared cargo
dependency was reported missing. hooks/sandbox-context.sh already reaches the
renderer as that user, since a hook runs inside the session; this is the other
route agreeing with it. The file it writes stays root-owned and read-only,
which is the half that must not be deprivileged.

The pty chown guarded on stdout and then asked `tty`, which reports on stdin.
With one attached and the other not, the path came back empty and the chown
silently did nothing - the nvim clipboard fallback failing with nothing to
point at.
2026-10-07 18:05:24 -07:00
branderson d418de83df Scrub the forge credential before the daemons that inherit it start
The published-ranges fetch is the only thing in the firewall path that needs
GH_TOKEN, and the header derived from it. Everything started after it - the
restricted resolver, the two log readers, the shipper - parses content the
session's own traffic decides, and inherits the environment of the process that
started it.

Both are unset as soon as the request that uses them is made. The test asserts
the ORDER rather than the presence of the unset, because an unset after the
daemons start is the same as none at all - and it also pins that the header is
still there for the fetch, so the scrub cannot pass by breaking the request.
2026-10-07 18:02:43 -07:00
branderson 225abccece Take back the any-port element when a forge-class permit half-lands
permit_address puts a forge-class address in literal_v4 and then in forge_v4.
If the second add failed, the entries file rolled back and the command died -
but the first element stayed, so the address the forge class says is HTTPS-only
was permitted on every port for the rest of the session, and neither `entries`
nor `status` showed it, because the record of it had been undone.

The element goes back too. If that removal itself fails there is nothing left to
try, so it says so and names the restart rather than reporting a narrowing that
did not happen.

Driven with a stub nft, since the real one needs a namespace to act on: the
half-landed case, the whole-success case which must undo nothing, and the
open-class case where there is no second set and so nothing to take back.
2026-10-07 17:58:32 -07:00
branderson 43081852ea Unmount the operator's ssh-agent when a dedicated key is configured
The forwarded agent was mounted whenever the workflow pushes, and entrypoint.sh
merely re-pointed SSH_AUTH_SOCK at a fresh agent holding the sandbox key. The
host agent stayed connectable at the path docker-compose.yml names, so
`SSH_AUTH_SOCK=/ssh-agent ssh ...` signed with the operator's personal
identities for the whole session.

Choosing a dedicated key has to mean the personal agent is not in the container,
not that it is merely not the default. bin/dejima now blanks SSH_AUTH_SOCK when
a key is configured, the same way it does for a workflow that never pushes, so
${SSH_AUTH_SOCK:-/dev/null} mounts nothing.

An empty key file is refused rather than accepted: it passes the is-a-file check,
loads nothing in the container, and is now what stops the agent being mounted -
the three together would be a pushing session with no credential at all.

Both the key spelling and [identity.default] ssh_key are covered, since
config_get resolves the identity's value for that key.
2026-10-07 17:57:28 -07:00
branderson 4adfe9b36c Read a GraphQL document that arrives attached to its flag
tests / confined (conversation) (pull_request_target) Successful in 1m13s
tests / confined (proxy) (pull_request_target) Successful in 1m24s
tests / confined (providers) (pull_request_target) Successful in 2m13s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (share) (pull_request_target) Successful in 2m15s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 3m7s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / confined (project) (pull_request_target) Successful in 3m36s
tests / confined (audit) (pull_request_target) Successful in 3m58s
tests / confined (static) (pull_request_target) Successful in 3m32s
tests / confined (config) (pull_request_target) Successful in 5m19s
tests / privileged (pull_request_target) Successful in 6m50s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
The mutation check and the file-indirection check ran after the loop had
skipped every dash-prefixed argument, so a flag carrying its value attached put
the document somewhere neither looked: `--field=query=mutation{...}` and
`-Fquery=mutation{...}` passed, while the positional `graphql` beside them
still counted as a readable target. With the github forge the push token is
write-capable, so that is the escalation the inline spelling was closed against.

Both checks now run over every argument. Neither needs to know which of the
CLI's flags take a separate value, which is what lets them look at all of them
- unlike the repository scoping below, which does, and which still sees only
the positionals.
2026-10-07 17:23:36 -07:00
branderson 12a222cace Let a positional remote beat --repo, the way git documents it
git: "this option is equivalent to the <repository> argument. If both are
specified, the command-line argument takes precedence." This read it the other
way round, so `git push --repo=origin other-remote feature` resolved and
approved origin's URL, treated other-remote as a refspec, and let git push to a
host nothing had checked against DEJIMA_PUSH_HOSTS.

The library's own header says a disagreement between this parser and git's has
to fail as a refusal. This one failed as an unchecked push - the test for it
shows ssh reaching the unvetted host when the precedence is put back.

`git push --repo=origin main` is a push to a remote called "main" under that
rule, which is how git reads it too, so it is now refused as an unresolvable
remote rather than by the branch policy.
2026-10-07 17:21:33 -07:00
branderson af0fce3326 Read the force marker as part of the refspec, not of the branch
`git push origin +main` put the destination through the branch policy as
"+main", which matched no entry, so the one spelling that escaped was the
destructive one. `+refs/heads/main` escaped the same way, since the prefix also
stopped the refs/heads/ strip from landing.

One leading `+` now comes off before the destination is read. A `+` on a branch
nothing protects is still an ordinary push, and the test says so - otherwise the
strip could be doing the refusing rather than the policy.
2026-10-07 17:19:20 -07:00
branderson 2217d6d3c3 Merge pull request 'Close the two gaps a review found in the guards, and rewrap what was written to a forge's rules' (#373) from review-1007 into development
Reviewed-on: #373
Reviewed-by: branderson <brad@branderson.io>
2026-10-07 16:51:38 -07:00
branderson 300097dcae Make the mutation boundary symmetric
tests / confined (conversation) (pull_request_target) Successful in 1m13s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / confined (launch) (pull_request_target) Successful in 2m27s
tests / confined (share) (pull_request_target) Successful in 2m12s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 3m5s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / privileged (pull_request_target) Successful in 6m37s
tests / confined (project) (pull_request_target) Successful in 3m33s
tests / confined (audit) (pull_request_target) Successful in 3m52s
tests / confined (static) (pull_request_target) Successful in 3m29s
tests / confined (config) (pull_request_target) Successful in 5m12s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
The right side still required whitespace or a brace, and GraphQL treats commas
and comments as ignored tokens - so `mutation,{mergePullRequest}` and
`mutation#c` followed by the body on the next line are valid documents that sat
just outside it. The bare word with nothing after it did too.

Both boundaries are now anything that cannot continue an identifier. The class
excludes `_` on both sides, so a field called foo_mutation is still not a
mutation, and the three honest queries in the test still pass - a check that
refused one would cost an agent the only route the API has to review threads.
2026-10-07 10:05:21 -07:00
branderson 30a2a5c288 Cut the second and third consequence from two comments
tests / confined (conversation) (pull_request_target) Successful in 1m15s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m10s
tests / confined (remote) (pull_request_target) Successful in 3m5s
tests / confined (shellcheck) (pull_request_target) Successful in 1m33s
tests / confined (project) (pull_request_target) Successful in 3m29s
tests / confined (audit) (pull_request_target) Successful in 3m47s
tests / confined (static) (pull_request_target) Successful in 3m24s
tests / confined (config) (pull_request_target) Successful in 5m8s
tests / privileged (pull_request_target) Successful in 6m49s
tests / confined (launch) (pull_request_target) Successful in 2m27s
tests / confined (share) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / runner-env (privileged) (pull_request_target) Successful in 57s
Both state a real reason and then keep going. The entrypoint's shadow comment
spent three sentences where the first carries the bug, and the harness.conf one
restated the entrypoint's fail-closed story when the harness-local fact is
that it cannot host the guards, not that nothing does.

Measured against this repo's own standard for comments, which asks for the
first consequence and leaves the chain to a reader who needs it.
2026-10-07 09:23:34 -07:00
branderson ab67534887 Rewrap the prose that was written to a forge body's rules
The host-entry section went in as four single lines of 236 to 487 characters,
in a file hard-wrapped at eighty. One-long-line-per-paragraph is for pull
request and issue bodies, where the forge turns a source newline into a visible
break; a repository document has no such reader.

Also pins the precondition behind the repository rules: they are applied to the
commands merge_block and disclose name, because those are the only keys that
name a CLI - so a forge declaring repo_flags and neither of them would get the
rule applied to nothing, silently, there being no command for it to be absent
from. Stated in providers.md for a third-party manifest, and asserted over the
shipped forges so the statement cannot quietly stop being true.
2026-10-07 09:22:15 -07:00
branderson bb4f06657f Read a mutation that is not the document's first operation
The left boundary was whitespace or `=`, so a GraphQL document carrying more
than one operation - `query A{x}mutation B{mergePullRequest(...)}` - put `}`
immediately before the mutation and walked past the check. Whether a given CLI
can select that second operation is beside the point: the boundary should not
be the thing deciding.

It is now anything that cannot continue an identifier, which still excludes
`_`, so a field named foo_mutation is not read as a mutation. Both halves are
tested, because a check that refused an honest query would cost an agent the
one route the API has to review threads.
2026-10-07 09:20:20 -07:00
branderson 6d77293880 Let the push hook see the one spelling that reaches neither layer
`/usr/bin/git push origin main` stepped over the PATH shadow by naming the
absolute path, and the hook compared the whole first word, so it did not see a
git invocation at all. Both layers allowed it.

That form is the example guard-lib.sh's own header gives as the reason the two
layers exist, and the identical spelling was already basenamed and tested for
the forge CLI beside it. The hook now basenames too, as the shadow does by
reading its own $0.

A basename is not a suffix match: `mygit push` and `git-annex push` stay
commands this has nothing to say about, and the test says so.
2026-10-07 09:18:56 -07:00
branderson c7521c7b6d Merge pull request 'One copy of the push and merge policy, and the forms that walked past it' (#372) from guards-365 into development
Reviewed-on: #372
Reviewed-by: branderson <brad@branderson.io>
2026-10-06 23:13:49 -07:00
branderson 3474c96f95 Prove in a container that the shadow can load its policy
tests / confined (conversation) (pull_request_target) Successful in 1m17s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / confined (launch) (pull_request_target) Successful in 2m29s
tests / confined (share) (pull_request_target) Successful in 2m10s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / confined (remote) (pull_request_target) Successful in 3m4s
tests / confined (shellcheck) (pull_request_target) Successful in 1m31s
tests / confined (project) (pull_request_target) Successful in 3m32s
tests / confined (audit) (pull_request_target) Successful in 3m52s
tests / confined (static) (pull_request_target) Successful in 3m52s
tests / confined (config) (pull_request_target) Successful in 5m9s
tests / privileged (pull_request_target) Successful in 6m50s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
Every git command in a session now goes through a shadow that sources a file
the Dockerfile copies in, so a COPY line that stopped matching the path would
break git outright in every session - and the confined suites would stay green,
because they point the guards at the checkout rather than the image.

The file-wide pair sets no DEJIMA_PUSH_HOSTS, which fails closed, so any push
is refused and the refusal itself is the evidence the policy loaded. The
pass-through half is asserted beside it: everything that is not a push has to
reach real git, or the shadow is a broken git rather than a guarded one.
2026-10-06 22:39:31 -07:00
branderson 4c81605df6 Say that the hooks and the shadows read the same decision
Three documents described the hooks as regex-matching a command string while
the shadows compared argv, which is what made the overlap read as two
implementations rather than two entry points. development.md's table gains the
library; configuration.md says which pushes are refused for being unreadable
rather than checked, since that is the part an operator meets as a refusal.

merge-guard.sh is also no longer only about merges, so configuration.md says
what it applies.
2026-10-06 22:38:42 -07:00
branderson 07d863d6dc Close the push and api forms that walked past the guards
Four bypasses, all of them a parser resolving exactly one thing and checking
that (#365).

`--all` and `--mirror` were opaque flags, and only the first positional after
the remote was read. So `git push --all origin` from a feature branch pushed
main, and `git push origin feature main` checked feature alone. Every
positional after the remote is now a refspec and every one is checked; --all
is checked against every local branch, since that is what it pushes. --mirror
is refused outright: it writes every ref AND deletes the remote refs this
checkout does not have, so a mirror from a repository with no main is how the
remote's main disappears, and no branch list makes that readable off the
command.

`--git-dir` and `--work-tree` were consumed and ignored, so a push carrying
one resolved the remote in the mounted project, found it allowed, and then ran
against a repository whose origin points somewhere else. Refused for a push -
and only for a push, since they say nothing about any other git command.
Honouring them would mean reimplementing how they combine with -C and with
each other.

`gh api` was scoped only when the endpoint sat under the declared segment, so
`user/repos`, `/orgs/<org>/repos` and `graphql` named no repository for the
guard to compare and nothing was refused. Under that subcommand the question
is now inverted: a target the guard CAN read has to be present. Telling an
endpoint apart from a header or field value would mean knowing which of the
CLI's flags take a separate value, which is the coupling this guard exists to
avoid - asking whether a readable target is there needs no such knowledge.

graphql stays readable, because dejima's own github-pr reads review threads
through it and the API has no other route to them, and a query can only read
what the token already could. A mutation does not: it is what an agent reaches
for once `gh pr merge` refuses, and no repository scoping can be read out of a
GraphQL document. A field value read from a file is refused under the same
subcommand, since it keeps the document out of the command entirely.
2026-10-06 22:34:31 -07:00
branderson 2d3a8ef32a Put the push and merge policy in one place both layers read
Each rule existed twice: as a PATH shadow over the command, and as a
PreToolUse hook over the command string a harness reports. The overlap is
deliberate - a hook sees `/usr/bin/git push`, which walks past the shadow, and
a shadow sees a push from an editor integration or a script, which the harness
never reports - so neither can be dropped. What they cannot do is disagree,
and they had: the shadow skipped `-o` and its value and read `--repo`, the
hook did neither, so `git push -o x origin main` was fine to one and a hard
refusal from the other for the same invocation in the same session.

docker/guard-lib.sh now holds the decision and all four call it. Each hook's
job is to turn a command string into the argv the library reads, which is also
how the hooks inherit the shadows' indifference to spacing and quoting - and
why merge-guard.sh no longer escapes regex metacharacters out of a block
pattern, since there is no regex left to inject into.

The library takes the stricter reading wherever the two differed: `HEAD`
resolves to the current branch, `--repo` names the remote, and a flag's value
is skipped rather than read as a positional.

entrypoint.sh derives the set of CLIs it links the shadow over from the same
function the hook checks a command against, so what is shadowed and what is
checked cannot drift apart either.
2026-10-06 22:32:04 -07:00
branderson f0cf3b98f7 Merge pull request 'Finish the check gaps, and clamp the host entry to the ports it was told to open' (#371) from checks-364 into development
Reviewed-on: #371
Reviewed-by: branderson <brad@branderson.io>
2026-10-06 21:55:33 -07:00
branderson 513187cef3 Merge pull request 'Correct the documents that stated the opposite of the code' (#370) from docs-contradictions into development
Reviewed-on: #370
Reviewed-by: branderson <brad@branderson.io>
2026-10-06 21:45:59 -07:00
branderson 4caf9d8d76 Keep the harness explanation out of the opening pitch
tests / confined (conversation) (pull_request_target) Successful in 1m13s
tests / confined (proxy) (pull_request_target) Successful in 1m27s
tests / confined (providers) (pull_request_target) Successful in 2m10s
tests / confined (launch) (pull_request_target) Successful in 2m26s
tests / confined (share) (pull_request_target) Successful in 2m8s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / confined (remote) (pull_request_target) Successful in 3m4s
tests / confined (shellcheck) (pull_request_target) Successful in 1m29s
tests / confined (project) (pull_request_target) Successful in 3m30s
tests / confined (audit) (pull_request_target) Successful in 3m47s
tests / confined (static) (pull_request_target) Successful in 3m19s
tests / confined (config) (pull_request_target) Successful in 5m8s
tests / privileged (pull_request_target) Successful in 6m5s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
The README's first paragraphs are the shortest attention budget in the project,
and a sentence there grew into three explaining which harness install.sh seeds.
That belongs in the install steps, where a reader meets the file and can be
surprised by it, so step 4 now says the starter config states claude-code and
which line to change.

The budget test stopped at the pitch, so the paragraph immediately after it -
the same real estate - had none. It now covers everything before "What bounds
it:", which is where the opening ends. Verified by putting the long version
back: 189 words against a budget of 160.
2026-10-06 21:24:58 -07:00
branderson bad60b2cbe Give the connection log a permitted destination that is not the host
tests / confined (conversation) (pull_request_target) Successful in 1m11s
tests / confined (proxy) (pull_request_target) Successful in 1m23s
tests / confined (providers) (pull_request_target) Successful in 2m6s
tests / confined (launch) (pull_request_target) Successful in 2m26s
tests / confined (share) (pull_request_target) Successful in 2m9s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / confined (remote) (pull_request_target) Successful in 3m0s
tests / confined (shellcheck) (pull_request_target) Successful in 1m30s
tests / confined (project) (pull_request_target) Successful in 3m27s
tests / confined (audit) (pull_request_target) Successful in 3m48s
tests / confined (static) (pull_request_target) Successful in 3m20s
tests / confined (config) (pull_request_target) Successful in 5m7s
tests / privileged (pull_request_target) Successful in 6m27s
tests / runner-env (privileged) (pull_request_target) Successful in 1m0s
The log test used the bridge gateway on port 9 for its permitted half, which
worked because the host entry was open on every port - so the clamp turned its
allow record into a refusal and the test failed in CI.

It needs a destination nothing has to answer on, since the allow record is
written when the first packet is accepted rather than when a service replies,
and waiting for a reply would make this a test of the network. A documentation
address permitted as a literal is that: in @literal_v4, on any port, routed
nowhere. Permitted live rather than declared at launch, so the file-wide pair
keeps the permitted set a real launch has.
2026-10-06 17:51:14 -07:00
branderson b906f33ab5 Export the two host-entry variables the launcher passes through
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (proxy) (pull_request_target) Successful in 1m26s
tests / confined (providers) (pull_request_target) Successful in 2m10s
tests / confined (launch) (pull_request_target) Successful in 2m25s
tests / confined (share) (pull_request_target) Successful in 2m9s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / confined (remote) (pull_request_target) Successful in 3m4s
tests / confined (shellcheck) (pull_request_target) Successful in 1m31s
tests / confined (project) (pull_request_target) Successful in 3m28s
tests / confined (audit) (pull_request_target) Successful in 3m48s
tests / confined (static) (pull_request_target) Successful in 3m18s
tests / confined (config) (pull_request_target) Successful in 5m10s
tests / privileged (pull_request_target) Failing after 7m3s
tests / runner-env (privileged) (pull_request_target) Successful in 1m2s
Every other name in filter_env is derived here, so it is exported where it is
assigned. These two come from the operator's own environment, and `docker run
-e NAME` reads the launcher's environment rather than its shell variables - so
a value set without being exported would reach the filter container as nothing
at all, and the clamp's escape hatch would silently do nothing.
2026-10-06 14:59:42 -07:00
branderson 2f921a0d6a Correct the two documents that describe the host as reachable
development.md described the runtime assertion as proving a gateway-bound
service reachable, which is the behaviour the clamp removes; the positive
control it explains now comes from a second container given one port.

remote-access.md said the policy deliberately permits traffic to the
default-route address. The address is still the one a sandbox can route to,
and that is what makes a front door bound to it an escape hatch - but no port
on it is open now, so the filter refuses such a door too. Said as the second
layer rather than the first, because binding the proxy correctly is still the
thing to get right.
2026-10-06 14:57:37 -07:00
branderson 79e18b916a Prove the host clamp holds against a real listener
The test asserted that a host service bound to the bridge gateway was
reachable, which is the behaviour the clamp removes. It now asserts both
halves: the file-wide pair, which names no ports, cannot reach a listener on
the gateway address, and a pair given that one port can reach it and not a
second listener on the same address at another port.

The loopback half stays - the clamp does not replace it, since a host service
bound to 127.0.0.1 is not on the gateway address at all - and the positive
control stays for the reason it was written: without one, a nonzero status is
equally satisfied by docker exec failing or bash missing from the image.
2026-10-06 14:46:56 -07:00
branderson ba2fb5b434 Describe the host entry as what it reaches
The allowlist said "the Docker host itself (not its whole subnet)", which is
accurate about addresses and said nothing about ports - so a reader checking
whether the boundary held would not have learned that every port the host
published was reachable, and now would not learn that none is.

Its own section, because the reason it is clamped is the reason it exists: it
is the one destination nobody declared.
2026-10-06 14:46:56 -07:00
branderson 03b96b2da8 Clamp the host entry to the ports it was told to open
Every other destination in the policy is there because something declared it.
The host entry is not: it is the gateway of the container's default route,
derived from the routing table, and it was permitted on every port. Docker
publishes container ports on that address, so a session reached the host's
sshd, anything bound to 0.0.0.0, and every published port of every other
container on the machine - including dejima's own remote-access front door,
which is a host shell behind a tmux prefix key (#363).

It now opens none by default. Nothing in a launch needs it: a host-resident
forge, a host-resident Loki and a self-served model endpoint are each reached
through their own entry, by name or as a literal address, and each lands in a
set of its own. EGRESS_HOST_PORTS takes TCP ports and low-high ranges for
anything else, `any` restores the unclamped rule explicitly, and a value that
is not a port stops the container rather than becoming a rule nobody meant.

The address stays named, with its own refusal rather than a fall-through to the
end of the chain, so reaching for the host is a counted refusal.

bin/dejima now passes EGRESS_HOST_PORTS and EGRESS_HOST_ADDR to the filter
container. Neither has a config key and both default inside egress-policy, so
without that the clamp would have no escape hatch an operator could reach.
2026-10-06 14:46:47 -07:00
branderson f8726b2ea9 Ask the kernel for the forwarder's port instead of naming five
proxy.bats named 18398, 18399, 18443, 18444 and 18445, while run-confined.sh
states that the suites binding a socket ask for a free one. The forwarder takes
its port as configuration rather than asking for one itself, so a test has to
supply a number - but it can supply a number the kernel just said was free.

That leaves a window where something else takes the port between the probe and
the bind. It is the smaller of the two failure modes: a constant fails on every
run on a machine already using it, and these suites run on developer machines.
2026-10-06 14:38:35 -07:00
branderson 1d91d07d5c State the free port the ttyd tests were reading from the machine
stub_ss_no_listeners existed and said in its own comment that it was the
precondition for every other ttyd test; one test called it. Eleven others wrote
ttyd_port = 12345 and asserted success, so they failed on any host holding that
port - the same shape as #56, and as the default-port case that produced this
helper.

It is now called beside every stub_ttyd but one. That one is the port-in-use
refusal, which binds a real socket and needs the real ss to see it; the
exclusion is stated there rather than left to be rediscovered.

Verified by holding 127.0.0.1:12345 and running the suite: green with the
stubs, six failures without them.
2026-10-06 14:38:35 -07:00
branderson 172988a67c Test the only validation standing between a forge's document and the allowlist
egress-policy pipes the fetched ranges and the operator's own forge addresses
through cidr-collapse and loads whatever comes back, so both halves of it are
load-bearing: the collapse keeps the interval sets small, and the validation is
the only thing that looks at what the published document actually contained.
Nothing exercised either.

Six cases, over documentation addresses: the merge, the refusal to over-merge
into addresses the forge never published, per-family output with v4 first,
stdin with both separators, a bare address and a host-bits range, and empty
input exiting clean so egress-policy's own empty-list message is reached. The
refusal case checks that nothing usable reaches stdout, since a caller
substituting partial output would load a short allowlist and look fine.

Verified by mutation: swallowing the ValueError instead of exiting fails the
refusal case, and collapsing both families together fails the merge case.
2026-10-06 14:34:55 -07:00
branderson a7d7ec9dda Stop the gate counting a skipped test as one that ran
bats emits a skip as `ok N <name> # skip <reason>`, so the summary counted a
test that declined to run as a test that passed, and named it nowhere. Several
suites skip on host state - an empty /opt/dejima, a missing ss or node - so the
number in the ran column was the only thing an operator had, and it was
overstating how much of the suite the run covered.

Skips now have their own column and their names are printed under the table.
The ran column is what executed. "ran nothing" still keys off the TAP lines
emitted, so a suite bats refused outright is still caught.
2026-10-06 14:33:44 -07:00
branderson 6d6e40d243 Make an absence assertion mean absent rather than unreadable
grep exits 2 for a file it could not read, which satisfies `-ne 0` exactly as
well as "the pattern is not there". Eighty-nine assertions in this suite are
the dangerous-thing-is-absent ones - no sudo in the Dockerfile, no docker
socket in compose, no ~/.ssh mount - so a file renamed or moved out from under
them would turn all eighty-nine green having checked nothing.

`-eq 1` is the status that means the file was read and the pattern was not in
it. All 503 tests still pass, so every one of these does read a real file
today; pointing one at a path that does not exist now fails it.

The new test walks the line after each `run grep` with awk rather than
grepping this file for the shape, which would match its own text.
2026-10-06 14:25:30 -07:00
branderson eb5b34f3be Refuse to build a page that silently drops a source
tests / confined (config) (pull_request_target) Successful in 5m55s
tests / confined (conversation) (pull_request_target) Successful in 1m20s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (project) (pull_request_target) Successful in 3m43s
tests / confined (providers) (pull_request_target) Successful in 2m15s
tests / confined (proxy) (pull_request_target) Successful in 1m34s
tests / confined (remote) (pull_request_target) Successful in 2m49s
tests / confined (share) (pull_request_target) Successful in 2m0s
tests / confined (static) (pull_request_target) Successful in 3m13s
tests / confined (shellcheck) (pull_request_target) Successful in 1m31s
tests / privileged (pull_request_target) Successful in 6m27s
tests / runner-env (confined) (pull_request_target) Successful in 1m4s
tests / runner-env (privileged) (pull_request_target) Successful in 56s
tests / confined (audit) (pull_request_target) Successful in 1m57s
A src/*.js omitted from CLIENT_SOURCES is hashed into the manifest and fails
no test - the staleness check and the Sources stamp both agree with the tree.
It is simply never inlined, so the page ships missing a module.

The list cannot become a glob, because its order is load-bearing: each file
attaches to the shared DEJIMA object and later ones parse through earlier
ones. So the build reads src/ and refuses when the directory holds a module
the list does not. Verified by removing osc52.js from the list.
2026-10-06 14:10:44 -07:00
branderson 9329c722cd Name the briefing and the guards by what they are, not by one route
The README said a SessionStart hook briefs the session. Neither shipped
harness takes that route: claude-code gets the briefing as a system prompt
through briefing_args, cline reads the file entrypoint.sh writes root-owned.
The hook is the fallback for a harness doing neither, which is a detail for
docs/configuration.md rather than the page someone reads first.

The blast-radius row - identical in security.md and threat-model.md, and
pinned identical by a test - credited the PreToolUse hooks for bounding an
agent that is not yet hostile, in a table that is otherwise harness-agnostic.
The push and merge guards are PATH shadows and hold for every harness; only
the denylist is a hook. The row's own radius column already claims both.
2026-10-06 14:10:38 -07:00
branderson 978480c73d Hash the builder into its own manifest
tests / confined (conversation) (pull_request_target) Successful in 1m42s
tests / confined (proxy) (pull_request_target) Successful in 1m51s
tests / confined (providers) (pull_request_target) Successful in 2m40s
tests / confined (launch) (pull_request_target) Successful in 2m57s
tests / confined (share) (pull_request_target) Successful in 2m13s
tests / runner-env (confined) (pull_request_target) Successful in 1m5s
tests / confined (remote) (pull_request_target) Successful in 3m33s
tests / confined (shellcheck) (pull_request_target) Successful in 1m32s
tests / confined (project) (pull_request_target) Successful in 4m7s
tests / confined (audit) (pull_request_target) Successful in 4m58s
tests / confined (static) (pull_request_target) Successful in 4m34s
tests / confined (config) (pull_request_target) Successful in 8m15s
tests / privileged (pull_request_target) Successful in 11m34s
tests / runner-env (privileged) (pull_request_target) Successful in 1m1s
web/build.mjs was not among the sources it stamps, so editing it - the
escaping, the fill order, which files get inlined - produced a different page
with a byte-identical manifest and stamp. The staleness check passed on a
page nobody had rebuilt, and web/README.md defends committing the artifact
precisely so nobody has to run the build, which is what makes that check the
only thing standing there.

The test hashes the same set, so the two cannot disagree about what counts as
a source. Verified by editing the builder and leaving the page alone, which
now fails.
2026-10-06 13:54:49 -07:00
branderson 4f0fbff39c Stop a comment claiming no harness hosts the guards, beside one that does
claude-code declares four. The comment sat next to cline's decision to declare
none and gave the reason as "no harness hosts them any more" - which is the
reasoning that made the missing git shadow a hole rather than a cost, since
the shadow was not installed under the default workflow either.

It now says what is true: this harness cannot host them, the PATH shadows
cover a harness that does not, and those shadows are installed whatever the
workflow says - so declaring none here costs the hook layer, not the guard.
2026-10-06 13:54:49 -07:00
branderson 34ae5c4044 Correct three documents that stated the opposite of the code
Two harness keys were typed `?template` where the schema says `?args`, and
the code's own comment explains the distinction exists because calling them
template "promised a substitution that does not happen" - re-promised to
exactly the audience writing a manifest against that table. assumed_backend
was in the prose and missing from the table of every key, and it is the one
key there that writes into the operator's config.

[github] token_file was documented as read whichever forge is selected. Both
it and the github forge's push_token_file fill GH_TOKEN, the forge's wins,
and the launch says so - which is the half that decides which token can push.
providers.md already had it right.

README said neither harness is a default without saying install.sh writes a
starter config selecting one. Both are true: dejima has no default, which is
why the generated file has to state a choice. Left as it was, a reader is
surprised by their own config.
2026-10-06 13:54:49 -07:00
branderson c400ff3ad0 Merge pull request 'The checks everything else leans on: shellcheck coverage, release pass, suite map' (#369) from review-checks into development
Reviewed-on: #369
Reviewed-by: branderson <brad@branderson.io>
2026-10-06 13:46:32 -07:00
branderson 57c76aa573 Select every suite that reads a changed path, not the one that owns it
tests / confined (conversation) (pull_request_target) Successful in 1m8s
tests / confined (proxy) (pull_request_target) Successful in 1m19s
tests / confined (providers) (pull_request_target) Successful in 2m6s
tests / confined (launch) (pull_request_target) Successful in 2m20s
tests / confined (share) (pull_request_target) Successful in 2m2s
tests / runner-env (confined) (pull_request_target) Successful in 58s
tests / confined (remote) (pull_request_target) Successful in 2m56s
tests / confined (shellcheck) (pull_request_target) Successful in 1m23s
tests / confined (project) (pull_request_target) Successful in 3m22s
tests / confined (audit) (pull_request_target) Successful in 3m42s
tests / confined (static) (pull_request_target) Successful in 3m10s
tests / confined (config) (pull_request_target) Successful in 5m3s
tests / privileged (pull_request_target) Successful in 6m9s
tests / runner-env (privileged) (pull_request_target) Successful in 55s
bin/dejima-provider-lib.sh selected `providers` alone while config.bats sets
LIB= to that very file; bin/dejima-harness-lib.sh selected a suite that does
not mention it at all. Twenty rules were narrower than the suites that read
them, and static.bats - which asserts over the whole tree - was missing from
nearly all of them.

The --changed lane is what runs between edits, so a narrow rule reports an
edit as green from a selection that never loaded the file.

.dejima.md says nothing can tell that a rule is too narrow. A reader cannot;
a test can: for each mapped path, every suite whose text names it has to be in
that rule's answer. Over-selecting costs a suite run and under-selecting costs
a regression, so the rules now err wide.

The check matches on a basename, and on the full path where the tree holds
more than one file by that name - without which web/README.md inherits every
mention of the root one.
2026-09-25 17:19:06 -07:00
branderson d188eb20be Name a manual check for the boundary in the release pass
Its patterns covered the launcher, the forges, the guards and the web page,
and nothing in the egress policy, the capability floor or the credential
path. A release that rewrote the firewall was therefore reported as touching
nothing that needs a manual check - over exactly the things this project
exists to hold, in the one place that says what nothing has verified.

Three patterns, each naming what only a session can show: a refusal that is
immediate rather than a hang, a capability set read from a running container,
and the credential paths the suites reach least.

The test drives each boundary path against the script's own patterns, so a
pattern edited to stop matching is caught rather than the list here drifting.
2026-09-25 17:19:06 -07:00
branderson 88779bd656 Shellcheck the two libraries the shebang filter dropped
The list matches a shebang, with named exceptions for the sourced fragments
that carry none - and that exception was written as a pattern rather than a
roll of names precisely so nobody would have to remember to extend it. It was
still short by one directory: bin/*-lib.sh, which is dejima-harness-lib.sh,
the code deciding which host paths may be mounted where.

47 files became 49. The one finding it surfaced is SC2015 on an `A && B || C`
that is correct - there is no then-branch and the block exits - so it is
rewritten as the `if` it means rather than suppressed.

The test derives what must be covered from the tree rather than from a list,
by reading each file's shebang, so a fragment added in a new place fails this
instead of being skipped in silence.
2026-09-25 17:19:06 -07:00
branderson 7535f4795b Merge pull request 'Three HIGH findings from the full-tree review' (#368) from report-high into development
Reviewed-on: #368
Reviewed-by: branderson <brad@branderson.io>
2026-09-25 16:57:49 -07:00
branderson bd01875c7f Say what length arrived, end the connection, and test the chunked half
tests / confined (conversation) (pull_request_target) Successful in 1m7s
tests / confined (proxy) (pull_request_target) Successful in 1m20s
tests / confined (providers) (pull_request_target) Successful in 2m10s
tests / confined (launch) (pull_request_target) Successful in 2m31s
tests / confined (share) (pull_request_target) Successful in 2m12s
tests / runner-env (confined) (pull_request_target) Successful in 59s
tests / confined (remote) (pull_request_target) Successful in 3m8s
tests / confined (shellcheck) (pull_request_target) Successful in 1m27s
tests / confined (project) (pull_request_target) Successful in 3m34s
tests / confined (audit) (pull_request_target) Successful in 3m53s
tests / confined (static) (pull_request_target) Successful in 3m19s
tests / confined (config) (pull_request_target) Successful in 5m15s
tests / privileged (pull_request_target) Successful in 6m10s
tests / runner-env (privileged) (pull_request_target) Successful in 54s
Three small things around the body cap.

The refusal log named the cap rather than the value, so `Content-Length: -1`
and a genuinely oversized upload read identically - while the comment on the
413 path promises the log says what came in.

The 413 left the connection open with the refused body still queued, so the
next parse took those bytes as a request line and a keep-alive client got
garbage 400s instead of one clean refusal.

And only the Content-Length path was tested. The two share a helper today, so
a refactor that inlines the chunked branch would restore the unbounded read
with nothing failing; the chunked shape now has its own case.
2026-09-25 16:38:28 -07:00
branderson d8263120e3 Find a scrubbed value by its components, not by re-reading its path
The display path is ambiguous and always was: a dict key spelled `x[0]` and
element 0 of a sibling list `x` produce the same string, and so does a key
containing a dot. Re-parsing it to find the value again deleted the bystander
and left the secret in the file for the first, and raised KeyError out of the
scrub for the second - after which bin/dejima copies the file in unscrubbed.

That spelling cannot change: finding_id hashes it, and the hash is what a
standing answer in credential-scan.decisions is keyed by. So walk_json now
yields the components it built the path from, and everything that has to find
the value again takes those. The string stays what an operator reads.

The string-parsing deleter is gone rather than left unused, so a future caller
cannot find its way back onto the ambiguous path.

One thing this does not fix: when two locations genuinely share a display path
they share a finding id, so scrubbing removes both. That is the fail-safe
direction - the secret goes - and disambiguating the string would orphan every
recorded decision.
2026-09-25 16:38:28 -07:00
branderson c302aaae96 Test the shadow against re-gating in any spelling, and the real guard
The regression test matched an `if` carrying one of the two lists, so re-gating
written as `[ -n "${DEJIMA_PUSH_HOSTS:-}" ] && ln -sf ...` passed it while
putting the bug straight back - no shadow under the shipped default workflow.
It now requires the link to sit at the top level of the file, carry no test of
its own, and open no conditional in the block it lives in. All three forms were
run against it.

The empty-host refusal was proven by a copy of the guard's loop written in the
test, which is the shape this repository keeps getting caught by: it passes
when a fail-open short-circuit is added above the loop, and fails when the loop
is rewritten harmlessly. It now runs git-guard.sh against a real repository
with the list empty, and checks a permitted host is not refused for the same
reason - so the refusal is the empty list rather than a broken guard.
2026-09-25 16:38:27 -07:00
branderson a899d60d59 Delete scrubbed JSON paths in an order that does not shift the rest
tests / confined (conversation) (pull_request_target) Successful in 1m37s
tests / confined (proxy) (pull_request_target) Successful in 1m45s
tests / confined (providers) (pull_request_target) Successful in 2m32s
tests / confined (launch) (pull_request_target) Successful in 2m50s
tests / confined (share) (pull_request_target) Successful in 2m3s
tests / runner-env (confined) (pull_request_target) Successful in 56s
tests / confined (remote) (pull_request_target) Successful in 3m22s
tests / confined (shellcheck) (pull_request_target) Successful in 1m23s
tests / confined (project) (pull_request_target) Successful in 3m49s
tests / confined (audit) (pull_request_target) Successful in 4m11s
tests / confined (static) (pull_request_target) Successful in 3m8s
tests / confined (config) (pull_request_target) Successful in 5m32s
tests / privileged (pull_request_target) Successful in 12m27s
tests / runner-env (privileged) (pull_request_target) Successful in 54s
del cur[i] moves every later element of a list down one, so applying findings
in document order deleted the wrong ones from the second onwards. Two secrets
in one array left the second secret in the file and removed an innocent value
instead - and the staged file is mounted into the container, so the operator
answers "keep it out" and it goes in silently.

Reachable from anything that carries several token-shaped strings in one list,
which an mcpServers args array is.

Deepest path and highest index first makes each deletion independent of the
ones after it.
2026-09-23 17:27:31 -07:00
branderson 4425d83d88 Refuse a negative Content-Length instead of reading to EOF
int() accepts a negative, a negative passes the `> MAX_BODY_BYTES` test, and a
buffered reader takes read(-1) as "until EOF" - so `Content-Length: -1` walked
past the cap and read an unbounded body into host RAM. The cap's own comment
says it is there because this runs on the host as the operator's user.

The same check now covers chunk sizes, which had the same shape.

A value that is not a number is deliberately left to raise. The suppression
around handle_error is meant to be specific to the client going away, and the
test that proves it uses a non-hex chunk length as the genuine fault that must
still surface - refusing it cleanly here would remove that canary. The bug was
the value that passes the cap and then reads to EOF, not untidy input.
2026-09-23 17:27:31 -07:00
branderson c73fb4702b Install the git shadow always, because an empty host list refuses
DEJIMA_PUSH_HOSTS fails closed: empty means no host may be pushed to, and the
hook's header says that is how a no-forge or non-pushing workflow becomes
enforced rather than merely described. The entrypoint linked the shadow only
when that list or the blocked-branch list was non-empty, so the guard was
absent in exactly the case it exists for.

The shipped default workflow is local-only, which sets push off and no blocked
branches, so that was the ordinary launch rather than a corner. With
claude-code the PreToolUse hook still covered it. cline declares no guards, on
the stated reasoning that the PATH shadows hold regardless - so a cline
local-only session had no push guard of any kind, while the launch report told
the operator the only gap was a full-path git invocation.

This reverses a deliberate decision, and the test that pinned it now carries
the reversal and the reason rather than being deleted. Leaving PATH as the
image left it is right for a shadow with nothing to enforce; the forge shadow
stays conditional for that reason, since an unset merge block genuinely blocks
nothing. A fail-closed list is the opposite case.
2026-09-23 17:27:31 -07:00
branderson 896773bfc5 Merge pull request 'Give the remote access page a favicon' (#362) from web-favicon into development
Reviewed-on: #362
Reviewed-by: branderson <brad@branderson.io>
2026-09-17 18:04:38 -07:00
branderson 8b579fdd59 Merge pull request 'Tell a session that a hang is not the allowlist' (#360) from briefing-refusal into development
Reviewed-on: #360
Reviewed-by: branderson <brad@branderson.io>
2026-09-17 18:04:03 -07:00
branderson f44fe47657 Give the remote access page a favicon
tests / confined (conversation) (pull_request_target) Successful in 1m6s
tests / confined (proxy) (pull_request_target) Successful in 1m15s
tests / confined (providers) (pull_request_target) Successful in 1m59s
tests / confined (launch) (pull_request_target) Successful in 2m16s
tests / confined (share) (pull_request_target) Successful in 1m56s
tests / runner-env (confined) (pull_request_target) Successful in 52s
tests / confined (remote) (pull_request_target) Successful in 2m48s
tests / confined (shellcheck) (pull_request_target) Successful in 1m18s
tests / confined (project) (pull_request_target) Successful in 3m16s
tests / confined (audit) (pull_request_target) Successful in 3m37s
tests / confined (static) (pull_request_target) Successful in 3m0s
tests / confined (config) (pull_request_target) Successful in 4m57s
tests / privileged (pull_request_target) Successful in 6m9s
tests / runner-env (privileged) (pull_request_target) Successful in 49s
Inlined as a data URI, like everything else on it: ttyd serves one file and
answers no path a /favicon.ico could be fetched from, so a link to one would
404 and the tab would keep the generic icon.

build.mjs hashes its manifest from bytes rather than from a utf8 read, so a
binary source can sit in the list beside the text ones. Every existing hash
is unchanged by that - hashing a utf8 string hashes exactly those bytes -
which is checked rather than assumed: only template.html's entry moved, and
only because this commit edits it.

"the terminal page fetches nothing at page load" refused the new link, and
its reasons - ttyd serves one file, the sandbox restricts outbound traffic -
are both about fetching. A data URI carries its bytes in the attribute and
fetches nothing, so it is exempt and nothing else is. The match is now per
attribute rather than per line, so a data URI cannot carry a real reference
past the check beside it, and the test builds a copy with a real external
reference to prove the exemption did not open a hole.
2026-09-17 16:54:38 -07:00
branderson 378ad85d8e Tell a session that a hang is not the allowlist
tests / confined (conversation) (pull_request_target) Successful in 1m4s
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (providers) (pull_request_target) Successful in 2m0s
tests / confined (launch) (pull_request_target) Successful in 2m16s
tests / confined (share) (pull_request_target) Successful in 2m1s
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 1m20s
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 3m22s
tests / confined (config) (pull_request_target) Successful in 4m58s
tests / privileged (pull_request_target) Successful in 6m16s
tests / runner-env (privileged) (pull_request_target) Successful in 51s
The policy rejects rather than drops, and its own chain comment gives the
reason: silently dropping would make every blocked destination look like a
hang. The briefing then described both as failing to connect, which throws
that signal away.

Two agents reported the same non-problem in one day as a result - a service
on the operator's network reachable only on a port their own firewall
exposes, met as a timeout, reported as dejima blocking an allowlisted
domain. From inside a sandbox the two are indistinguishable except by
timing: a refusal here is immediate, and a timeout is something past the
boundary.

The test pins the advice to the behaviour it rests on, so a policy that
started dropping would fail the test that teaches the distinction rather
than leaving a briefing that quietly became wrong.
2026-09-17 12:03:43 -07:00
branderson 6522ad56e0 Merge pull request 'Finish the release's documentation review, and two things it found in code' (#359) from release-followups into development
tests / confined (conversation) (pull_request_target) Successful in 1m4s
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (providers) (pull_request_target) Successful in 1m58s
tests / confined (launch) (pull_request_target) Successful in 2m16s
tests / confined (share) (pull_request_target) Successful in 1m57s
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 1m18s
tests / confined (project) (pull_request_target) Successful in 3m15s
tests / confined (audit) (pull_request_target) Successful in 3m34s
tests / confined (static) (pull_request_target) Successful in 3m1s
tests / confined (config) (pull_request_target) Successful in 4m50s
tests / privileged (pull_request_target) Successful in 5m57s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
Reviewed-on: #359
Reviewed-by: branderson <brad@branderson.io>
2026-09-17 01:29:05 -07:00
branderson b91b8f5fb3 Map fixtures and dejima-allow to the suites that actually exercise them
tests / confined (config) (pull_request_target) Successful in 4m28s
tests / confined (conversation) (pull_request_target) Successful in 1m11s
tests / confined (launch) (pull_request_target) Successful in 2m11s
tests / confined (project) (pull_request_target) Successful in 2m54s
tests / confined (providers) (pull_request_target) Successful in 1m59s
tests / confined (proxy) (pull_request_target) Successful in 1m19s
tests / confined (remote) (pull_request_target) Successful in 2m35s
tests / confined (share) (pull_request_target) Successful in 1m22s
tests / confined (static) (pull_request_target) Successful in 2m30s
tests / confined (shellcheck) (pull_request_target) Successful in 1m12s
tests / privileged (pull_request_target) Successful in 6m44s
tests / runner-env (confined) (pull_request_target) Successful in 51s
tests / runner-env (privileged) (pull_request_target) Successful in 48s
tests / confined (audit) (pull_request_target) Successful in 1m49s
test/fixtures/fake-anthropic.py is executed as proxy.bats' upstream, but
test/* mapped every non-bats test file to static alone, so a fixture-only
change ran a suite that never touches it and skipped the one that does.
bin/dejima-allow likewise: static.bats asserts on its class wiring, and the
project-only rule skipped the suite holding that guard.

runtime.bats consumes the fixture too, but no confined selection can carry
it - that half stays CI's.
2026-09-16 17:21:51 -07:00
branderson e9e1c2bc52 Walk the whole image for file capabilities, not a union of named roots
Setuid gets a whole-filesystem find in the derived-image test; capabilities
were covered only inside the sweeper's own roots plus the four directories it
skips, so one at a top-level path nobody enumerated was asserted by nothing.
-xdev keeps the walk to the image itself - /proc, /sys and the bind mounts
are their own filesystems - and a missing getcap prints a marker rather than
an empty result that reads as clean.

Not run here: test/runtime.bats needs Docker. CI's privileged job is what
exercises it.
2026-09-16 17:21:01 -07:00
branderson cb793edead Assert the connection log's verdict values, not the field's presence
The verdict rides on which NFLOG group a rule named, and nothing asserted
either value - the two groups swapped would have logged every refusal as
"allowed" with the suite green, and a supervisor reading the log would
believe it. The refused probe now has to be recorded as blocked, and a
permitted attempt - the bridge gateway, which is in @host_v4 whether or not
the host answers on the port - has to be recorded as allowed, closing the
half the comment promised and the test never drove.

Not run here: this is test/runtime.bats, which needs Docker. CI's privileged
job is what exercises it.
2026-09-16 17:19:57 -07:00
branderson 832c4a4f6d Fold case in record_entries' exemption too, the fourth site of four
df7a744 fixed the comparison in build_entries; the exemption decision one
function up still compared INTERNAL_OK to the entry verbatim, on case and on
a trailing dot. A pair spelled differently passed the launcher's folded check
and build_entries' folded check, was recorded strict here, and the boot then
refused the name's internal answer - telling the operator to configure the
exemption they had already configured.

The existing test now spells the two sides apart in case and dot both, and
asserts the exemption that was recorded rather than only that the boot
proceeded.
2026-09-16 17:17:58 -07:00
branderson 0daae02833 Anchor the exit audit to the lines the proxy writes, not phrases in them
The classifier asked whether 'answered N' appeared anywhere and called every
log without one a credential failure. Both halves were wrong. The log carries
text chosen elsewhere - an upstream error-body prefix, an exception echoing
the session's request path - so a token endpoint whose 400 body said
'answered 503' minted a status count and the retry caveat for a session that
made no request. And a log whose only lines were connection failures was
reported as the session being unable to authenticate, sending an operator to
log in again over a network problem.

Three sections now, each on its own evidence: statuses counted from the
proxy's anchored 'upstream answered N for' lines, the credential section from
its 'cannot load|refresh credentials:' lines plus the remedy the proxy writes
after them, and anything else shown under a header that claims neither. A
session that earned both gets both.

The two new cases are driven like the existing pair, against logs the proxy
really wrote - the fixture's oauth-reject gained ?say= so a test can choose
the upstream's text.
2026-09-16 17:16:03 -07:00
branderson a88393f769 Keep the credential proxy's log out of the directory the session mounts
docs/security.md's new rule says what the proxy learned from the provider
stays on the host - and the log holds exactly that, statuses and error-body
prefixes, while living in the socket directory that is bind-mounted read-only
into the session. The session user's uid is the host user's, so the 0644 log
inside the 0700 mount was readable from inside, including token-endpoint
error bodies the container-facing message deliberately withholds.

The log is its own mktemp file now, removed by the exit trap, and the trap
never inherits its name from the environment - same rule as the other names
cleanup() removes but does not create.
2026-09-16 17:11:33 -07:00
branderson b8c34bb98c Make the privilege-sweep test require the sweep, not the spelling
tests / confined (conversation) (pull_request_target) Successful in 1m8s
tests / confined (proxy) (pull_request_target) Successful in 1m14s
tests / confined (providers) (pull_request_target) Successful in 1m57s
tests / confined (launch) (pull_request_target) Successful in 2m14s
tests / confined (share) (pull_request_target) Successful in 1m53s
tests / runner-env (confined) (pull_request_target) Successful in 51s
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 3m13s
tests / confined (audit) (pull_request_target) Successful in 3m33s
tests / confined (static) (pull_request_target) Successful in 2m57s
tests / confined (config) (pull_request_target) Successful in 4m50s
tests / privileged (pull_request_target) Successful in 6m38s
tests / runner-env (privileged) (pull_request_target) Successful in 48s
Both halves were substring greps, and the sweeper's own path appears in the
line that chmods it. So a Dockerfile whose last instruction only copies or
chmods the sweeper satisfied the test, and so did a derived layer whose
invocation had been commented out - the sweep could be disabled with the test
green, leaving Debian's setuid set in the image with no_new_privs as the only
lock, which the sweeper's header declines to rely on.

It now asks whether the sweeper is invoked as a command, and reads the
derived Dockerfile the generator produces rather than grepping for a string
in the generator, since the order relative to the layer's own installs is the
whole property.

My first attempt at this was the same defect again - a tighter regex that
still matched `chmod +x <path>`. Three mutations, all of which had passed:
the base invocation removed, the derived one commented out, and the derived
one moved above its installs.

The runtime check also gained the directories the sweeper deliberately does
not walk. --check covers the sweeper's own roots, which is right for proving
the sweep worked and leaves nothing asserting that the image ships no file
capability outside them; the whole-filesystem getcap this replaced did cover
them.
2026-09-16 01:20:40 -07:00
branderson df7a74481c Fold case where the policy checks internal_domains, the third site of three
dejima_class_for and dejima_mark_internal fold; build_entries compared
verbatim. So a pair the launcher had already accepted was refused one layer
down, with a message saying the name was permitted by nothing - a correct
configuration that will not start, over a spelling difference DNS does not
recognise.
2026-09-16 01:17:54 -07:00
branderson 337b2bce04 Route every entry into the open class through the class rule, not just one
fixed-domains.sh said the forge class wins wherever the two overlap. Only the
approved-domains loop asked. The built-in open list carries
release-assets.githubusercontent.com, which sits under a built-in forge
suffix, so with the shipped defaults it kept the open class and took a
resolver line more specific than the suffix entry - feeding the set with no
port restriction.

The 443 clamp then rests on the forge's published ranges covering that
address, which is the one-layer-where-there-are-two that firewall-up.sh's own
comment warns about. The ranges do cover it today. The last release did not
depend on that: with no line of its own, the name fell to the suffix entry
and was restricted.

Every route into the open class is normalised now, so a harness or backend
domain under a forge suffix cannot arrive the same way. The comment states
the mechanism rather than asserting the outcome.

The test drives the composition with nothing else set, because the shipped
defaults are the configuration every operator has before declaring anything,
and that was the case nothing covered.
2026-09-16 01:17:05 -07:00
branderson d8845206c4 Finish the release's documentation review, and two things it found in code
tests / confined (conversation) (pull_request_target) Successful in 1m0s
tests / confined (proxy) (pull_request_target) Successful in 1m13s
tests / confined (providers) (pull_request_target) Successful in 2m8s
tests / confined (launch) (pull_request_target) Successful in 2m23s
tests / confined (share) (pull_request_target) Successful in 2m14s
tests / runner-env (confined) (pull_request_target) Successful in 1m0s
tests / confined (remote) (pull_request_target) Successful in 3m13s
tests / confined (shellcheck) (pull_request_target) Successful in 1m26s
tests / confined (project) (pull_request_target) Successful in 3m46s
tests / confined (audit) (pull_request_target) Successful in 4m14s
tests / confined (static) (pull_request_target) Successful in 3m47s
tests / confined (config) (pull_request_target) Successful in 5m43s
tests / privileged (pull_request_target) Successful in 6m22s
tests / runner-env (privileged) (pull_request_target) Successful in 50s
The documentation pass that ran mid-release checked what existed then. Four
pull requests landed after it. These are what they left behind.

The exit audit announced every proxy log as "The model API answered with an
error this session", counted statuses out of it, and closed by saying a
retried one may still have succeeded. A credential that could not be loaded
produces no status line at all, because no request was made - so that case
printed the header, no counts, and an invitation to hope. All three are false
for exactly the failure the credential work routes there deliberately. It now
says what happened and prints the one line that says what to do.

permit_name still spelled out its own set name, which is the fourth place
deciding which set a class feeds and the one set_for_class's comment claimed
did not exist. A live add looking in a different set from the one being
filled reports a permitted name as unreachable, or the reverse. The static
test now refuses any spelling of a set name outside that helper, rather than
naming the three sites it knew about.

Documentation: the rule the credential proxy's comment cites - what may cross
into a session and what may not - was not written anywhere, and the change
that made a repo-authored remedy cross needed it to be. The boot check that
now refuses a name whose address is missing from its class's set was
undocumented, as was the widening of the launch check from counting dots to
asking for a host name. And an approval granted before the two-label rule now
stops a launch with no remove verb to clear it, so the file to edit is named.

The audit's two paths are driven against logs this proxy really wrote, rather
than lines written in the test, because the classification only works if the
two files agree about what a line looks like.
2026-09-16 00:23:48 -07:00
branderson c0935a52c3 Merge pull request 'Name the login that fixes an expired credential, where it is met' (#358) from cred-expiry 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 1m59s
tests / confined (launch) (pull_request_target) Successful in 2m15s
tests / confined (share) (pull_request_target) Successful in 1m58s
tests / runner-env (confined) (pull_request_target) Successful in 53s
tests / confined (remote) (pull_request_target) Successful in 2m49s
tests / confined (shellcheck) (pull_request_target) Successful in 1m19s
tests / confined (project) (pull_request_target) Successful in 3m16s
tests / confined (audit) (pull_request_target) Successful in 3m36s
tests / confined (static) (pull_request_target) Successful in 3m2s
tests / confined (config) (pull_request_target) Successful in 4m49s
tests / privileged (pull_request_target) Successful in 5m40s
tests / runner-env (privileged) (pull_request_target) Successful in 47s
Reviewed-on: #358
Reviewed-by: branderson <brad@branderson.io>
2026-09-15 23:29:49 -07:00
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 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
73 changed files with 11485 additions and 2918 deletions
+104 -40
View File
@@ -80,6 +80,7 @@ jobs:
matrix:
suite:
- static
- launch
- project
- remote
- audit
@@ -94,14 +95,6 @@ jobs:
# needs to run at all, and apt-get is what everything below needs.
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
@@ -113,7 +106,8 @@ jobs:
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
@@ -146,7 +140,11 @@ jobs:
- name: Install test dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates
# iproute2 for `ss`: the one test asserting dejima-ttyd refuses to
# start when something already holds its port skips without it, and a
# permanent skip on the check guarding a reverse-proxy port takeover
# is the same as not having it.
apt-get install -y --no-install-recommends bats jq python3 tmux ca-certificates iproute2
# The code under test, named explicitly - see the header. Without `ref`
# this checks out the base branch and the suite passes without ever
# seeing the change.
@@ -250,14 +248,6 @@ jobs:
# nothing to do with what the test asserts. That test is the containment
# argument for the ttyd front door, so it is not one to skip in CI.
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
@@ -269,7 +259,8 @@ jobs:
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
@@ -327,9 +318,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,
@@ -380,7 +392,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.
@@ -420,6 +432,58 @@ jobs:
fi
echo "GitHub ranges lookup is authenticated - ceiling is $limit requests/hour."
# One fetch for the whole run, into the directory the job already shares
# with the containers it starts. test/runtime.bats starts twenty-seven
# container pairs and each filter fetched the ranges for itself, which is
# roughly twenty-seven requests in five minutes - comfortably inside the
# 5000/hour a token allows, and exactly the burst GitHub's SECONDARY limit
# answers with a 403 (#357). One request cannot be a burst.
#
# Best-effort: a failure here leaves no file, and runtime.bats falls back
# to each container fetching for itself. That is the behaviour this
# replaces, so the worst case is the status quo rather than a red job -
# and the step says which of the two the run is getting.
- name: Fetch the GitHub ranges once for the whole run
env:
GH_TOKEN: ${{ secrets.GH_META_TOKEN }}
run: |
# POSIX sh, like every other run: block here - the runner's default
# shell is `sh -e`, where an array is a syntax error and the step dies
# before any of this runs. Two curl branches rather than one with a
# built-up argument list, for the same reason.
mkdir -p /tmp/dejima-ci
out=/tmp/dejima-ci/github-meta.json
rm -f "$out" "$out.part"
fetched=0
if [ -n "${GH_TOKEN:-}" ]; then
if curl -fsS --max-time 30 -H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/meta -o "$out.part" 2>/dev/null; then
fetched=1
fi
else
if curl -fsS --max-time 30 \
https://api.github.com/meta -o "$out.part" 2>/dev/null; then
fetched=1
fi
fi
# Written only once it parses AND carries ranges, because a rate-limit
# body is a 200 with a message and no ranges - and a cached one would
# then fail every container in the run instead of one.
ranges=""
if [ "$fetched" = 1 ]; then
ranges="$(jq -r '[.git[]?, .api[]?, .web[]?] | unique | .[]' < "$out.part" 2>/dev/null || true)"
fi
if [ -n "$ranges" ]; then
mv "$out.part" "$out"
echo "Cached GitHub ranges for this run: $(printf '%s\n' "$ranges" | wc -l) CIDRs."
else
rm -f "$out.part"
echo "Could not cache the ranges, so each container will fetch its own."
echo "That is the pre-#357 behaviour, not a new failure - but it is what"
echo "trips the secondary limit, so a 403 in a container boot below is"
echo "explained by this line."
fi
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
@@ -446,11 +510,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
@@ -500,14 +578,6 @@ jobs:
container:
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
@@ -519,7 +589,8 @@ jobs:
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
@@ -571,14 +642,6 @@ jobs:
container:
image: node:20-bookworm
steps:
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
# allowlist, not the package - and ci-runner.bats, which would say so
# precisely, cannot run yet because installing it is what just failed.
#
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
# Before apt, because apt's failure for this says "Unable to locate
# package bats" and nothing about why. On a runner behind the egress
# allowlist a name that does not resolve is the resolver or the
@@ -590,7 +653,8 @@ jobs:
# job on a single lookup would be stricter than the step it is
# protecting. Six attempts over ~15s, then a diagnosis.
#
# Uses only what the image already has: getent is libc.
# Uses only what the image already has: getent is libc, and the
# resolver's address is in resolv.conf.
- name: Check the job network can resolve before installing anything
run: |
for attempt in 1 2 3 4 5 6; do
+31 -27
View File
@@ -160,8 +160,8 @@ layers below the container rather than into a harder one.
This repo only holds the tooling; no project lives here. Point it at a project
on disk and that project is bind-mounted into the container at runtime.
**Prerequisites:** Docker with the `docker compose` v2 plugin, and `python3`
(stdlib only, no packages) for the host-side credential proxy.
**Prerequisites:** Docker with the `docker compose` v2 plugin, `jq`, and
`python3` (stdlib only, no packages) for the host-side credential proxy.
Only the first block below is required. The rest are independent, so take the
ones you want.
@@ -184,7 +184,9 @@ ones you want.
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.
it, which is what stops a later release changing your selection for you. The
starter config states `claude-code`, because a file with no default to fall
back on has to name a choice; change that line for Cline.
That is enough to run `dejima`, and no push credential is needed to get this
far. Everything else runs at its default, which means `[core] forge = none` and
@@ -293,9 +295,10 @@ dejima --ro ~/other-project ~/myproject
**→ [docs/configuration.md](docs/configuration.md#one-off-mounts-for-a-single-launch)** -
both forms, and which host paths a mount is refused over and why.
**Ctrl-Z backgrounds all of dejima**, and `fg` brings back a working session.
It makes no progress while suspended, though, so for one that should keep
working while you are away, run dejima inside tmux and use
**Ctrl-Z backgrounds dejima's host side**, and `fg` brings back a working
session. It gets your terminal back rather than pausing the agent - the
container keeps running - so for a session you want to leave working and pick
up elsewhere, run dejima inside tmux and use
[`dejima-attach`](docs/remote-access.md).
→ [why that takes doing](docs/configuration.md#suspending-a-session)
@@ -398,11 +401,13 @@ behind each of these, and the fork-and-review workflow in full.
## Network egress firewall
Outbound access is default-deny: the container builds an allowlist-only
`iptables`/`ipset` firewall at start, and anything not explicitly allowed is
rejected outright rather than left to time out, so a session is told when
something doesn't connect. IPv6 is blocked outright, with no allowlist
equivalent for it.
Outbound access is default-deny: the filter container establishes an
allowlist-only `nftables` policy at start, and anything not explicitly allowed
is rejected outright rather than left to time out, so a session is told when
something doesn't connect rather than waiting out a timeout and reporting a
broken tool. IPv6 is refused outright rather than allowlisted: a sandbox has no
IPv6 egress at all, and one table states that in the same transaction as
everything else, so there is no moment where the family is partly filtered.
The allowlist covers what the sandbox needs to function - DNS, the Docker host,
GitHub on port 443 for read access whichever forge is selected, whatever the
@@ -428,8 +433,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
@@ -501,7 +506,7 @@ at a fixed host path.
`.dejima.md` at a project's root is that project's agent-instruction file, the
harness-agnostic equivalent of `CLAUDE.md`, loaded into the session's context
with no authority over the sandbox itself. A `SessionStart` hook also briefs the
with no authority over the sandbox itself. dejima also briefs the
session on where it is running and on the instructions belonging to whichever
workflow you selected, so a session under `local-only` is told something
different from one under `worktree-pr`.
@@ -531,13 +536,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
@@ -557,9 +565,8 @@ 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
that repo. Its diff is time-windowed: a finding says a path changed while the
session was running, not that the session changed it. Findings are a time
window, not a verdict on who caused them.
that repo. Its findings are a time window, not a verdict on who caused them:
one says a path changed while the session was running.
`dejima-audit show` indexes what is waiting and `dejima-audit ack` walks it, one
finding at a time. `[audit] strict` refuses a headless launch while CRITICAL
@@ -710,6 +717,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.
+597 -25
View File
@@ -645,6 +645,10 @@ unset _tools_wanted _tool
AUDIT_DIR="$(config_get audit dir)"
BASELINE_DIR="$(config_get audit baselines_dir)"
# Read before either guard runs, because what this list declares becomes
# something the other two lists are refused for naming - see PP_PROJECT_ROOTS.
SANDBOX_RW_PP_MOUNTS="$(config_get mounts read_write_per_project)"
SANDBOX_EXTRA_MOUNTS="$(config_get mounts readonly)"
SANDBOX_EXTRA_MOUNTS="$(_append_mounts "$SANDBOX_EXTRA_MOUNTS" \
${EXTRA_RO_FLAGS[@]+"${EXTRA_RO_FLAGS[@]}"})"
@@ -745,6 +749,30 @@ _harness_host_config
# arrives at ~/shared without going through this list; naming the root that
# holds every project's would put the pile back, which is the thing the split
# removed.
# Refuse $2 if it reaches a per-project root. Its own check rather than a row
# in GUARDED_STORES, because the generic refusal there is about approval and
# audit stores and says so - an operator who wrote the same path into two
# mount lists would be told their specs directory lets a session approve its
# own domains, which is both untrue and no help.
_refuse_pp_root() {
local label="$1" path="$2" real root root_real
_pp_project_roots
[ ${#PP_PROJECT_ROOTS[@]} -gt 0 ] || return 0
real="$(readlink -m "$path")"
for root in "${PP_PROJECT_ROOTS[@]}"; do
root_real="$(readlink -m "$root")"
_paths_overlap "$real" "$root_real" || continue
echo "Refusing $label: $path" >&2
echo "It reaches $root_real, which holds one directory per project for a" >&2
echo "[mounts] read_write_per_project entry." >&2
echo "" >&2
echo "Handing that over is handing over every project's, which is the pile" >&2
echo "slicing it removed. If this path is meant to be shared by every" >&2
echo "project, it belongs in one list or the other and not in both." >&2
exit 1
done
}
GUARDED_STORES=()
_guarded_stores() {
[ ${#GUARDED_STORES[@]} -eq 0 ] || return 0
@@ -753,6 +781,28 @@ _guarded_stores() {
"$(share_projects_root "$(config_get core share_dir)")")
}
# The `projects` subdirectory of every read_write_per_project root: the
# directory that holds one slice per project, exactly as share_dir's does.
#
# Guarded separately from the stores above because the two lists need
# different answers. For `readonly` and `read_write`, naming one of these is
# the mistake the share guard already names: handing over the root that holds
# every project's directory puts back the pile the split removed. For the
# per-project list itself it is not a mistake at all - it is where that list
# puts things - so that loop consults GUARDED_STORES alone.
PP_PROJECT_ROOTS=()
_pp_project_roots() {
[ ${#PP_PROJECT_ROOTS[@]} -eq 0 ] || return 0
local path
[ -n "$SANDBOX_RW_PP_MOUNTS" ] || return 0
IFS=':' read -ra _pp_declared <<< "$SANDBOX_RW_PP_MOUNTS"
for path in "${_pp_declared[@]}"; do
[ -n "$path" ] || continue
PP_PROJECT_ROOTS+=("$(projects_root "$path")")
done
unset _pp_declared
}
# Refuse $2 if it overlaps any of the paths in $3.., in either direction: an
# ancestor of a store exposes it just as surely as the store itself.
_refuse_guarded_mount() {
@@ -802,6 +852,7 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
# epitaph store be mountable from here after it was added to one of
# them. A store added to _guarded_stores is now guarded on both
# paths without anyone remembering to do it twice.
_refuse_pp_root "$(_mount_label "$path" "[mounts] read_write entry" "--extra-read-write path")" "$path"
_guarded_stores
for guarded in ${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"} \
${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"} \
@@ -837,6 +888,78 @@ if [ -n "$SANDBOX_RW_MOUNTS" ]; then
done
fi
# [mounts] read_write_per_project: the same idea as read_write, sliced the way
# [core] share_dir is. An operator declares ~/specs; the container gets ~/specs,
# backed by ~/specs/projects/<slug> on the host rather than by the whole thing.
#
# Which keeps the property that separates these lists from share_dir - a mount
# arrives at the host path it names, so a project instruction saying "put specs
# in ~/specs" goes on working - while removing the one share_dir was split to
# remove: every session seeing every other project's, across projects. A spec
# is written to be read as background, which makes that threat-model item 4
# with nobody attacking anything.
#
# Its own key rather than a marker on read_write, and that is what makes it
# small: a key that did not previously parse has nothing in it on the day it
# lands. Nothing moves, nothing is stranded, and there is no migration. Some
# writable mounts are meant to be shared - a cache, a bin directory - and
# read_write stays right for those.
RW_PP_MOUNT_SOURCES=()
RW_PP_MOUNT_TARGETS=()
DEJIMA_RW_PP_MOUNTS=""
# Every host path this launch makes writable, which for a plain read_write
# entry is the path itself and for a per-project one is the slice. The audit
# reads this; the briefing reads the two target lists.
DEJIMA_RW_SOURCES="$DEJIMA_RW_MOUNTS"
if [ -n "$SANDBOX_RW_PP_MOUNTS" ]; then
IFS=':' read -ra rw_pp_paths <<< "$SANDBOX_RW_PP_MOUNTS"
for path in "${rw_pp_paths[@]}"; do
[ -n "$path" ] || continue
# The declared root must exist, like a read_write entry: dejima creates
# the slice below it, never the root itself, so a typo stays a typo
# rather than becoming a new empty directory nobody meant.
if [ ! -d "$path" ]; then
echo "[mounts] read_write_per_project entry is not an existing directory: $path" >&2
echo "dejima creates the per-project directory inside it, but not the" >&2
echo "directory itself - so a path that is not there is a typo, not a" >&2
echo "layout to be built." >&2
exit 1
fi
pp_real="$(readlink -m "$path")"
# GUARDED_STORES alone, deliberately: PP_PROJECT_ROOTS holds this
# list's own `projects` directories, which the other two lists are
# refused for naming and this one exists to write into.
_guarded_stores
for guarded in ${GUARDED_STORES[@]+"${GUARDED_STORES[@]}"} \
${HARNESS_HOST_CONFIG[@]+"${HARNESS_HOST_CONFIG[@]}"} \
"$HOME/.local/bin" "$REPO_ROOT" "$WORKSPACE_DIR"; do
guarded_real="$(readlink -m "$guarded")"
_paths_overlap "$pp_real" "$guarded_real" || continue
echo "Refusing [mounts] read_write_per_project entry: $path" >&2
echo "It overlaps $guarded_real, which dejima needs out of a session's reach." >&2
echo "Slicing it per project does not change what the session could reach" >&2
echo "through it - only how much of it there is." >&2
exit 1
done
# Resolved here and created later, on the launch path. This block runs
# before --print-briefing so the briefing it composes names the same
# mounts a launch would, and a diagnostic that prints something should
# not leave a directory behind - which is why share_dir's own mkdir
# sits after that exit too.
pp_source="$(project_dir_for "$path" "$WORKSPACE_DIR")"
RW_PP_MOUNT_TARGETS+=("$path")
RW_PP_MOUNT_SOURCES+=("$pp_source")
# Two lists, because the two readers want different answers and for a
# per-project mount they differ. The briefing tells the SESSION what it
# can write, which is the path inside the container. The exit audit
# tells the OPERATOR what on their host this session could reach, and
# naming the root there would claim a blast radius this feature exists
# to have narrowed.
DEJIMA_RW_PP_MOUNTS="${DEJIMA_RW_PP_MOUNTS:+$DEJIMA_RW_PP_MOUNTS }$path"
DEJIMA_RW_SOURCES="${DEJIMA_RW_SOURCES:+$DEJIMA_RW_SOURCES }$pp_source"
done
fi
# --print-briefing composes and exits, the same way --print-config does -
# before anything is built or mounted, and before a workspace argument is
# required. It exists so the composed briefing can be inspected (and, per
@@ -859,12 +982,12 @@ DEJIMA_WORKFLOW_PUSH="$(manifest_get workflow "$DEJIMA_WORKFLOW_NAME" push)"
# A forge declaring hosts_allowlisted_by_core says its hosts are ones core
# already allowlists in its own right - the GitHub ranges every session can
# read from over 443. Those must NOT reach DEJIMA_FORGE_HOSTS, which
# entrypoint.sh folds into EXTRA_ALLOWED_DOMAINS: that path resolves a name
# into the port-UNRESTRICTED allowed-domains ipset and exempts it from
# dnsmasq's stop-dns-rebind, both of which are deliberate for an internal,
# self-hosted forge host and both of which are a widening for a public one.
# It would also make init-firewall.sh's startup self-check ("port 22 was
# reachable without the push grant") fail, killing the container outright.
# firewall-up.sh puts in the open class: that path permits a name on every
# port and exempts it from the check that refuses an answer pointing inside a
# private network, both of which are deliberate for an internal, self-hosted
# forge host and both of which are a widening for a public one. It would also
# make the policy's startup self-check ("port 22 was reachable without the
# push grant") fail, killing the container outright.
#
# They still reach DEJIMA_PUSH_HOSTS: git-push-guard.sh's allowlist is a
# separate question from which names the firewall resolves, and a push to a
@@ -884,6 +1007,12 @@ if [ "$(manifest_get forge "$DEJIMA_FORGE_NAME" hosts_allowlisted_by_core)" = "o
else
DEJIMA_FORGE_HOSTS="$_forge_all_hosts"
fi
# Exported as well as passed to the sandbox with `-e NAME=value`. The filter
# container is started with `docker run` and takes this the `-e NAME` way, so
# it has to be in this process's environment - and that form is what keeps a
# value out of a command line, which is the rule the credential beside it in
# that list follows.
export DEJIMA_FORGE_HOSTS
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ]; then
DEJIMA_PUSH_HOSTS="$_forge_all_hosts"
else
@@ -892,7 +1021,7 @@ fi
# --allow-github-push's actual effect: git-push-guard.sh only ever checks
# DEJIMA_PUSH_HOSTS, so granting it a github.com push means adding these
# hosts to that list, not just exporting ALLOW_GITHUB_PUSH - that variable
# reaches only init-firewall.sh's port-22 grant and its startup self-check,
# reaches only the egress policy's port-22 grant and its startup self-check,
# never the guard. Keyed to the
# flag alone, not to ALLOW_CORE_PUSH_GRANT: a forge whose own hosts these
# are has already contributed them above, and adding them twice would leave
@@ -1027,6 +1156,14 @@ if [ -n "$SANDBOX_SSH_KEY" ] && [ ! -f "$SANDBOX_SSH_KEY" ]; then
echo "[core] ssh_key is set but not a file: $SANDBOX_SSH_KEY" >&2
exit 1
fi
# An empty file is not a key: it passes the check above and loads nothing in the
# container. Treated as no key at all rather than refused, because the only
# launch it costs anything is one that pushes - and that case is Rule 4's
# below, which already says what to do about having neither.
have_usable_key=1
if [ -z "$SANDBOX_SSH_KEY" ] || [ ! -s "$SANDBOX_SSH_KEY" ]; then
have_usable_key=0
fi
if [ -n "$SANDBOX_SSH_KEY" ]; then
key_perms=$(stat -c '%a' "$SANDBOX_SSH_KEY")
if [ "${key_perms: -2}" != "00" ]; then
@@ -1056,7 +1193,10 @@ fi
# Rule 4: a workflow that never pushes has no use for either - skip the
# requirement entirely rather than force an operator doing local-only work
# to set up an agent or a deploy key for nothing.
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ -z "$SANDBOX_SSH_KEY" ]; then
if [ "$DEJIMA_WORKFLOW_PUSH" = "on" ] && [ "$have_forwarded_agent" = 0 ] && [ "$have_usable_key" = 0 ]; then
if [ -n "$SANDBOX_SSH_KEY" ]; then
echo "[core] ssh_key names an empty file, so there is no key to load: $SANDBOX_SSH_KEY" >&2
fi
echo "SSH_AUTH_SOCK is not set or not a valid socket, and [core] ssh_key is not set." >&2
echo "Either start ssh-agent and run ssh-add, or set [core] ssh_key in $(config_path) to a" >&2
echo "dedicated key for headless use - see docs/configuration.md." >&2
@@ -1240,7 +1380,13 @@ DEJIMA_STATE_DIR=""
# different statement: what the trap must never inherit.
SANDBOX_BRIEFING_FILE=""
SANDBOX_CREDENTIAL_PROXY_DIR=""
SANDBOX_CREDENTIAL_PROXY_LOG=""
SANDBOX_FAKE_CREDENTIALS_FILE=""
# The same rule, for two names cleanup() hands to `docker rm` rather than to
# `rm`. An inherited value there removes a container or a network belonging to
# something else on this host, which is a worse thing to get wrong than a file.
SANDBOX_FILTER_CONTAINER=""
SANDBOX_RUN_NETWORK=""
SANDBOX_NVIM_LOCKFILE=""
SANDBOX_DERIVED_DOCKERFILE=""
SANDBOX_DERIVED_CONTEXT=""
@@ -1353,28 +1499,76 @@ cleanup() {
# retries turns one condition into several identical lines - three
# 429s four seconds apart was the first real instance - and a list of
# them says no more than the count does.
printf '\n%sThe model API answered with an error this session:%s\n' \
"${C_WARN:-}" "${C_RESET:-}" >&2
#
# match() on the whole line rather than a loop over fields. A loop
# needs an index, and an awk index dereferenced inside single quotes
# is indistinguishable to the test that every name cleanup() uses is
# bound before the EXIT trap can fire - it reads one and reports an
# unbound name. That test is right to be strict, so this stays out of
# its way rather than asking it to parse quoting.
awk '{ if (match($0, /answered [0-9]+/))
n[substr($0, RSTART + 9, RLENGTH - 9)]++ }
#
# Anchored to the whole line the proxy writes - its prefix, then
# `upstream answered N for` or `cannot load|refresh credentials:`,
# the spellings in bin/dejima-credential-proxy - not to a phrase
# appearing anywhere. The log also carries text chosen elsewhere: an
# upstream error-body prefix, an exception echoing the session's
# request path. Classified on a floating match, a token endpoint
# whose error body said `answered 503` fabricated a status count
# here, and a session whose only lines were connection failures was
# reported as unable to authenticate. Each section prints on its own
# evidence, and a session can have earned both.
_audit_counts="$(awk '{ if (match($0, /^[0-9:]* dejima-credential-proxy: upstream answered [0-9]+ for /)) {
match($0, / answered [0-9]+ for /)
n[substr($0, RSTART + 10, RLENGTH - 15)]++ } }
END { for (s in n) printf " %d x HTTP %s\n", n[s], s }' \
"$SANDBOX_CREDENTIAL_PROXY_LOG" >&2
printf ' most recent: %s\n' \
"$(tail -n 1 "$SANDBOX_CREDENTIAL_PROXY_LOG" \
| sed 's/^[0-9:]* dejima-credential-proxy: //')" >&2
# Said because the count above cannot distinguish them: a harness that
# retried a 429 or a 5xx may well have got its answer in the end, so
# this is not on its own a session that failed.
printf ' A retried status may still have succeeded; this is what the API said,\n' >&2
printf ' not a verdict on the session.\n' >&2
"$SANDBOX_CREDENTIAL_PROXY_LOG")"
_audit_api_last="$(awk '{ if (match($0, /^[0-9:]* dejima-credential-proxy: upstream answered [0-9]+ for /))
line = $0 }
END { print line }' "$SANDBOX_CREDENTIAL_PROXY_LOG" \
| sed 's/^[0-9:]* dejima-credential-proxy: //')"
# The `cannot ...` line names what failed; the line the proxy writes
# right after it is the remedy, and is the one an operator can act on.
_audit_cred="$(awk '
{ if (match($0, /^[0-9:]* dejima-credential-proxy: cannot (load|refresh) credentials: /)) {
cred = $0; want = NR + 1; remedy = "" }
else if (NR == want) remedy = $0 }
END { if (cred != "") { print cred; if (remedy != "") print remedy } }' \
"$SANDBOX_CREDENTIAL_PROXY_LOG" \
| sed 's/^[0-9:]* dejima-credential-proxy: //; s/^/ /')"
_audit_last="$(tail -n 1 "$SANDBOX_CREDENTIAL_PROXY_LOG" \
| sed 's/^[0-9:]* dejima-credential-proxy: //')"
if [ -n "$_audit_counts" ]; then
printf '\n%sThe model API answered with an error this session:%s\n' \
"${C_WARN:-}" "${C_RESET:-}" >&2
printf '%s\n' "$_audit_counts" >&2
printf ' most recent: %s\n' "$_audit_api_last" >&2
# Said because the count above cannot distinguish them: a harness
# that retried a 429 or a 5xx may well have got its answer in the
# end, so this is not on its own a session that failed.
printf ' A retried status may still have succeeded; this is what the API said,\n' >&2
printf ' not a verdict on the session.\n' >&2
fi
if [ -n "$_audit_cred" ]; then
# The proxy logged a failure the API never answered - it could not
# load or refresh the credential, and no request was made. Saying
# "the model API answered with an error" there sends an operator to
# check the provider's availability, and the retry caveat above it
# invites them to hope it succeeded anyway. Neither is true.
printf '\n%sThe session could not authenticate to the model API:%s\n' \
"${C_WARN:-}" "${C_RESET:-}" >&2
printf '%s\n' "$_audit_cred" >&2
fi
if [ -z "$_audit_counts" ] && [ -z "$_audit_cred" ]; then
# Something else: a request that never reached the upstream, a
# body the proxy refused. Neither an API answer nor a credential
# failure, so this claims neither and shows the line instead.
printf '\n%sThe credential proxy reported a problem this session:%s\n' \
"${C_WARN:-}" "${C_RESET:-}" >&2
printf ' %s\n' "$_audit_last" >&2
fi
unset _audit_counts _audit_last _audit_cred _audit_api_last
fi
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" "${SANDBOX_BRIEFING_FILE:-}" || true
rm -f "$SANDBOX_SETTINGS_FILE" "$SANDBOX_CLAUDE_JSON_FILE" "${SANDBOX_NVIM_LOCKFILE:-}" "${SANDBOX_FAKE_CREDENTIALS_FILE:-}" "${SANDBOX_DERIVED_DOCKERFILE:-}" "${SANDBOX_BRIEFING_FILE:-}" "${SANDBOX_CREDENTIAL_PROXY_LOG:-}" || true
rm -rf "${SANDBOX_GITCONFIG_DIR:-}" "${SANDBOX_CREDENTIAL_PROXY_DIR:-}" "${SANDBOX_DERIVED_CONTEXT:-}" || true
rm -f "${DEJIMA_SESSION_FILE:-}" || true
# dejima-notify-watch keys its debounce stamps by run id. Removed here,
@@ -1399,6 +1593,21 @@ cleanup() {
# dejima-notify-watch's marker consumption.
rm -rf "${DEJIMA_NOTIFY_DIR:-}" \
|| echo "warning: could not remove the marker directory ${DEJIMA_NOTIFY_DIR:-}" >&2
# This launch's egress control, and the network under it. The sandbox has
# already exited by the time the trap runs, so nothing is left holding the
# namespace - Docker permits removing an owner from under a live joiner
# anyway, which is why the order here is deliberate rather than incidental.
#
# `|| true` on both, like every other removal in this trap: a Docker daemon
# that has gone away must not replace the session's exit status. What that
# leaves behind is inert - a filter container filters a namespace nothing
# is in - and a later launch reconciles it.
if [ -n "${SANDBOX_FILTER_CONTAINER:-}" ]; then
docker rm -f "$SANDBOX_FILTER_CONTAINER" >/dev/null 2>&1 || true
fi
if [ -n "${SANDBOX_RUN_NETWORK:-}" ]; then
docker network rm "$SANDBOX_RUN_NETWORK" >/dev/null 2>&1 || true
fi
exit_step "session ended - keeping what it compacted away"
# Keep whatever the session compacted away, into a store nothing mounts.
# `|| true` for the same reason every other line in this trap has it: a
@@ -1431,7 +1640,11 @@ cleanup() {
# read it, so this function's own cleanup can't race the audit that
# needs it - and its removal is guarded too, so it stays truly last.
exit_step "checking what changed on the host while it ran"
DEJIMA_RW_MOUNTS="$DEJIMA_RW_MOUNTS" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
# DEJIMA_RW_SOURCES, not DEJIMA_RW_MOUNTS: this report names what on the
# HOST the session could have written, and for a per-project mount that is
# the slice rather than the root it sits in. Naming the root would claim a
# blast radius the per-project split exists to have narrowed.
DEJIMA_RW_MOUNTS="$DEJIMA_RW_SOURCES" "$SCRIPT_DIR/dejima-audit" compare "$WORKSPACE_DIR" "$SANDBOX_AUDIT_STATE" || true
rm -f "$SANDBOX_AUDIT_STATE" || true
}
trap cleanup EXIT
@@ -1651,6 +1864,13 @@ export SANDBOX_GITCONFIG_DIR
# forwarded either - left empty here, docker-compose.yml's
# ${SSH_AUTH_SOCK:-/dev/null} mounts nothing.
[ "$DEJIMA_WORKFLOW_PUSH" = "on" ] || SSH_AUTH_SOCK=""
# And neither does a launch that configured its own key. entrypoint.sh loads
# that key into an agent of its own and points SSH_AUTH_SOCK at it, so the
# forwarded one was already unused - but it stayed mounted at the path
# docker-compose.yml names, where `SSH_AUTH_SOCK=/ssh-agent ssh ...` signs with
# the operator's personal identities. Choosing a dedicated key has to mean the
# personal agent is not in the container, not that it is merely not the default.
[ "$have_usable_key" = 0 ] || SSH_AUTH_SOCK=""
export SSH_AUTH_SOCK
export SANDBOX_SSH_KEY_FILE="$SANDBOX_SSH_KEY"
SSH_KNOWN_HOSTS="$(config_get core known_hosts)"; export SSH_KNOWN_HOSTS
@@ -1859,6 +2079,9 @@ _suggest_backends() { # <harness-section-name>
# declare still stops the launch.
DEJIMA_BACKEND_NAME=""
SANDBOX_BACKEND_PROXIED_DOMAIN=""
# The host of a backend the operator serves themselves, when the selected one
# has a base_url. Empty for every backend dejima proxies.
DEJIMA_BACKEND_HOST=""
DEJIMA_BACKEND_CREDENTIAL_KIND=""
DEJIMA_BACKEND_CREDENTIAL_FILE=""
DEJIMA_BACKEND_CREDENTIAL_FIELD=""
@@ -2031,6 +2254,29 @@ if [ -n "$DEJIMA_BACKEND_NAME" ]; then
if config_type "backend.$DEJIMA_BACKEND_NAME.credential_field" >/dev/null 2>&1; then
DEJIMA_BACKEND_CREDENTIAL_FIELD="$(config_get "backend.$DEJIMA_BACKEND_NAME" credential_field)"
fi
# A backend the operator serves rather than one dejima stands in front of:
# where it is comes from their own config, and the sandbox has to be able
# to reach it or the session has no model at all.
#
# Handed to the firewall path the way the forge's hosts and Loki's are.
# Without this the endpoint is reachable only if the operator ALSO approves
# its name as a domain - which works, and then looks like any other
# approved public name, so a self-hosted endpoint on their own network gets
# the answer check meant for names nobody reviewed. dejima configured this
# address; there is nothing for an answer to decide about it.
#
# Only for a backend dejima does NOT stand in front of, which today is the
# same set as "has a base_url" and is guarded rather than assumed. A
# backend with both would be proxied AND have its host permitted and
# exempted: that re-opens the DNS bypass leaving a proxied name
# unresolvable exists to close, and hands a public SaaS name the exemption
# fixed-domains.sh withholds from it on purpose. No manifest declares both,
# and this is what keeps that from being a coincidence.
if [ -z "$SANDBOX_BACKEND_PROXIED_DOMAIN" ] \
&& config_type "backend.$DEJIMA_BACKEND_NAME.base_url" >/dev/null 2>&1; then
DEJIMA_BACKEND_HOST="$(config_get "backend.$DEJIMA_BACKEND_NAME" base_url \
| sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')"
fi
fi
# Everything staged for the container, checked before anything is put back
@@ -2088,7 +2334,7 @@ SANDBOX_HARNESS_BRIEFING_PATH="$(manifest_get harness "$DEJIMA_HARNESS_NAME" bri
export SANDBOX_HARNESS_COMMAND SANDBOX_HARNESS_REAL SANDBOX_HARNESS_ARGS \
SANDBOX_HARNESS_SETTINGS SANDBOX_HARNESS_DOMAINS \
SANDBOX_BACKEND_PROXIED_DOMAIN SANDBOX_HARNESS_BRIEFING_ARGS \
SANDBOX_HARNESS_BRIEFING_PATH
SANDBOX_HARNESS_BRIEFING_PATH DEJIMA_BACKEND_HOST
# [core] command defaults to the selected harness's own command rather than
# to a literal in core's schema.
@@ -2101,6 +2347,86 @@ export SANDBOX_CMD
ENABLE_FIREWALL="$(config_get_bool core firewall)"; export ENABLE_FIREWALL
LOKI_URL="$(config_get logging loki_url)"; export LOKI_URL
# Approved names that genuinely resolve inside the operator's own network.
#
# The egress policy refuses an answer for a permitted name that points at a
# private address, because an answer decides what the allowlist contains and an
# answer comes from outside - so one influenced reply for a public name would
# otherwise be egress to an internal host. The configured forge and Loki are
# exempt without being listed here: dejima resolves their addresses itself, so
# there is nothing for an answer to decide. An approved domain is not, because
# a project's declared domains are reviewed with the project and expected to be
# public names.
#
# This is for the case where that expectation is wrong on purpose - an internal
# mirror, a service on the operator's LAN. It exempts and does not grant: a
# name here is reachable only if it is also approved, and firewall-up.sh stops
# the launch when one is not, rather than leaving an exemption attached to
# nothing. Colon-separated in the config file like every other list;
# space-separated from here on, which is what the firewall scripts read.
DEJIMA_INTERNAL_DOMAINS="$(config_get core internal_domains | tr ':' ' ')"
export DEJIMA_INTERNAL_DOMAINS
# Every host that becomes a permitted destination has to name at least two
# labels, checked here because here is where the operator can act on it.
#
# The policy refuses a dotless entry anyway, and correctly: an entry becomes a
# `server=/<entry>/` line, which dnsmasq matches against the name and
# everything under it, so a bare label grants its whole suffix - `org` would
# permit every .org name. There is no exact-name form to use instead. Refusing
# one here is a floor on how much a single entry can grant, not a judgement
# about the name.
#
# But the policy refuses it at container start, which an operator meets as a
# sandbox that will not boot, on a value every check on the way in accepted.
# `valid_domain` cannot simply require a dot: it also gates host-side consumers
# where a bare label is fine. So the requirement lives with the keys that reach
# the firewall, and names the one it found.
#
# 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.
_require_fqdn() { # <config key, for the message> <host>
[ -n "$2" ] || return 0
# A host name with at least two labels. Counting dots alone would pass the
# residue of a derivation that went wrong in a way that happens to leave
# one: `https://user@host.example/` reduces to `user@host.example`, and
# `http://loki./` to `loki.`, neither of which is a host name. Those are the
# slips this exists to catch, so it asks the same question of the value that
# the policy will ask, rather than a cheaper one.
if valid_domain "$2"; then
case "$2" in
*.*) return 0 ;;
esac
fi
echo "ERROR: $1 gives the host '$2', which this cannot permit." >&2
echo "" >&2
echo "A permitted destination has to be a host name of at least two" >&2
echo "labels. The sandbox's resolver does not forward a dotless query, so" >&2
echo "a bare label would not resolve; and a permitted name covers" >&2
echo "everything under it, so a bare label would grant its whole suffix." >&2
echo "" >&2
echo "If the value came from a URL, check it parses to the host you meant:" >&2
echo "userinfo and a trailing dot both survive into the derived name." >&2
exit 1
}
for _host in ${DEJIMA_FORGE_HOSTS:-}; do
_require_fqdn "the configured forge's host" "$_host"
done
for _host in ${ALLOWED_DOMAINS:-}; do
_require_fqdn "an approved domain" "$_host"
done
for _host in ${DEJIMA_INTERNAL_DOMAINS:-}; do
_require_fqdn "[core] internal_domains" "$_host"
done
_require_fqdn "[backend.$DEJIMA_BACKEND_NAME] base_url" "${DEJIMA_BACKEND_HOST:-}"
if [ -n "${LOKI_URL:-}" ]; then
_require_fqdn "[logging] loki_url" \
"$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')"
fi
unset _host
# ~/.claude/hooks may contain symlinks whose real targets live outside
# ~/.claude (this repo's own hooks/, and potentially others - e.g. dotfiles'
# config/claude/hooks/ for deny-dangerous-bash.sh, if that's what a given
@@ -2258,6 +2584,7 @@ if [ -n "$SANDBOX_EXTRA_MOUNTS" ]; then
# [mounts] read_write has been guarded since it was added; this one
# never was, so `readonly = ~/.config/dejima` mounted the approval
# store into the container and nothing said a word (#15).
_refuse_pp_root "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" "$path"
_guarded_stores
_refuse_guarded_mount "$(_mount_label "$path" "[mounts] readonly entry" "--extra-readonly path")" \
"$path" "${GUARDED_STORES[@]}"
@@ -2280,6 +2607,17 @@ fi
for path in ${RW_MOUNT_PATHS[@]+"${RW_MOUNT_PATHS[@]}"}; do
extra_mounts+=(-v "$path:$path")
done
# And the per-project ones, which are the only mounts in dejima whose source
# and target differ on purpose: the slice on the host, the declared path in
# the container. Indices are paired, filled together in one loop above.
for _i in ${RW_PP_MOUNT_TARGETS[@]+"${!RW_PP_MOUNT_TARGETS[@]}"}; do
# dejima's own layout below a directory the operator made, so the slice is
# created and the root never is. Here rather than where it was resolved,
# because this is the launch path and that was also --print-briefing's.
mkdir -p "${RW_PP_MOUNT_SOURCES[$_i]}"
extra_mounts+=(-v "${RW_PP_MOUNT_SOURCES[$_i]}:${RW_PP_MOUNT_TARGETS[$_i]}")
done
unset _i
# The session briefing, composed host-side from the core text and the
# selected workflow/harness fragments (see briefing_compose) and mounted
@@ -2484,10 +2822,15 @@ if [ -n "$DEJIMA_BACKEND_NAME" ] \
&& _credential_usable "$DEJIMA_BACKEND_CREDENTIAL_KIND" "$DEJIMA_BACKEND_CREDENTIAL_FILE" "$DEJIMA_BACKEND_CREDENTIAL_FIELD"; then
SANDBOX_CREDENTIAL_PROXY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dejima-credential-proxy.XXXXXX")"
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SANDBOX_CREDENTIAL_PROXY_DIR/proxy.sock"
# Outside SANDBOX_CREDENTIAL_PROXY_DIR, which is bind-mounted into the
# session below so the socket is reachable. The log holds what the proxy
# learned from the provider - statuses, error-body prefixes - and
# docs/security.md says that stays on the host, so it must not share the
# one proxy path the session can read.
SANDBOX_CREDENTIAL_PROXY_LOG="$(mktemp "${TMPDIR:-/tmp}/dejima-credential-proxy-log.XXXXXX")"
# Every value comes from backends/<name>/backend.conf. Nothing about a
# backend is written here, which is what makes adding one a directory
# rather than an edit to this file.
SANDBOX_CREDENTIAL_PROXY_LOG="$SANDBOX_CREDENTIAL_PROXY_DIR/proxy.log"
_proxy_args=(
--socket "$SANDBOX_CREDENTIAL_PROXY_SOCKET"
--log "$SANDBOX_CREDENTIAL_PROXY_LOG"
@@ -3103,6 +3446,235 @@ if [ -n "$pending_crit" ] && [ ! -t 0 ]; then
fi
fi
# The egress control runs in a container of its own, which owns this launch's
# network namespace. The sandbox joins that namespace rather than getting one
# of its own, so the filter bounding it is installed somewhere it has no
# capability to reach - capabilities are per process, and the sandbox declares
# none of the network ones.
#
# Ordering is the point, and it is why this is here rather than inside the
# sandbox's own startup. The namespace has its rules before anything is created
# in it, so the window where a session holds an interface and no filter does
# not exist, rather than being bounded by an argument about how fast an
# entrypoint runs.
#
# The base image, never the per-project derived one: this is the privileged
# half of the pair and has no use for a project's declared dependencies.
SANDBOX_RUN_NETWORK="dejima-run-$DEJIMA_RUN_ID"
SANDBOX_FILTER_CONTAINER="dejima-filter-$DEJIMA_RUN_ID"
# A launch removes its own pair on the way out, which covers every exit the
# trap reaches. It does not cover SIGKILL, a host reboot, or a docker daemon
# restart, and each of those leaves a filter container holding a network open.
#
# The leak is inert - a filter container filters a namespace nothing is in - so
# this is hygiene rather than a second control. What makes it worth doing is
# the address pool: Docker carves a bounded number of networks out of its
# default pool, and exhausting it fails a launch with an error about address
# space that says nothing about the cause.
#
# Never fatal. A daemon that cannot answer any of this is one that is about to
# fail the launch a few lines down for a reason worth reporting, and a tidy-up
# pass should not be what reports it.
# How old a filter container or network has to be before an absent session
# counts as abandoned rather than as not started yet.
#
# Between `docker run` for the filter container and `docker compose run` for
# the session there is a window in which a healthy pair has no joiner, and it
# lasts as long as the firewall takes to come up - seconds, or half a minute
# when the GitHub ranges fetch retries. The same window exists for a network
# between its creation and its first endpoint. Minutes against seconds, because
# reaping a concurrent launch out from under itself costs that launch, while
# waiting costs one more pass.
DEJIMA_ORPHAN_GRACE=300
# Seconds since $1, a timestamp as Docker rendered it, or nothing at all if it
# cannot be read as one.
#
# Two renderings arrive here and only one is RFC 3339, because the field is not
# the same type on both of the structs this asks about: a container's `Created`
# is a string, a network's is a `time.Time`. A Go template prints a `time.Time`
# through String(), which appends a timezone NAME - "+0000 UTC" - and date(1)
# refuses that outright.
#
# So the name is stripped rather than the format assumed. Assuming the format
# is what broke this: the comment here used to say every caller read RFC 3339,
# which was true of one of them, and the network half therefore reaped nothing
# at all while looking exactly like the half that worked.
#
# An unparseable value still yields no age rather than a huge one, so the
# failure stays "nothing was reaped" and never becomes "everything was".
_epoch_age() {
local made stamp="$1"
# "2026-09-09 18:00:00.1 +0000 UTC" -> "2026-09-09 18:00:00.1 +0000", and
# an RFC 3339 stamp, which has no spaces, through unchanged.
case "$stamp" in
*' '[A-Z]*) stamp="${stamp% *}" ;;
esac
made="$(date -d "$stamp" +%s 2>/dev/null)" || return 1
[ -n "$made" ] || return 1
printf '%s' "$(( $(date +%s) - made ))"
}
reconcile_orphans() {
local candidates joined id name created age
# The candidates first, so a host with no filter containers at all asks one
# question and stops. The query below is the expensive one - it inspects
# every running container on the host, dejima's or not - and there is no
# point paying for it to answer about nothing.
candidates="$(docker ps -aq --filter 'label=dejima.filter' 2>/dev/null \
| xargs -r docker inspect -f '{{.Id}} {{.Name}} {{.Created}}' 2>/dev/null)"
if [ -n "$candidates" ]; then
# Whose namespace something is still in. Read once: asking per
# candidate would be a docker call per candidate, on every launch.
#
# NetworkMode is `container:<id>` for a joiner - the id, even where the
# launcher passed a name, so what is compared below are ids.
joined="$(docker ps -q 2>/dev/null | xargs -r docker inspect \
-f '{{.HostConfig.NetworkMode}}' 2>/dev/null | sed -n 's/^container://p')"
fi
while read -r id name created; do
[ -n "$id" ] && [ -n "$created" ] || continue
grep -qxF "$id" <<< "${joined:-}" && continue
age="$(_epoch_age "$created")" || continue
[ "$age" -gt "$DEJIMA_ORPHAN_GRACE" ] || continue
echo "dejima: removing a filter container an earlier session left behind (${name#/})" >&2
docker rm -f "$id" >/dev/null 2>&1 || true
done <<< "$candidates"
# Networks defend themselves twice over: `docker network rm` refuses one
# that still has an endpoint, and the grace covers the moment between a
# network being created and its filter container attaching to it.
while read -r name created; do
[ -n "$name" ] && [ -n "$created" ] || continue
age="$(_epoch_age "$created")" || continue
[ "$age" -gt "$DEJIMA_ORPHAN_GRACE" ] || continue
docker network rm "$name" >/dev/null 2>&1 || true
done < <(docker network ls -q --filter 'label=dejima.run' 2>/dev/null \
| xargs -r docker network inspect -f '{{.Name}} {{.Created}}' 2>/dev/null)
}
reconcile_orphans || true
# One network per launch, for two reasons, and the first is not optional:
# Docker's embedded resolver at 127.0.0.11 exists only on a user-defined
# network, and the restricted resolver forwards to it. The second is that
# concurrent
# sessions are then separated at the link layer as well, rather than only by
# each namespace's own egress default-deny.
docker network create --label "dejima.run=$DEJIMA_RUN_ID" "$SANDBOX_RUN_NETWORK" >/dev/null
# Everything the firewall path reads, passed as `-e NAME` so a value is taken
# from this process's environment rather than written into a command line every
# process on the host can read - GH_TOKEN is in this list.
#
# Derived from nothing: this IS the declaration, because the filter container
# is started with `docker run` and so gets none of the environment
# docker-compose.yml declares for the sandbox. A static test reads the same
# names back out of the firewall scripts, so a script that starts reading a new
# one fails rather than silently allowlisting less than it was told to.
# The only two names here this launcher does not derive: they come from the
# operator's own environment and travel through untouched. Marked exported
# anyway, so that `-e NAME` carries a value rather than an empty string on a
# shell where they were set without being exported.
export EGRESS_HOST_ADDR EGRESS_HOST_PORTS
filter_env=(
-e ENABLE_FIREWALL # whether to install rules at all
-e ALLOWED_DOMAINS # the operator's approved names
-e DEJIMA_FORGE_HOSTS # the configured forge's, resolved host-side
-e ALLOW_GITHUB_PUSH # whether port 22 to GitHub opens
-e GH_TOKEN # authenticates the GitHub ranges fetch
-e LOKI_URL # its host is allowlisted, and the shipper needs it
-e DEJIMA_INTERNAL_DOMAINS # approved names that really do resolve inside
-e DEJIMA_BACKEND_HOST # a self-served model's host, from its base_url
# The host entry: which address stands for "this container's host", and
# which of its TCP ports open. Both default inside egress-policy and
# neither has a config key, so passing them is what makes them reachable
# at all - without this the clamp would have no escape hatch an operator
# could use.
-e EGRESS_HOST_ADDR
-e EGRESS_HOST_PORTS
-e SANDBOX_HARNESS_DOMAINS
-e SANDBOX_BACKEND_PROXIED_DOMAIN
-e SANDBOX_CREDENTIAL_PROXY_SOCKET
)
# The capabilities the egress control needs, and the sysctl the namespace it
# owns needs. Every one of these used to be in docker-compose.yml, and none of
# them belongs to a container running a session:
#
# NET_ADMIN, NET_RAW the packet filter and its named address sets, which
# the resolver also writes into as it answers
# KILL the resolver drops to its own user, and dejima-allow
# restarts it to add a domain mid-session
# CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID
# the log files, and the daemons that drop privilege
# after opening what they need
# SETPCAP spent taking the rest away from log-shipper.sh
#
# The sysctl lets the credential proxy's TLS terminator bind :443 as the
# unprivileged sandbox user. It is a property of the network namespace rather
# than of a container, so it is set here, by the container that owns one, and
# it applies to the session container that joins it.
docker run -d --init \
--name "$SANDBOX_FILTER_CONTAINER" \
--label "dejima.filter=$DEJIMA_RUN_ID" \
--network "$SANDBOX_RUN_NETWORK" \
--cap-drop ALL \
--cap-add NET_ADMIN --cap-add NET_RAW \
--cap-add KILL \
--cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER \
--cap-add SETUID --cap-add SETGID \
--cap-add SETPCAP \
--security-opt no-new-privileges:true \
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
${filter_env[@]+"${filter_env[@]}"} \
--entrypoint /usr/local/bin/filter-entrypoint.sh \
"${SANDBOX_IMAGE_REPO}:${SANDBOX_USER}" >/dev/null
# Named, not the id `docker run -d` printed. Both work for `container:`, and a
# name we chose is one this launcher already knows without reading anything
# back - which is also what lets the teardown below name it after a failure
# that happened before any output could be captured.
export SANDBOX_NETNS_CONTAINER="container:$SANDBOX_FILTER_CONTAINER"
# Waited for, rather than assumed. The filter container resolves domains and
# fetches GitHub's published ranges, which is seconds on a good day and retries
# for half a minute on a bad one - so "it will be up by the time compose has
# started the sandbox" is a guess, and the thing it guesses about is whether
# the session gets a filter.
#
# Two ways out, and the second is why this is a loop rather than a sleep: the
# marker appears, or the container is gone. A firewall that refused to come up
# exits the container, and without that check this would wait out the whole
# timeout before reporting a failure that had already been decided.
echo "dejima: bringing up this session's egress control..." >&2
_filter_ready=0
for _ in $(seq 1 240); do
if docker exec "$SANDBOX_FILTER_CONTAINER" \
test -f /run/dejima-firewall-ready 2>/dev/null; then
_filter_ready=1
break
fi
if [ "$(docker inspect -f '{{.State.Running}}' "$SANDBOX_FILTER_CONTAINER" 2>/dev/null)" != "true" ]; then
break
fi
sleep 0.5
done
if [ "$_filter_ready" -ne 1 ]; then
echo "" >&2
echo "The egress control for this session did not come up, so there is no" >&2
echo "filter for the sandbox to run behind. Refusing to start it." >&2
echo "" >&2
echo "What the filter container said:" >&2
docker logs "$SANDBOX_FILTER_CONTAINER" 2>&1 | sed 's/^/ /' >&2 || true
exit 1
fi
compose_status=0
# Conversation shipping, host-side: it reads transcripts that are on the host
# already, so it never needed to be in the container - and being there put it
+308 -119
View File
@@ -6,11 +6,12 @@
# actually needs, it should tell you the exact command to run here rather
# than try to work around the firewall.
#
# Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
# Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>...
# dejima-allow crates.io # this running session only
# dejima-allow --global crates.io # persist for every project
# dejima-allow --project crates.io # persist for the project in $PWD
# dejima-allow --project ~/myproj crates.io # persist for one named project
# dejima-allow dl.example.com files.example.org # several, confirmed first
#
# --project and --global are approvals for future sessions and deliberately do
# not touch a running container: a scope flag means "record this", not "patch
@@ -25,32 +26,32 @@ case "${1:-}" in
cat <<'DEJIMA_ALLOW_HELP'
dejima-allow - let a sandbox reach a domain the egress firewall blocks
Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>
Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>...
Up to twenty. More than one is confirmed first, defaulting to no, which
needs a terminal - so a script does them one at a time.
Scope, which decides who the grant is for and how long it lasts:
(none) the running container, right now, until it exits. Finds it
if exactly one is running. With several, it offers them to
pick from on a terminal, and lists them and stops anywhere
else - a prompt is no use to a script.
if exactly one is running. With several it offers them to
pick from on a terminal, by project and by the number the
dejima-attach picker gives each; anywhere else it lists
them and stops, a prompt being no use to a script.
--container <id> the same, for a named container. Use this when a session
told you its own id rather than letting anything guess.
With several running, they are listed by project and by
the number the dejima-attach picker gives each one. On a
terminal it offers them to pick from instead.
--project [path] every future session in one project. Also writes the domain
to that project's .dejima/allowed-domains, so it is shared
with everyone working on it. Path defaults to $PWD.
--global every future session in every project on this host.
They combine. --project and --global deliberately do NOT touch a session that
is already running: they are approvals for future ones. Pass --container as
well to do both at once.
They combine, and --project/--global deliberately do NOT touch a running
session: they are approvals for future ones, so pass --container to do both.
Prefer --project to --global - a domain one project needs is that project's,
and a global grant widens every other project's firewall too.
Prefer --project to --global. A domain one project needs is that project's, and
a global grant widens every other project's firewall too.
With --container, the domain is verified reachable afterwards and a warning is
With --container, each domain is verified reachable afterwards and a warning is
printed if it is not, so a typo or a blocked port does not look like success.
One that fails stops there naming what landed; a scope flag has them all by
then regardless.
See docs/configuration.md; docs/security.md explains what the allowlist bounds.
DEJIMA_ALLOW_HELP
@@ -60,10 +61,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
@@ -73,34 +74,138 @@ source "$SCRIPT_DIR/../docker/fixed-domains.sh"
source "$SCRIPT_DIR/dejima-tui-lib.sh"
usage() {
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>" >&2
echo "Usage: dejima-allow [--global] [--project [path]] [--container <id>] <domain>..." >&2
exit 1
}
# 1: the domain is the required trailing positional, which is what tells
# parse_scope_args whether a token after --project is a path or the domain.
# The most domains one command will take. A cap rather than no limit, because
# the confirmation below only works if the operator reads the list, and a list
# long enough to scroll is one nobody reads to the end of - which is where
# something would be hidden. Twenty is enough for the real case (a toolchain
# that fetches from several hosts) and short enough to take in at once.
readonly DEJIMA_ALLOW_MAX=20
# 1: at least one trailing positional, which is what tells parse_scope_args
# whether a token after --project is a path or a domain. It is a minimum, not
# an exact count - several domains may follow.
parse_scope_args 1 "$@" || usage
[ "${#dj_positional[@]}" -eq 1 ] || usage
domain="${dj_positional[0]}"
[ "${#dj_positional[@]}" -ge 1 ] || usage
domains=("${dj_positional[@]}")
global="$dj_global"
project=""
if [ "$dj_project_set" -eq 1 ]; then
# parse_scope_args consumes a token after --project as the path whenever
# enough positionals follow it, which with several domains is any of them.
# A path that is not a directory and does read as a domain was the first
# domain: `dejima-allow --project a.com b.com` means both of those, in the
# project in $PWD. A directory wins, so a project whose name happens to
# look like a hostname still works.
if [ -n "$dj_project" ] && [ ! -d "$dj_project" ] \
&& [[ "$dj_project" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
domains=("$dj_project" "${domains[@]}")
dj_project=""
fi
project="$(cd "${dj_project:-$PWD}" && pwd)"
disclose_project_symlink "$project"
fi
container="$dj_container"
# Validated up front, not just passed as a positional arg below: this also
# has to be a well-formed hostname to be a valid dnsmasq server=/domain/...
# line (e.g. no embedded "/"), on top of ruling out shell metacharacters. A
# sandboxed agent can suggest this exact command to the user to copy-paste
# (see sandbox-context.sh), so a prompt-injected agent handing back a
# malicious "domain" is a real path here, not just a theoretical one.
if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
echo "Not a valid domain: $domain" >&2
# Repeats dropped, order kept. A list with the same name twice asks the
# operator to approve it twice and makes the count they are reading wrong.
_seen=""
_unique=()
for _d in "${domains[@]}"; do
case "$_seen" in *"|${_d,,}|"*) continue ;; esac
_seen="$_seen|${_d,,}|"
_unique+=("$_d")
done
domains=("${_unique[@]}")
# What to call this request where it is described as a whole, rather than once
# per name. Anything outside the per-domain loops below has no $domain to use,
# and under set -u reaching for one is fatal rather than merely wrong - which is
# how the container picker's own prompt took this script down.
if [ "${#domains[@]}" -eq 1 ]; then
domain_label="${domains[0]}"
else
domain_label="${#domains[@]} domains"
fi
if [ "${#domains[@]}" -gt "$DEJIMA_ALLOW_MAX" ]; then
echo "That is ${#domains[@]} domains, and this takes at most $DEJIMA_ALLOW_MAX at once." >&2
echo "" >&2
echo "The limit is there because the confirmation below is only worth anything if" >&2
echo "you read the whole list. Split it up, and read each part." >&2
exit 1
fi
# Every domain validated BEFORE any of them is shown or recorded, and the whole
# command refused if any fails. Two reasons beyond the obvious one.
#
# The list the operator confirms has to be the list that gets added: validating
# as we went would ask them to approve names, then add a prefix of them and
# fail. And the confirmation rests on the list being readable as printed - this
# pattern allows letters, digits, hyphens and dots and nothing else, so no
# entry can carry a carriage return or an escape sequence that rewrites the
# lines above it on the way past.
#
# It also has to be a well-formed hostname to be a valid dnsmasq
# server=/domain/... line (e.g. no embedded "/"), on top of ruling out shell
# metacharacters. A sandboxed agent can suggest this exact command for the
# operator to copy-paste (see sandbox-context.sh), so a prompt-injected agent
# handing back a malicious "domain" is a real path here, not a theoretical one.
#
# All of them reported, not the first: an operator fixing a typo in a list of
# ten should not have to run it ten times to find the second one.
_bad=()
for _d in "${domains[@]}"; do
[[ "$_d" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]] || _bad+=("$_d")
done
if [ "${#_bad[@]}" -gt 0 ]; then
if [ "${#_bad[@]}" -eq 1 ]; then
echo "Not a valid domain: ${_bad[0]}" >&2
else
echo "Not valid domains:" >&2
printf ' %s\n' "${_bad[@]}" >&2
fi
exit 1
fi
# One domain goes through as it always has. More than one is confirmed first,
# because a list is where something unwanted can ride along unread - and the
# agent that suggested the command is exactly what might have put it there.
#
# Default no, and the list on stdout one per line so nothing is off the right
# edge of a narrow terminal.
#
# No terminal means no confirmation is possible, so it refuses rather than
# assuming either answer. A script that wants several does them one at a time,
# which is the behaviour this had before and still has for one.
if [ "${#domains[@]}" -gt 1 ]; then
echo "About to allow ${#domains[@]} domains:"
printf ' %s\n' "${domains[@]}"
echo ""
_where=()
[ -n "$project" ] && _where+=("every future session in $project")
[ "$global" -eq 1 ] && _where+=("every future session in every project")
[ -n "$container" ] && _where+=("the running session $container")
[ "${#_where[@]}" -gt 0 ] || _where=("the running session")
printf 'This grants them to: %s\n' "$(printf '%s; ' "${_where[@]}" | sed 's/; $//')"
echo ""
if [ ! -t 0 ]; then
echo "Several domains at once needs a terminal to confirm on, and this is not one." >&2
echo "Run them one at a time, which needs no confirmation." >&2
exit 1
fi
printf 'Add all %s? [y/N] ' "${#domains[@]}"
read -r _reply || _reply=""
case "$_reply" in
y|Y|yes|YES|Yes) ;;
*) echo "Nothing was added."; exit 1 ;;
esac
echo ""
fi
# Recorded first, and unconditionally: --project/--global are the user's
# approval decision for future sessions, not a claim about this one. Gating
# either on a live container would break the documented workflow outright -
@@ -111,6 +216,7 @@ fi
# given together, and each persists to its own store.
if [ -n "$project" ]; then
store="$(ensure_project_store "$project")"
for domain in "${domains[@]}"; do
approve_entry "$store" allowed-domains "$domain"
echo "Added $domain to the approved domains for $project (applies to every future session in it)."
# Printed only after the approval above, and guarded rather than left to
@@ -124,11 +230,14 @@ if [ -n "$project" ]; then
echo "The approval above IS recorded and applies to every future session in it;" >&2
echo "only the project's own .dejima declaration could not be written." >&2
fi
done
fi
if [ "$global" -eq 1 ]; then
store="$(ensure_global_store)"
approve_entry "$store" allowed-domains "$domain"
echo "Added $domain to the global approved domains (applies to every future session)."
for domain in "${domains[@]}"; do
approve_entry "$store" allowed-domains "$domain"
echo "Added $domain to the global approved domains (applies to every future session)."
done
fi
# A scope flag means "record this approval", not "patch what is running now" -
@@ -225,7 +334,7 @@ pick_container() {
"$C_LINK" "$CT_W_NAME" "${CT_NAMES[$i]}" "$C_OFF" \
"$C_MUTED" "${CT_ATTACH[$i]}" "$C_OFF"
done
tui_pick "${#CT_IDS[@]}" "allow $domain in"
tui_pick "${#CT_IDS[@]}" "allow $domain_label in"
container="${CT_NAMES[$((TUI_CHOICE - 1))]}"
}
@@ -268,92 +377,172 @@ 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
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
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
echo "$domain allowed for the running session ($container)."
# TCP-level truth check from inside the very container that was just
# updated - this script's job is the firewall, so "TCP connect succeeded"
# is the claim to verify. curl exit 6 (resolve failed), 7 (connect
# refused/blocked) and 28 (connect or overall timeout) mean still
# unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
# the connection itself went through the firewall fine. --max-time bounds
# the whole request, not just the connect phase - without it, a server that
# accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
# would hang this script indefinitely; curl maps that case to the same
# exit 28 the case below already handles, so no new branch is needed. This
# is a report on this run, not a gate on either scope's approval: both
# --project and --global are already recorded above, deliberately - a
# declared host may legitimately not answer on 443 (an internal service,
# an SSH-only host, an apex that only serves a subdomain), and the natural
# moment to add a global approval is precisely when nothing is running to
# probe in the first place. The exit 2 below still means what it says:
# the live patch to this session did not verify.
echo "Verifying $domain is reachable from $container..."
rc=0
docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
case "$rc" in
6|7|28)
echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
echo "otherwise the allow did not take effect - re-check the container ID." >&2
exit 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:}"
;;
*)
echo "Verified: $domain is reachable from $container."
# Then it is a filter container, or not part of a pair at all.
# `docker exec` takes a name or an id; NetworkMode reports the id,
# so the search below compares ids.
filter_id="$(docker inspect -f '{{.Id}}' "$container" 2>/dev/null)" || filter_id=""
session=""
if [ -n "$filter_id" ]; then
while IFS=' ' read -r _id _mode; do
[ "$_mode" = "container:$filter_id" ] || continue
session="$_id"
break
done < <(docker ps -q 2>/dev/null | xargs -r docker inspect \
-f '{{.Id}} {{.HostConfig.NetworkMode}}' 2>/dev/null)
fi
if [ -z "$session" ]; then
echo "$container is neither a running session nor the filter container of" >&2
echo "one, so there is no allowlist to add this domain to." >&2
echo "" >&2
echo "A session's own id is in its startup context, or from 'cat /etc/hostname'" >&2
echo "inside it - either half of the pair is accepted here. If the session has" >&2
echo "already exited, record the domain for the next one instead:" >&2
echo "" >&2
echo " dejima-allow --project ${domains[*]}" >&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.
#
# Each domain reaches the container as an argument rather than inside the
# command string: they are 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.
# One domain at a time, because each is its own change to the running
# policy: the class it belongs to, whether the operator declared it
# internal, and whether it then answers. A failure stops the loop rather
# than carrying on, so what did and did not land is the order printed
# above - and the names after it are still recorded under any scope flag
# given, which happened before this and is not undone by a live add
# failing.
for domain in "${domains[@]}"; do
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
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)."
# TCP-level truth check from inside the very container that was just
# updated - this script's job is the firewall, so "TCP connect succeeded"
# is the claim to verify. curl exit 6 (resolve failed), 7 (connect
# refused/blocked) and 28 (connect or overall timeout) mean still
# unreachable; anything else (0, or a TLS/HTTP-layer code like 35/52) means
# the connection itself went through the firewall fine. --max-time bounds
# the whole request, not just the connect phase - without it, a server that
# accepts the TCP connection and then stalls (never sends TLS/HTTP bytes)
# would hang this script indefinitely; curl maps that case to the same
# exit 28 the case below already handles, so no new branch is needed. This
# is a report on this run, not a gate on either scope's approval: both
# --project and --global are already recorded above, deliberately - a
# declared host may legitimately not answer on 443 (an internal service,
# an SSH-only host, an apex that only serves a subdomain), and the natural
# moment to add a global approval is precisely when nothing is running to
# probe in the first place. The exit 2 below still means what it says:
# the live patch to this session did not verify.
echo "Verifying $domain is reachable from $container..."
rc=0
docker exec "$container" curl -sI --connect-timeout 5 --max-time 10 "https://$domain/" >/dev/null 2>&1 || rc=$?
case "$rc" in
6|7|28)
echo "WARNING: $domain is still NOT reachable from $container (curl exit $rc)." >&2
echo "If the service doesn't listen on 443 this check can fail spuriously;" >&2
echo "otherwise the allow did not take effect - re-check the container ID." >&2
exit 2
;;
*)
echo "Verified: $domain is reachable from $container."
;;
esac
done
fi
+2 -2
View File
@@ -953,8 +953,8 @@ tcb_snapshot() {
#
# A forge's `fixed_hosts` and a harness's paths are resolved on the
# host and handed to the container: fixed_hosts reaches
# DEJIMA_FORGE_HOSTS, which entrypoint.sh joins into extra_domains and
# init-firewall.sh allowlists. So a line added to a manifest widens the
# DEJIMA_FORGE_HOSTS, which firewall-up.sh puts into the permitted set
# the egress policy is given. So a line added to a manifest widens the
# egress allowlist on the next launch - the boundary that bounds where
# a compromised session can send anything.
#
+14 -9
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
@@ -285,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.
@@ -299,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)*$ ]]
+103 -12
View File
@@ -52,6 +52,35 @@ SUPPLIED_NAMES = {
}
# 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."""
@@ -130,11 +159,9 @@ class OAuthCredential:
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 RuntimeError(
"the refresh token has expired - run `claude` on the host "
"and log in again first")
# 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:
@@ -172,6 +199,19 @@ class OAuthCredential:
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)
@@ -360,29 +400,54 @@ class Handler(BaseHTTPRequestHandler):
# forwarded upstream is a worse outcome than a rejected one.
MAX_BODY_BYTES = 32 * 1024 * 1024
def _length(self, raw, what):
"""A byte count from the wire, or BodyTooLarge.
`int()` alone accepts a negative, and a negative passes a `> MAX` test
and then means "read to EOF" to a buffered reader - so the cap above,
whose whole reason is that this runs on the host as the operator, was
bypassed by sending `Content-Length: -1`.
A value that is not a number still raises out of here, deliberately: the
suppression around handle_error is meant to be specific to the client
going away, and a malformed chunk length is the fault the test for that
uses to prove it. Refusing it cleanly here would remove the canary and
trade noise for blindness.
"""
n = int(raw, 16) if what == "chunk size" else int(raw)
if n < 0 or n > self.MAX_BODY_BYTES:
# The offending value, not the cap. The host log said "refused a
# request body: 33554432 bytes" whatever arrived, so a hostile -1
# and a genuinely oversized upload read identically - and the
# comment on the 413 path promises the log says what came in.
raise BodyTooLarge("%s of %d, cap %d"
% (what, n, self.MAX_BODY_BYTES))
return n
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)
size = self._length(self.rfile.readline().split(b";")[0], "chunk size")
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)
raise BodyTooLarge("chunked body of %d or more, cap %d"
% (total, 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)
length = self._length(self.headers.get("Content-Length", 0), "Content-Length")
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.
# exception detail; message (sent to the container in the response body)
# never should. The one thing that does cross is text written in this
# file naming something the operator must do - see CredentialNeedsOperator,
# and docs/security.md for why that is not an exception to the rule.
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}}
@@ -479,10 +544,29 @@ class Handler(BaseHTTPRequestHandler):
# 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.
#
# And the connection ends here. The body was refused before it was
# read, so its bytes are still queued; left open, the next parse
# takes them as a request line and answers a keep-alive client with
# garbage 400s instead of one clean refusal.
self.close_connection = True
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",
@@ -499,6 +583,13 @@ class Handler(BaseHTTPRequestHandler):
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",
+45 -21
View File
@@ -160,17 +160,31 @@ def classify(path, value):
return (None, None)
def walk_json(doc, prefix=""):
def walk_json(doc, prefix="", parts=()):
"""Yields (display path, value, components).
The display path is what an operator reads and what finding_id hashes, so
its spelling is fixed - it is recorded in the decisions file and a change
would orphan every standing answer. It is also ambiguous: a dict key of
`x[0]` and element 0 of a list `x` produce the same string, and so do a key
containing a dot and the traversal a dot denotes.
The components are the same walk as a tuple, which is not ambiguous. Anything
that has to FIND the value again uses those; re-parsing the string deleted
the wrong element, and for a dotted key raised KeyError out of the scrub,
leaving the file to be copied unscrubbed.
"""
if isinstance(doc, dict):
for k, v in doc.items():
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k))
yield from walk_json(v, "%s.%s" % (prefix, k) if prefix else str(k),
parts + (("key", 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))
yield from walk_json(v, "%s[%d]" % (prefix, i), parts + (("idx", i),))
else:
yield (prefix, doc)
yield (prefix, doc, parts)
def ini_logical_lines(text):
@@ -227,17 +241,28 @@ def walk_ini(text):
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 components_sort_key(parts):
"""Orders paths so a list element is deleted after any later sibling.
`del cur[i]` shifts every later index down, so applying findings in document
order deletes the wrong elements from the second onwards: two secrets in one
array left the second one in the file and removed an innocent value instead,
and the file is then mounted into the container - the operator answers "keep
it out" and it goes in. Deepest and highest index first makes each deletion
independent of the ones after it.
"""
return [(1, p[1]) if p[0] == "idx" else (0, p[1]) for p in parts]
def delete_json_components(doc, drop):
"""Every component path, in an order under which no deletion invalidates
another: deepest and highest index first, so a list deletion cannot shift an
element that is still to be removed."""
for parts in sorted(drop, key=components_sort_key, reverse=True):
cur = doc
for kind, item in parts[:-1]:
cur = cur[item]
del cur[parts[-1][1]]
def drop_ini_paths(text, paths):
@@ -311,21 +336,20 @@ def main():
print("%s is not readable as JSON" % args.label, file=sys.stderr)
return 2
else:
pairs = list(walk_ini(text))
pairs = [(p, v, ()) for p, v in walk_ini(text)]
if args.scrub:
wanted = set(load_list(args.scrub))
drop = [path for path, value in pairs
drop = [(path, parts) for path, value, parts 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)
delete_json_components(doc, [parts for _path, parts in drop])
out = json.dumps(doc, indent=2) + "\n"
else:
out = drop_ini_paths(text, drop)
out = drop_ini_paths(text, [p for p, _parts in drop])
with open(args.file, "w") as f:
f.write(out)
return 0
@@ -350,7 +374,7 @@ def main():
return True
return False
for path, value in pairs:
for path, value, _parts in pairs:
fid = finding_id(args.label, path)
if fid in decided or judged_safe("%s\t%s" % (args.label, path)):
continue
+2 -2
View File
@@ -308,10 +308,10 @@ emit_host_exec() {
echo "Harness plugin emitted a host-exec leaf or label containing a tab or newline" >&2
exit 1 ;;
esac
[ -n "$leaf" ] && [ -n "$label" ] || {
if [ -z "$leaf" ] || [ -z "$label" ]; then
echo "Harness plugin emitted a host-exec surface with no leaf or label" >&2
exit 1
}
fi
DEJIMA_HOST_EXEC+=("$ns"$'\t'"$leaf"$'\t'"$path"$'\t'"$label")
}
+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
}
+18 -2
View File
@@ -1696,8 +1696,10 @@ What this means:
and leave the screenshot in ~/shared.
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
registry, {{network_allowlist}}, plus anything
approved via dejima-allow). Requests to anything else will simply
fail to connect. That restriction is not only a limit on your reach: it
approved via dejima-allow). Anything else is refused at once rather than
left to time out, so a connection that HANGS is not this allowlist - it is
something past the sandbox, and from in here the timing is the only thing
that tells the two apart. That restriction is not only a limit on your reach: it
is also what bounds where a prompt injection or a compromised dependency
in this session could send the user's code or fetch its next instruction
from. You cannot audit yourself for that from the inside, so the
@@ -1709,6 +1711,10 @@ What this means:
dejima-allow --container {{container_id}} <domain> # just this session
dejima-allow --project <path> <domain> # this project, always
dejima-allow --global <domain> # every project
Several domains can go on one line, up to twenty, and more than one is
printed back for the operator to confirm before anything is added. So ask
for the whole set a task needs in one command rather than one at a time -
but ask only for what it needs, because the list is read.
Pick deliberately rather than asking for all three: a domain this project
needs is --project, not --global - a global grant widens every other
project's firewall too, which is rarely what one project's dependency
@@ -2002,6 +2008,16 @@ _briefing_resolve_key() {
if [ -n "${DEJIMA_RW_MOUNTS:-}" ]; then
printf '\n These host directories are bind-mounted read-write at their own paths and\n outlive the container too: %s.\n ' "$DEJIMA_RW_MOUNTS"
fi
# A separate sentence, because the first one is not true of these.
# A per-project mount arrives at the path it names, like the
# others, but what is behind it is this project's own slice of
# that directory rather than the directory - so "these host
# directories ... at their own paths" would tell a session it can
# see something it cannot, and imply the next project's session
# will read what it leaves.
if [ -n "${DEJIMA_RW_PP_MOUNTS:-}" ]; then
printf '\n These are bind-mounted read-write too and also outlive the container, but\n each is THIS project'"'"'s own directory rather than one every project shares -\n the same arrangement as ~/shared: %s.\n ' "$DEJIMA_RW_PP_MOUNTS"
fi
;;
*)
echo "internal error: no resolver for whitelisted briefing key: $key" >&2
+20 -8
View File
@@ -17,16 +17,28 @@
# runs inside the container, the other is a separate library).
ws_slug() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; }
# Every per-project share directory lives under this one subdirectory of
# `[core] share_dir` rather than directly in it, which is what makes "left
# over from before the split" a decidable question: anything at share_dir's
# top level other than this directory predates the layout, because nothing
# writes there any more. It is also the directory to mount over sshfs - one
# entry per project, with no loose files mixed in.
share_projects_root() { printf '%s/projects' "${1%/}"; }
# The per-project layout, and there is one of it because there is more than one
# caller: `[core] share_dir` slices this way, and so does every
# `[mounts] read_write_per_project` entry. A host directory sliced per project
# holds one `projects` subdirectory, and one directory per slug inside it.
#
# Everything lives under that single subdirectory rather than directly in the
# root, which is what makes "left over from before, or put there by hand" a
# decidable question: anything at the root's top level other than this
# directory was not written by dejima. It is also the directory to mount over
# sshfs - one entry per project, with no loose files mixed in.
projects_root() { printf '%s/projects' "${1%/}"; }
# The directory a launch against $2 gets from a root sliced this way.
project_dir_for() { printf '%s/%s' "$(projects_root "$1")" "$(ws_slug "$2")"; }
# share_dir's own spelling of the two above. `[core] share_dir` is one
# instance of the layout, and these names are what let bin/dejima-share go on
# talking about a share rather than about "a per-project root".
share_projects_root() { projects_root "$1"; }
# The host directory mounted at ~/shared for a launch against $2.
share_dir_for() { printf '%s/%s' "$(share_projects_root "$1")" "$(ws_slug "$2")"; }
share_dir_for() { project_dir_for "$1" "$2"; }
# The slugs belonging to $1: its own, plus one per live git worktree nested
# underneath it. One per line, sorted, no duplicates.
+2 -2
View File
@@ -5,8 +5,8 @@
# systemd/dejima-ttyd.service.
# See docs/remote-access.md.
#
# --interface 127.0.0.1 is a requirement, not a preference. init-firewall.sh
# accepts container OUTPUT to the bridge gateway (the host), and attaching
# --interface 127.0.0.1 is a requirement, not a preference. The egress policy
# permits traffic to the container's own host, and attaching
# tmux gives a HOST shell - the prefix key opens a window outside the
# container. A listener on any address the container can route to is a
# compromised session's escape hatch; loopback is not one of them. Put nginx
+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
+169 -101
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,11 +304,15 @@ RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
# The launched application itself (SANDBOX_CMD) and any editor config for it
# are supplied at runtime, not baked into the image - see
# docker-compose.yml/entrypoint.sh.
COPY docker/session-bashrc.sh /etc/bash.bashrc.d/10-session.bash
COPY docker/fixed-domains.sh /usr/local/bin/
COPY docker/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/
@@ -281,6 +324,9 @@ COPY docker/harness-wrapper.sh /usr/local/bin/dejima-harness-wrapper
# nothing until something puts them on PATH.
COPY docker/git-guard.sh /usr/local/lib/dejima/git-guard.sh
COPY docker/forge-guard.sh /usr/local/lib/dejima/forge-guard.sh
# The policy both of those apply, and that the two hooks beside them apply to
# the same command from the other side. One copy, so a bypass is fixed once.
COPY docker/guard-lib.sh /usr/local/lib/dejima/guard-lib.sh
# The briefing renderer, which every harness needs and no harness owns. Baked
# in rather than reached through the ~/.claude/hooks mount, because that mount
# is Claude Code's configuration: sandbox-context.sh calls this, and so does
@@ -312,19 +358,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 /usr/local/lib/dejima/hooks/*.sh && \
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
# Source the snippet directory from the system rc file, so every interactive
# bash gets it, and from the sandbox user's own ~/.bashrc, so it is applied
# after the Debian skeleton rc file rather than being undone by it. The guard on
# the append keeps a rebuild on a changed base from adding the loop twice.
RUN grep -q 'bash.bashrc.d' /etc/bash.bashrc || \
printf '\nfor __rc in /etc/bash.bashrc.d/*.bash; do\n [ -r "$__rc" ] && . "$__rc"\ndone\nunset __rc\n' >> /etc/bash.bashrc; \
printf '%s\n' \
'# The sandbox shell configuration this image installs. Sourced here as well' \
'# as from /etc/bash.bashrc so it is applied last, after the Debian defaults.' \
'[ -r /etc/bash.bashrc.d/10-session.bash ] && . /etc/bash.bashrc.d/10-session.bash' \
> "$SANDBOX_HOME/.bashrc" && \
chown "$SANDBOX_USER:$SANDBOX_USER" "$SANDBOX_HOME/.bashrc"
RUN chmod +x /usr/local/sbin/egress-policy /usr/local/sbin/dns-lookup-log /usr/local/bin/cidr-collapse /usr/local/bin/firewall-up.sh /usr/local/bin/filter-entrypoint.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/dejima-harness-wrapper /usr/local/lib/dejima/git-guard.sh /usr/local/lib/dejima/forge-guard.sh /usr/local/lib/dejima/dejima-briefing-render.sh /usr/local/lib/dejima/hooks/*.sh && \
echo 'export PATH="$PATH:/opt/node-tools/bin"' > /etc/profile.d/node-tools-path.sh
# Last instruction that changes the filesystem, and it has to stay last: it
# removes every setuid bit, setgid bit and file capability in the image and
# fails the build if one survives, which only holds for what already exists
# when it runs. A derived per-project layer runs it again at its own end, for
# the packages it installs after this point - see bin/dejima-project-lib.sh.
COPY docker/strip-privilege-bits.sh /usr/local/sbin/strip-privilege-bits
RUN chmod +x /usr/local/sbin/strip-privilege-bits && /usr/local/sbin/strip-privilege-bits
# Entrypoint runs as root (no USER node here) so it can set up the firewall
# directly, with no sudo/setuid escalation path available to node at any
+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()
+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
+83 -33
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,
@@ -143,7 +188,7 @@ services:
SANDBOX_HARNESS_SEED: ${SANDBOX_HARNESS_SEED:-}
SANDBOX_HARNESS_STAGED_EMPTY: ${SANDBOX_HARNESS_STAGED_EMPTY:-}
# Opt-in escape hatch from the default hard GitHub-push block - see
# init-firewall.sh and git-push-guard.sh. Off unless explicitly set.
# the egress policy and git-push-guard.sh. Off unless explicitly set.
ALLOW_GITHUB_PUSH: ${ALLOW_GITHUB_PUSH:-0}
# Base URL of a Loki push endpoint (e.g. http://192.0.2.10:3100) to
# ship DNS-query and firewall allow/reject logs to. Its host is
@@ -237,12 +282,17 @@ 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
# Required only if SANDBOX_SSH_KEY isn't set - bin/dejima
# itself enforces that at least one of the two is present.
# itself enforces that at least one of the two is present, and blanks
# SSH_AUTH_SOCK when the key is the one chosen, so this mounts nothing
# then. A forwarded agent left here signs with the operator's personal
# identities for anything in the container that names this path.
- ${SSH_AUTH_SOCK:-/dev/null}:/ssh-agent
- ${SSH_KNOWN_HOSTS:-/dev/null}:${HOME}/.ssh/known_hosts:ro
# Dedicated, purpose-built key for headless git push - see README
+1711
View File
File diff suppressed because it is too large Load Diff
+70 -79
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
@@ -65,7 +44,10 @@ fi
# involved), leaving the tty root-owned. nvim's clipboard fallback needs to
# write to it directly as the sandbox user, so fix the ownership up here,
# before dropping privileges.
if [ -t 1 ]; then
# Both halves read stdin, because that is what `tty` reports on. Testing stdout
# instead meant a container with one attached and the other not produced an
# empty path and a chown that silently did nothing.
if [ -t 0 ]; then
chown "$SANDBOX_USER" "$(tty)" 2>/dev/null || true
fi
@@ -91,6 +73,9 @@ fi
# Mounted at /root (not under $SANDBOX_HOME) so only this root-run block
# can ever read it - copied out, loaded into the agent, and the copy
# deleted, all before dropping to the sandbox user below.
# The forwarded agent is not mounted when this key is configured (bin/dejima
# blanks SSH_AUTH_SOCK), so this is the only agent in the container rather than
# the preferred one of two.
SANDBOX_SSH_KEY_SRC="/root/.ssh-sandbox-key-src"
if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
install -o "$SANDBOX_USER" -g "$SANDBOX_USER" -m 600 "$SANDBOX_SSH_KEY_SRC" "$SANDBOX_HOME/.ssh/sandbox-key"
@@ -111,31 +96,25 @@ fi
# Linked here rather than COPYed into place at build time because both depend
# on values that are only known per launch: which hosts this forge may be
# pushed to, and which subcommand this workflow blocks.
if [ -n "${DEJIMA_PUSH_HOSTS:-}${DEJIMA_BLOCKED_BRANCHES:-}" ]; then
# /usr/local/bin precedes /usr/bin, so this shadows git without moving it
# and the guard execs /usr/bin/git directly.
ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git
fi
# Every CLI the shadow has something to do about: the one whose merge a
# workflow blocks, and every one that writes a body the session authored. More
# than one, because a forge can have a CLI per artifact rather than one with
# subcommands, and because a forge with nothing blocked can still have
# something to disclose.
# The git shadow is installed unconditionally, because DEJIMA_PUSH_HOSTS fails
# CLOSED: empty means no host may be pushed to, which is how a no-forge or
# non-pushing workflow becomes enforced rather than described. Gating the
# shadow on a non-empty value would leave it absent in exactly that case - and
# local-only, the shipped default, is exactly that case.
#
# The first word of each record names the CLI. The real one is already behind
# the symlink this replaces (see install-forge-bins.sh), so the guard has a
# fixed path to exec.
_shadow_clis() {
[ -z "${DEJIMA_MERGE_BLOCK:-}" ] || printf '%s\n' "${DEJIMA_MERGE_BLOCK%% *}"
# "<kind> <cli> <subcommand>...", so the CLI is the second word.
[ -z "${DEJIMA_DISCLOSE:-}" ] || printf '%s\n' "$DEJIMA_DISCLOSE" |
while read -r _ _cli _; do
[ -z "$_cli" ] || printf '%s\n' "$_cli"
done
}
# /usr/local/bin precedes /usr/bin, so this shadows git without moving it and
# the guard execs /usr/bin/git directly.
ln -sf /usr/local/lib/dejima/git-guard.sh /usr/local/bin/git
_shadow_cli_list="$(_shadow_clis | sort -u)"
# Every CLI the shadow has something to do about. Derived by the same function
# hooks/merge-guard.sh uses to decide whether a command is one of these, so the
# set the shadow is linked over and the set the hook checks against cannot
# disagree. The real CLI is already behind the symlink this replaces (see
# install-forge-bins.sh), so the guard has a fixed path to exec.
# shellcheck source=guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
_shadow_cli_list="$(dejima_forge_commands | sort -u)"
if [ -n "$_shadow_cli_list" ]; then
while read -r _cli; do
[ -n "$_cli" ] || continue
@@ -336,7 +315,11 @@ if [ -n "${SANDBOX_HARNESS_BRIEFING_ARGS:-}" ] || [ -n "${SANDBOX_HARNESS_BRIEFI
mkdir -p /etc/dejima
# Written and renamed, so a harness starting while this is written reads
# either the whole briefing or no file at all.
if ! /usr/local/lib/dejima/dejima-briefing-render.sh "" "$PWD" \
# As the sandbox user, not as root. It reads the project's own .dejima.md
# and asks the package managers what is installed, with its cwd inside the
# workspace the session can write - and the file it produces is written by
# the redirect out here, which root still owns.
if ! gosu "$SANDBOX_USER" /usr/local/lib/dejima/dejima-briefing-render.sh "" "$PWD" \
> "$DEJIMA_BRIEFING_RENDERED.part"; then
rm -f "$DEJIMA_BRIEFING_RENDERED.part"
echo "entrypoint: could not render the session briefing. Refusing to" >&2
@@ -532,18 +515,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
@@ -560,4 +551,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
+170
View File
@@ -0,0 +1,170 @@
#!/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 "${*:-}"; }
# The forge class wins wherever the two overlap, applied here rather than
# asserted in a comment. Every route into the open class is normalised, not just
# the approved-domains loop: the built-in list carries
# release-assets.githubusercontent.com, which is under a forge suffix, and a
# harness or backend domain under one would arrive the same way. Left alone, such
# a name gets a resolver line more specific than the suffix entry, feeding the
# open-class set - and the port restriction then rests on the forge's published
# ranges alone, which is one layer where the design has two.
_forge_extra=""
_open_kept=""
for _domain in ${DEJIMA_OPEN_CLASS[@]+"${DEJIMA_OPEN_CLASS[@]}"}; do
if [ "$(dejima_class_for "$_domain")" = forge ]; then
_forge_extra="$_forge_extra $_domain"
else
_open_kept="$_open_kept $_domain"
fi
done
# shellcheck disable=SC2086
EGRESS_FORGE_NAMES="$(_join ${DEJIMA_FORGE_CLASS[@]+"${DEJIMA_FORGE_CLASS[@]}"} $_forge_extra)"
# shellcheck disable=SC2086
EGRESS_ALLOWED_NAMES="$(_join $_open_kept)"
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"
# The raw token has no other use here, and the header is the only thing the
# policy needs it for. Both are a credential anything started below has no
# reason to hold: they parse content the session's own traffic decides.
unset GH_TOKEN
/usr/local/sbin/egress-policy apply
# The header carries the token in another wrapper, so it goes the moment the
# request that needed it is made. egress-policy scrubs its own daemons at the
# line that starts each one; nothing started below this point can hold either
# half, which is how the shipper is covered.
unset EGRESS_FORGE_META_AUTH
# 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.
# No `env -u` prefix here, unlike the daemons egress-policy starts: drop_caps
# is a shell function, and env can only exec a binary - so a prefix turns this
# line into exit 127, which the `&` hides from set -e. The two unsets above
# already ran, which is what this one relies on instead.
drop_caps -- -c 'exec "$@"' dejima /usr/local/bin/log-shipper.sh &
+151 -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,149 @@
# use for it, and - since a project's declaration is also what a hardened CI
# runner reads its allowlist from (docs/ci.md) - a name that is granted by
# being built in is a name nobody ever reviewed.
FIXED_DOMAINS=(
"registry.npmjs.org"
"release-assets.githubusercontent.com"
)
# Split on spaces explicitly, not on whatever IFS the caller happens to have.
# init-firewall.sh sources this file with IFS=$'\n\t' set, dns-proxy.sh with
# the default - so an unquoted expansion gives the two consumers different
# lists from the same value, which is the drift this shared file exists to
# prevent. A harness declaring two api_domains would reach init-firewall as
# one glued element and fail its DNS check fatally, refusing to start.
# init-firewall.sh already restores IFS=' ' around its own extra-domains loop
# for exactly this reason.
if [ -n "${SANDBOX_HARNESS_DOMAINS:-}" ]; then
IFS=' ' read -ra _dejima_harness_domains <<< "$SANDBOX_HARNESS_DOMAINS"
FIXED_DOMAINS+=(${_dejima_harness_domains[@]+"${_dejima_harness_domains[@]}"})
fi
# The harness's API endpoint is allowlisted only when this session has no
# credential proxy (kill switch or API-key auth), where DNS is the only path
# to it. With the proxy on, the session reaches the API through the forwarder
# rather than by resolving this name (under the tls transport, via
# entrypoint.sh's /etc/hosts pin), so leaving it unresolvable defeats a bypass
# that looks the name up by DNS. It is not a route block: the API's address
# stays reachable as an IP literal for as long as another allowlisted name
# resolves to the same one.
# Forge class. GitHub is reachable for reading - clone, fetch, gh over HTTPS -
# and never for pushing, and the port restriction is what makes that a fact
# rather than a policy: with 22 closed there is no transport left for the
# forwarded agent's real key to authenticate a push with, whatever a hook does
# or does not catch. ALLOW_GITHUB_PUSH=1 is the only thing that opens it.
#
# Appended here rather than to EXTRA_ALLOWED_DOMAINS because it is a public
# SaaS name: dns-proxy.sh exempts the extra list from stop-dns-rebind, and
# init-firewall.sh downgrades a failed lookup there to a warning.
#
# Which name this is comes from the provider (proxied_domain); the rule
# about when to allowlist it is dejima's, and stays here.
if [ -z "${SANDBOX_CREDENTIAL_PROXY_SOCKET:-}" ] \
&& [ -n "${SANDBOX_BACKEND_PROXIED_DOMAIN:-}" ]; then
FIXED_DOMAINS+=("$SANDBOX_BACKEND_PROXIED_DOMAIN")
fi
# GitHub itself is allowlisted by IP range (see github-domains ipset,
# populated from api.github.com/meta), never by resolving these names to
# specific IPs - but the container's own tools (git, gh, curl) still need to
# resolve these names to *something* before connecting, so the DNS proxy
# needs to know they're allowed even though init-firewall.sh never digs them.
# Suffix matching means these also cover their actual subdomains in use
# (api.github.com, codeload.github.com, objects.githubusercontent.com, etc.)
# without enumerating each one.
GITHUB_DNS_SUFFIXES=(
# Suffix entries, because dnsmasq matches a domain and everything under it: one
# line covers api.github.com, codeload.github.com and the rest without
# enumerating them. Stating the class here is what keeps the restriction
# attached to the destination - an address reached by resolving one of these
# names gets the same ports as one reached from the published ranges, so a name
# cannot be the way around the range's restriction.
DEJIMA_FORGE_CLASS=(
"github.com"
"githubusercontent.com"
"githubassets.com"
)
# Which ipset an allowlisted name's addresses belong in.
# Open class: reachable on any port, because these are destinations whose
# protocol dejima does not get to decide.
DEJIMA_OPEN_CLASS=(
"registry.npmjs.org"
"release-assets.githubusercontent.com"
)
DEJIMA_INTERNAL_CLASS=()
# release-assets.githubusercontent.com is under a forge-class suffix, so it ends
# up in the forge class: firewall-up.sh routes every open-class entry through
# dejima_class_for, this list included, rather than only the ones a project
# declared. It is fetched over HTTPS like everything else under that suffix, so
# the narrower class costs it nothing, and the alternative is a name that
# quietly widens the forge - which is what it did until the normalisation
# covered this list too.
# 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'
}
+25 -187
View File
@@ -3,21 +3,22 @@
# one subcommand a workflow blocks is refused, a command aimed at another
# repository is refused, and a body the session authored says so.
#
# The first rule is the one hooks/merge-guard.sh applies - a merge is the
# operator's decision, so the sandbox opens a PR and stops - moved off the
# harness's hook protocol and onto the command. It works for a harness that
# cannot intercept a tool call, and it compares argv rather than
# regex-matching a command string, so quoting, spacing and pipelines stop
# mattering.
# The first two are docker/guard-lib.sh, which hooks/merge-guard.sh applies to
# the same command from the other side. Here they sit on the command itself: it
# works for a harness that cannot intercept a tool call, and it compares argv
# rather than regex-matching a command string, so quoting, spacing and
# pipelines stop mattering.
#
# The second is here for the same reason it is not in the forge CLIs: dejima
# writes two of them and not the third, so a disclosure living in the tools is
# one that depends on which forge an operator picked. This is the one place
# every forge passes through.
# The third is this layer's alone, because a hook is handed a command string
# and cannot rewrite the body an argument points at. It is here rather than in
# the forge CLIs for the same reason the repository rule is: dejima writes two
# of them and not the third, so a disclosure living in the tools is one that
# depends on which forge an operator picked. This is the one place every forge
# passes through.
#
# Fast-fail, not a boundary, exactly as the hook was: what actually decides
# whether a merge is possible is the scope of the token the forge issued. This
# stops an eager agent doing it by accident.
# Fast-fail, not a boundary: what actually decides whether a merge is possible
# is the scope of the token the forge issued. This stops an eager agent doing
# it by accident.
#
# DEJIMA_MERGE_BLOCK the full command prefix to refuse, e.g. "gitea-pr
# merge". Its first word is the command this shadows;
@@ -51,180 +52,12 @@ if [ -z "${DEJIMA:-}" ] ||
exec "$real" "$@"
fi
# The words of a command prefix, minus the CLI name, appear as a run of
# consecutive non-flag arguments. Flags are dropped first, so `gitea-pr
# --verbose merge 7` is still a merge.
#
# Anywhere in that run rather than at the start of it, because a flag's value
# is indistinguishable from a subcommand here: in `gh --repo o/r pr merge 7`
# the first non-flag argument is `o/r`, and requiring the match to begin there
# let the most ordinary way to name a PR from outside its checkout walk
# straight past the block. Knowing which flags take a value would mean knowing
# each CLI, which is the coupling this guard exists to avoid.
#
# The cost is a command that happens to pass the words as consecutive values -
# `--title pr --body merge` - matching. For the merge block that is a loud
# refusal against a merge going through unnoticed; for a disclosure it is a
# line of text on something an agent did write anyway. Both are the direction
# to be wrong in.
plain=()
for arg in "$@"; do
case "$arg" in -*) continue ;; esac
plain+=("$arg")
done
# shellcheck source=guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
# argv_has <word>... - true when those words appear consecutively in `plain`.
argv_has() {
local want=("$@") n="${#plain[@]}" m="$#" i=0 k
[ "$m" -gt 0 ] || return 1
while [ "$((i + m))" -le "$n" ]; do
k=0
while [ "$k" -lt "$m" ] && [ "${plain[$((i + k))]}" = "${want[$k]}" ]; do
k=$((k + 1))
done
[ "$k" -lt "$m" ] || return 0
i=$((i + 1))
done
return 1
}
# --- the merge block --------------------------------------------------------
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
# The first word names the command; only the rest is a subcommand sequence
# to compare against argv. A block naming a different command than the one
# being run is not this invocation's business.
if [ "${#blocked[@]}" -ge 1 ] && [ "${blocked[0]}" = "$self" ]; then
if [ "${#blocked[@]}" -eq 1 ]; then
# The whole command is blocked, not one of its subcommands.
echo "Blocked by dejima's forge guard: '$self' is not available in the sandbox." >&2
exit 1
fi
if argv_has "${blocked[@]:1}"; then
echo "Blocked by dejima's forge guard: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
exit 1
fi
fi
fi
# --- the repository the session was launched against ------------------------
#
# A forge whose CLI can be aimed elsewhere declares how (repo_flags,
# repo_env). The gitea CLIs declare neither, because they resolve the repo
# from origin and refuse another host; `gh` declares both, so on that path the
# most ordinary way to name a PR from outside its checkout is also the way to
# file an issue on a repository the session has nothing to do with (#187).
#
# Fast-fail, not a boundary, for the same reason the merge block is not one:
# the session holds the token and could call the API directly. What bounds
# this is the scope of push_token_file.
#
# Refused rather than skipped when the launch resolved no repository at all: a
# workspace with no origin has nothing for --repo to legitimately name, and
# "cannot confirm" is not a reason to let it through.
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ] ||
[ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ]; then
refuse_repo() {
echo "Blocked by dejima's forge guard: this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts." >&2
exit 1
}
# Case-insensitively: forges treat an owner and a repository name as
# case-preserving but not case-sensitive, and a refusal over capitalisation
# would be a false one.
same_repo() {
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
}
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
if [ -n "$env_value" ] && ! same_repo "$env_value"; then
refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
fi
fi
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
args_left=("$@")
while [ "${#args_left[@]}" -gt 0 ]; do
arg="${args_left[0]}"
args_left=("${args_left[@]:1}")
for flag in "${repo_flags[@]}"; do
value=""
case "$arg" in
"$flag")
# The value is the next argument, and its absence is
# the CLI's error to report rather than this one's.
[ "${#args_left[@]}" -gt 0 ] || continue
value="${args_left[0]}"
;;
"$flag"=*) value="${arg#*=}" ;;
*)
# One-dash flags only; a two-dash flag carries its
# value with `=` or as the next argument, both above.
case "$flag" in
-?) ;;
*) continue ;;
esac
# Short flags cluster, so the letter can sit anywhere
# in one: `-Rowner/name`, `-wR owner/name` and
# `-wRowner/name` all name a repository and none of
# them is `-R` as its own argument (#243). Split at the
# letter the way getopt does - what follows it in the
# same argument is the value, and nothing following it
# means the next argument is.
letter="${flag#-}"
# A cluster, not a value that happens to start with a
# dash: one dash, then the letter somewhere in a run of
# letters. Without the second check, `--search foo-R/x`
# style arguments would be mined for flag letters.
case "$arg" in -[!-]*) ;; *) continue ;; esac
rest="${arg#-}"
case "$rest" in *"$letter"*) ;; *) continue ;; esac
case "${rest%%"$letter"*}" in *[!A-Za-z]*) continue ;; esac
value="${rest#*"$letter"}"
if [ -z "$value" ]; then
[ "${#args_left[@]}" -gt 0 ] || continue
value="${args_left[0]}"
fi
;;
esac
[ -n "$value" ] || continue
same_repo "$value" || refuse_repo "$flag" "$value"
done
done
fi
# A repository named in a path rather than in a flag - `gh api
# repos/<owner>/<name>/issues` reaches any repository the token does,
# without a --repo anywhere in it (#243).
#
# Only when the declared subcommand is argv's first word. The alternative
# is inspecting every positional that contains two slashes, which would
# refuse `gh issue create --title repos/a/b` - and a false refusal costs an
# honest agent a working command, which is the wrong way for a fast-fail to
# be wrong.
if [ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] && [ "$#" -gt 0 ]; then
read -ra repo_paths <<< "$DEJIMA_FORGE_REPO_PATHS"
for spec in "${repo_paths[@]}"; do
[ "$1" = "${spec%%:*}" ] || continue
segment="${spec#*:}"
for arg in "$@"; do
case "$arg" in -*) continue ;; esac
# gh substitutes {owner} and {repo} from the checkout, so a
# path carrying them names this repository by construction and
# comparing the literal braces would refuse it.
case "$arg" in *'{'*) continue ;; esac
path="${arg#/}"
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
path="${path#"$segment"/}"
owner="${path%%/*}"
path="${path#*/}"
name="${path%%/*}"
[ -n "$owner" ] && [ -n "$name" ] || continue
same_repo "$owner/$name" \
|| refuse_repo "the path '$arg'" "$owner/$name"
done
done
fi
if ! dejima_forge_verdict "$self" "$@"; then
echo "Blocked by dejima's forge guard: $DEJIMA_GUARD_REASON" >&2
exit 1
fi
# --- the authorship disclosure ---------------------------------------------
@@ -242,13 +75,18 @@ marker_for() {
esac
}
# The non-flag arguments the records below are compared against. Set here
# rather than relied on from the verdict above, so this section says what it
# depends on.
dejima_forge_plain "$@"
kind=""
while read -r record_kind record_rest; do
[ -n "$record_kind" ] || continue
read -ra words <<< "$record_rest"
[ "${#words[@]}" -ge 1 ] || continue
[ "${words[0]}" = "$self" ] || continue
if argv_has "${words[@]:1}"; then
if dejima_argv_has "${words[@]:1}"; then
kind="$record_kind"
break
fi
+17 -129
View File
@@ -1,20 +1,20 @@
#!/bin/bash
# Shadows git on PATH so `git push` is checked whatever ran it.
#
# The rules are the ones hooks/git-push-guard.sh applies - push only to the
# forge's own hosts, never straight to a blocked branch - moved off the
# harness's hook protocol and onto the command itself. Two things follow from
# that. It works for a harness with no way to intercept a tool call, which is
# most of them. And it reads the decision out of argv rather than out of a
# regex over a reconstructed command string, so `git push`, `git -c k=v
# push` and a push buried in a pipeline are all just argv here.
# The rules are in docker/guard-lib.sh, which hooks/git-push-guard.sh applies
# to the same push from the other side - push only to the forge's own hosts,
# never straight to a blocked branch. Here they sit on the command itself,
# which means two things. It works for a harness with no way to intercept a
# tool call, which is most of them. And it reads the decision out of argv
# rather than out of a regex over a reconstructed command string, so
# `git push`, `git -c k=v push` and a push buried in a pipeline are all just
# argv here.
#
# It is a fast-fail guard, not a boundary, and the distinction is the same one
# docs/security.md draws about the hook it replaces: a session that means to
# get round it can call /usr/bin/git directly, exactly as it could have used a
# tool the hook did not cover. What stops a push reaching somewhere it should
# not is the firewall, and what stops it being accepted is the forge. This
# stops an eager agent doing the obvious thing by accident.
# docs/security.md draws about the hook beside it: a session that means to get
# round it can call /usr/bin/git directly, exactly as it could have used a tool
# the hook did not cover. What stops a push reaching somewhere it should not is
# the firewall, and what stops it being accepted is the forge.
#
# Everything it needs is already in the container's environment, resolved on
# the host from the forge and workflow manifests (see bin/dejima):
@@ -32,125 +32,13 @@ if [ -z "${DEJIMA:-}" ]; then
exec "$REAL_GIT" "$@"
fi
# Find the subcommand, and -C if it is there. git's own global options come
# before the subcommand and several take a value, so the ones that do are
# skipped in pairs rather than assumed to be attached.
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
-C)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
;;
-c)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] \
&& config_overrides+=(-c "${argv[$i]}")
;;
--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
i=$((i + 1))
;;
-*)
;;
*)
subcommand="${argv[$i]}"
break
;;
esac
i=$((i + 1))
done
DEJIMA_GUARD_GIT="$REAL_GIT"
# shellcheck source=guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
[ "$subcommand" = "push" ] || exec "$REAL_GIT" "$@"
# What comes after `push`: the first two non-flag words are the remote and the
# refspec. Flags that take a value are skipped in pairs for the same reason as
# above - `git push --repo origin main` must not read "origin" as the remote.
# --repo names the remote in git's own legacy form and wins over a positional,
# so it is read rather than skipped: reading it as an opaque flag would leave
# `git push --repo=origin main` looking like a push to a remote called "main",
# which resolves to no URL - and passing that through unchecked is a push to
# origin/main that nothing looked at.
remote=""
refspec=""
i=$((i + 1))
while [ "$i" -lt "${#argv[@]}" ]; do
case "${argv[$i]}" in
--repo=*)
remote="${argv[$i]#--repo=}"
;;
--repo)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && remote="${argv[$i]}"
;;
-o|--push-option|--receive-pack|--exec)
i=$((i + 1))
;;
-*)
;;
*)
if [ -z "$remote" ]; then
remote="${argv[$i]}"
elif [ -z "$refspec" ]; then
refspec="${argv[$i]}"
fi
;;
esac
i=$((i + 1))
done
[ -n "$remote" ] || remote=origin
# --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
# failure of that disagreement has to be a refusal rather than a push nobody
# checked.
if [ -z "$remote_url" ]; then
echo "Blocked by dejima's git guard: cannot resolve the remote '$remote' to a URL," >&2
echo "so there is nothing to check this push against. Name a remote that exists." >&2
if ! dejima_push_verdict "$PWD" "$@"; then
echo "Blocked by dejima's git guard: $DEJIMA_GUARD_REASON" >&2
exit 1
fi
host="$(printf '%s' "$remote_url" \
| sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')"
host_allowed=0
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
[ "$host" = "$allowed_host" ] && { host_allowed=1; break; }
done
if [ "$host_allowed" != 1 ]; then
echo "Blocked by dejima's git guard: this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'." >&2
exit 1
fi
if [ -n "$refspec" ]; then
branch="${refspec##*:}"
else
branch="$("$REAL_GIT" -C "$repo_dir" branch --show-current 2>/dev/null)"
fi
branch="${branch#refs/heads/}"
for blocked_branch in ${DEJIMA_BLOCKED_BRANCHES:-}; do
if [ "$branch" = "$blocked_branch" ]; then
echo "Blocked by dejima's git guard: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead." >&2
exit 1
fi
done
exec "$REAL_GIT" "$@"
+563
View File
@@ -0,0 +1,563 @@
#!/bin/bash
# The push and merge policy, once, for the two layers that apply it.
#
# Each rule used to exist twice: as a PATH shadow over the command
# (docker/git-guard.sh, docker/forge-guard.sh) and as a PreToolUse hook over
# the command string a harness reports (hooks/git-push-guard.sh,
# hooks/merge-guard.sh). The two overlap deliberately - a hook sees
# `/usr/bin/git push`, which walks past the shadow, and a shadow sees a push
# from an editor integration or a script, which the harness never reports - so
# neither can be dropped. What they cannot do is disagree, and they had already
# drifted: `git push -o x origin main` was fine to one and a hard refusal from
# the other, for the same invocation in the same session.
#
# So the decision lives here and both call it. A bypass is then fixed once.
#
# Every function reads argv, never a command string. A hook holds a string, so
# it splits it into segments and words first (dejima_guard_segments) and hands
# the words over - which is also why the hooks inherit the shadows' indifference
# to spacing, quoting and pipelines.
#
# None of this is a boundary, and the distinction is the one docs/security.md
# draws: a session that means to get round these can call /usr/bin/git or the
# forge API directly. What stops a push reaching somewhere it should not is the
# firewall, and what stops it being accepted is the forge's token scope. These
# stop an eager agent doing the obvious thing by accident.
#
# Sourced, so nothing here exits: a refusal is a return of 1 with the reason in
# DEJIMA_GUARD_REASON. The caller supplies its own prefix, because an operator
# reading a refusal needs to know which layer spoke.
# The real git, never the shadow. The shadow passes a non-push through, so
# calling it would work - but it would also mean every question this asks git
# goes through a script that is asking git questions.
DEJIMA_GUARD_GIT="${DEJIMA_GUARD_GIT:-/usr/bin/git}"
DEJIMA_GUARD_REASON=""
# --- splitting a command string into git/forge invocations -------------------
# dejima_guard_segments <command string>
#
# One segment per line. A guarded command can be the second half of a compound
# one, and only a segment whose first word is the command in question is that
# command - `echo push` is not a push.
#
# Word-splitting rather than a full shell parse, so quoting edge cases are not
# handled. That is the same pragmatic tradeoff as the sibling
# deny-dangerous-bash.sh denylist, and the reason every rule below refuses
# rather than guesses when it cannot read what it is looking at.
# The trailing newline is load-bearing: without it `read` returns false on the
# last segment even though it read one, so a command with no separators in it -
# every ordinary push - was never looked at.
dejima_guard_segments() {
printf '%s\n' "$1" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g'
}
# --- git push ---------------------------------------------------------------
# Where a push will go and which branches it will write, read out of argv the
# way git reads it. Sets DEJIMA_GUARD_PUSH=1 when the argv is a push at all.
#
# git's own global options come before the subcommand and several take a
# separate value, so those are skipped in pairs rather than assumed attached.
#
# `-c key=value` is kept rather than skipped: git resolves where a push goes
# from the config the push runs under, so asking without these answers for a
# different configuration than the one about to be used. A one-command
# `-c remote.origin.pushurl=...` is exactly that case.
#
# --git-dir and --work-tree are REFUSED rather than skipped or honoured. Both
# used to be consumed and ignored, so `git --git-dir /tmp/x/.git push origin
# main` resolved `origin` in the mounted project, found it allowed, and then
# ran against a repository whose origin points somewhere else. Honouring them
# would mean reimplementing how they combine with -C and with each other;
# refusing is the direction the unresolvable-remote case below already takes,
# and a session with a second repository to push from can cd into it or use -C.
dejima_push_verdict() { # <repo-dir> <git argv...>
local repo_dir="$1"; shift
local argv=("$@") i=0 subcommand="" branch
local -a config_overrides=() refspecs=()
local remote="" repo_flag="" push_all=0 push_mirror=0 repo_override=0
DEJIMA_GUARD_REASON=""
DEJIMA_GUARD_PUSH=0
while [ "$i" -lt "${#argv[@]}" ]; do
case "${argv[$i]}" in
-C)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
;;
-c)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] \
&& config_overrides+=(-c "${argv[$i]}")
;;
--git-dir|--work-tree)
repo_override=1
i=$((i + 1))
;;
--git-dir=*|--work-tree=*)
repo_override=1
;;
--exec-path|--namespace|--super-prefix)
i=$((i + 1))
;;
-*)
;;
*)
subcommand="${argv[$i]}"
break
;;
esac
i=$((i + 1))
done
[ "$subcommand" = push ] || return 0
DEJIMA_GUARD_PUSH=1
# Noted while parsing and refused here, so that only a push is refused:
# these say nothing about any other git command, and a guard that refused
# `git --git-dir X status` would be refusing the flag rather than the push.
if [ "$repo_override" = 1 ]; then
DEJIMA_GUARD_REASON="a push carrying --git-dir or --work-tree cannot be checked, because the repository it writes from is not the one this would resolve the remote in. Run it from that repository, or name it with -C."
return 1
fi
# What comes after `push`. --repo names the remote in git's own legacy form
# and is read rather than treated as an opaque flag, because skipping it
# would leave `git push --repo=origin main` looking like a bare push and
# resolve a remote the command never named.
#
# It LOSES to a positional, which is what git documents: "this option is
# equivalent to the <repository> argument. If both are specified, the
# command-line argument takes precedence." So in `git push --repo=origin
# other-remote feature` the remote is other-remote, and checking origin
# instead would approve a host this push never goes to.
#
# Every positional after the remote is a refspec, not just the first.
# `git push origin feature main` checked `feature` alone and let `main`
# through beside it.
i=$((i + 1))
while [ "$i" -lt "${#argv[@]}" ]; do
case "${argv[$i]}" in
--repo=*) repo_flag="${argv[$i]#--repo=}" ;;
--repo)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && repo_flag="${argv[$i]}"
;;
--all) push_all=1 ;;
--mirror) push_mirror=1 ;;
-o|--push-option|--receive-pack|--exec)
i=$((i + 1))
;;
-*) ;;
*)
if [ -z "$remote" ]; then
remote="${argv[$i]}"
else
refspecs+=("${argv[$i]}")
fi
;;
esac
i=$((i + 1))
done
[ -n "$remote" ] || remote="$repo_flag"
[ -n "$remote" ] || remote=origin
# --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.
local remote_url host
remote_url="$("$DEJIMA_GUARD_GIT" ${config_overrides[@]+"${config_overrides[@]}"} \
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)"
if [ -z "$remote_url" ]; then
# 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 failure of that disagreement has to be a refusal rather
# than a push nobody checked.
DEJIMA_GUARD_REASON="cannot resolve the remote '$remote' to a URL, so there is nothing to check this push against. Name a remote that exists."
return 1
fi
host="$(printf '%s' "$remote_url" \
| sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')"
local allowed_host host_allowed=0
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
[ "$host" = "$allowed_host" ] && { host_allowed=1; break; }
done
if [ "$host_allowed" != 1 ]; then
DEJIMA_GUARD_REASON="this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'."
return 1
fi
# --mirror puts every ref under refs/ on the remote AND deletes the ones
# that are gone locally, so what it does to a protected branch cannot be
# read off the command: a mirror from a checkout that has no `main` deletes
# the remote's. Refused outright rather than resolved, because there is no
# branch list that makes it safe.
if [ "$push_mirror" = 1 ]; then
DEJIMA_GUARD_REASON="--mirror writes every ref and deletes the remote refs this checkout does not have, so it cannot be checked against a branch policy. Push the branch you mean."
return 1
fi
# --all pushes every local branch, so every local branch is what the policy
# has to be applied to. The refspec list is empty here - git refuses --all
# with one - and reading only the current branch is what let `git push
# --all origin` from a feature branch push `main` beside it.
if [ "$push_all" = 1 ]; then
local local_branch
while IFS= read -r local_branch; do
[ -n "$local_branch" ] || continue
if _dejima_branch_blocked "$local_branch"; then
DEJIMA_GUARD_REASON="--all pushes every local branch, and '$local_branch' is one of them. Direct pushes to '$local_branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead."
return 1
fi
done < <("$DEJIMA_GUARD_GIT" -C "$repo_dir" for-each-ref \
--format='%(refname:short)' refs/heads/ 2>/dev/null)
return 0
fi
# Each refspec's destination. `src:dst` names it outright, a bare name is
# both, and `HEAD` or no refspec at all means the current branch - `git
# push origin` and `git push origin HEAD` both push it.
if [ "${#refspecs[@]}" -eq 0 ]; then
refspecs=(HEAD)
fi
local refspec
for refspec in "${refspecs[@]}"; do
# One leading `+` is the force marker and belongs to the refspec rather
# than to the branch, so it comes off before the destination is read -
# `git push origin +main` names main, and the force spelling is the
# destructive one to be wrong about.
refspec="${refspec#+}"
case "$refspec" in
*:*) branch="${refspec##*:}" ;;
HEAD) branch="$("$DEJIMA_GUARD_GIT" -C "$repo_dir" branch --show-current 2>/dev/null)" ;;
*) branch="$refspec" ;;
esac
# A destination that does not start with refs/ is matched against the
# remote's refs by git's rev-parse rules, so `heads/main` and `main`
# both name refs/heads/main. Stripping only the full prefix left
# `feature:heads/main` naming a branch no policy entry matched.
branch="${branch#refs/}"
branch="${branch#heads/}"
if _dejima_branch_blocked "$branch"; then
DEJIMA_GUARD_REASON="direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead."
return 1
fi
done
return 0
}
# DEJIMA_BLOCKED_BRANCHES fails OPEN: empty means no branch is protected, which
# is correct for a workflow that genuinely has no branch policy. Unlike
# DEJIMA_PUSH_HOSTS above, which fails CLOSED - empty means no host is allowed,
# so every push is refused, and that is how a no-forge or non-pushing workflow
# becomes enforced rather than merely described.
_dejima_branch_blocked() {
local blocked
[ -n "${1:-}" ] || return 1
for blocked in ${DEJIMA_BLOCKED_BRANCHES:-}; do
[ "$1" = "$blocked" ] && return 0
done
return 1
}
# --- the forge CLI ----------------------------------------------------------
# dejima_argv_has <word>... - true when those words appear consecutively among
# the non-flag arguments of the command being checked. _dejima_plain must be
# set first, by dejima_forge_verdict.
#
# Anywhere in that run rather than at the start of it, because a flag's value
# is indistinguishable from a subcommand here: in `gh --repo o/r pr merge 7`
# the first non-flag argument is `o/r`, and requiring the match to begin there
# let the most ordinary way to name a PR from outside its checkout walk
# straight past the block. Knowing which flags take a value would mean knowing
# each CLI, which is the coupling this guard exists to avoid.
#
# The cost is a command that happens to pass the words as consecutive values -
# `--title pr --body merge` - matching. For the merge block that is a loud
# refusal against a merge going through unnoticed; for a disclosure it is a
# line of text on something an agent did write anyway. Both are the direction
# to be wrong in.
dejima_argv_has() {
local want=("$@") n="${#_dejima_plain[@]}" m="$#" i=0 k
[ "$m" -gt 0 ] || return 1
while [ "$((i + m))" -le "$n" ]; do
k=0
while [ "$k" -lt "$m" ] && [ "${_dejima_plain[$((i + k))]}" = "${want[$k]}" ]; do
k=$((k + 1))
done
[ "$k" -lt "$m" ] || return 0
i=$((i + 1))
done
return 1
}
# dejima_forge_plain <argv...> - fills _dejima_plain with the non-flag
# arguments, which is what every rule below compares against.
dejima_forge_plain() {
local arg
_dejima_plain=()
for arg in "$@"; do
case "$arg" in -*) continue ;; esac
_dejima_plain+=("$arg")
done
}
# dejima_forge_commands - every CLI this policy applies to, one per line: the
# one whose merge a workflow blocks, and every one that writes a body the
# session authored. More than one, because a forge can have a CLI per artifact
# rather than one with subcommands, and because a forge with nothing blocked
# can still have something to disclose.
#
# docker/entrypoint.sh links the shadow over exactly this set, and
# hooks/merge-guard.sh checks a command against it before applying anything -
# without that the hook would apply gh's --repo rule to any command carrying
# that flag, which the shadow cannot do because it only exists for these.
dejima_forge_commands() {
[ -z "${DEJIMA_MERGE_BLOCK:-}" ] || printf '%s\n' "${DEJIMA_MERGE_BLOCK%% *}"
# "<kind> <cli> <subcommand>...", so the CLI is the second word.
[ -z "${DEJIMA_DISCLOSE:-}" ] || printf '%s\n' "$DEJIMA_DISCLOSE" |
while read -r _ _cli _; do
[ -z "$_cli" ] || printf '%s\n' "$_cli"
done
}
# dejima_forge_verdict <command name> <argv...>
#
# The command name is argv[0] as the caller saw it - the shadow's own basename,
# or the first word of a hook's segment. Every policy here is keyed to it,
# because a block naming a different command than the one being run is not this
# invocation's business.
dejima_forge_verdict() {
local self="$1"; shift
DEJIMA_GUARD_REASON=""
dejima_forge_plain "$@"
if [ -n "${DEJIMA_MERGE_BLOCK:-}" ]; then
local -a blocked
read -ra blocked <<< "$DEJIMA_MERGE_BLOCK"
if [ "${#blocked[@]}" -ge 1 ] && [ "${blocked[0]}" = "$self" ]; then
if [ "${#blocked[@]}" -eq 1 ]; then
# The whole command is blocked, not one of its subcommands.
DEJIMA_GUARD_REASON="'$self' is not available in the sandbox."
return 1
fi
if dejima_argv_has "${blocked[@]:1}"; then
DEJIMA_GUARD_REASON="merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop."
return 1
fi
fi
fi
_dejima_forge_repo_verdict "$@" || return 1
return 0
}
# The repository the session was launched against.
#
# A forge whose CLI can be aimed elsewhere declares how (repo_flags, repo_env,
# repo_paths). The gitea CLIs declare none, because they resolve the repo from
# origin and refuse another host - which leaves nothing here to compare, not
# that they are confined to one repository: origin is session-writable. `gh`
# declares all three, so on that path the
# most ordinary way to name a PR from outside its checkout is also the way to
# file an issue on a repository the session has nothing to do with (#187).
#
# Refused rather than skipped when the launch resolved no repository at all: a
# workspace with no origin has nothing for --repo to legitimately name, and
# "cannot confirm" is not a reason to let it through.
_dejima_forge_repo_verdict() {
[ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ] || [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ] \
|| [ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] || return 0
if [ -n "${DEJIMA_FORGE_REPO_ENV:-}" ]; then
local env_value="${!DEJIMA_FORGE_REPO_ENV:-}"
if [ -n "$env_value" ] && ! _dejima_same_repo "$env_value"; then
_dejima_refuse_repo "$DEJIMA_FORGE_REPO_ENV" "$env_value"
return 1
fi
fi
if [ -n "${DEJIMA_FORGE_REPO_FLAGS:-}" ]; then
local -a repo_flags args_left
read -ra repo_flags <<< "$DEJIMA_FORGE_REPO_FLAGS"
args_left=("$@")
local arg flag value letter rest
while [ "${#args_left[@]}" -gt 0 ]; do
arg="${args_left[0]}"
args_left=("${args_left[@]:1}")
for flag in "${repo_flags[@]}"; do
value=""
case "$arg" in
"$flag")
# The value is the next argument, and its absence is
# the CLI's error to report rather than this one's.
[ "${#args_left[@]}" -gt 0 ] || continue
value="${args_left[0]}"
;;
"$flag"=*) value="${arg#*=}" ;;
*)
# One-dash flags only; a two-dash flag carries its
# value with `=` or as the next argument, both above.
case "$flag" in
-?) ;;
*) continue ;;
esac
# Short flags cluster, so the letter can sit anywhere
# in one: `-Rowner/name`, `-wR owner/name` and
# `-wRowner/name` all name a repository and none of
# them is `-R` as its own argument (#243). Split at the
# letter the way getopt does - what follows it in the
# same argument is the value, and nothing following it
# means the next argument is.
letter="${flag#-}"
# A cluster, not a value that happens to start with a
# dash: one dash, then the letter somewhere in a run of
# letters. Without the second check, `--search foo-R/x`
# style arguments would be mined for flag letters.
case "$arg" in -[!-]*) ;; *) continue ;; esac
rest="${arg#-}"
case "$rest" in *"$letter"*) ;; *) continue ;; esac
case "${rest%%"$letter"*}" in *[!A-Za-z]*) continue ;; esac
value="${rest#*"$letter"}"
if [ -z "$value" ]; then
[ "${#args_left[@]}" -gt 0 ] || continue
value="${args_left[0]}"
fi
;;
esac
[ -n "$value" ] || continue
if ! _dejima_same_repo "$value"; then
_dejima_refuse_repo "$flag" "$value"
return 1
fi
done
done
fi
_dejima_forge_path_verdict "$@" || return 1
return 0
}
# A repository named in a path rather than in a flag - `gh api
# repos/<owner>/<name>/issues` reaches any repository the token does, without a
# --repo anywhere in it (#243).
#
# Only when the declared subcommand is argv's first word. The alternative is
# inspecting every positional that contains two slashes, which would refuse
# `gh issue create --title repos/a/b` - and a false refusal costs an honest
# agent a working command, which is the wrong way for a fast-fail to be wrong.
#
# Under that subcommand the rule is inverted: rather than looking for a path
# naming the wrong repository, it requires one naming the right one. `gh api`
# reaches every endpoint the token does, and an endpoint that is not under the
# declared segment - `user/repos`, `/orgs/<org>/repos`, `graphql` - names no
# repository for this to compare, so there was nothing to refuse and nothing
# was (#365). Telling an endpoint apart from a header value or a field value
# would mean knowing which of the CLI's flags take a separate value, which is
# the coupling this guard exists to avoid - so the question asked is whether a
# target it CAN read is present, which needs no such knowledge.
#
# graphql counts as one, because dejima's own github-pr reads review threads
# through it and the API has no other route to them. A mutation does not:
# `gh api graphql -f query='mutation{mergePullRequest(...)}'` is what an agent
# reaches for once `gh pr merge` refuses, and no repository scoping can be read
# out of a GraphQL document. Queries through it can only read, which the
# read-only token could do anyway.
_dejima_forge_path_verdict() {
[ -n "${DEJIMA_FORGE_REPO_PATHS:-}" ] && [ "$#" -gt 0 ] || return 0
local -a repo_paths
read -ra repo_paths <<< "$DEJIMA_FORGE_REPO_PATHS"
local spec segment arg path owner name scopable
for spec in "${repo_paths[@]}"; do
[ "$1" = "${spec%%:*}" ] || continue
segment="${spec#*:}"
scopable=0
for arg in "$@"; do
# A GraphQL document whose operation is a mutation. Matched
# anywhere in the argument, because the document arrives as the
# value of a field flag - `-f query=mutation{...}` - rather than as
# an argument of its own. Matched before the brace test below,
# which a document would otherwise satisfy.
#
# Over EVERY argument, including the dash-prefixed ones, because a
# flag can carry its value attached: `--field=query=mutation{...}`
# and `-Fquery=mutation{...}` put the document inside one word that
# the scoping loop below skips. This check needs no flag table to
# read it, which is what lets it look at all of them - unlike the
# repository scoping, which does.
#
# Both boundaries are anything that cannot continue an
# identifier, rather than whitespace and a brace. GraphQL treats
# commas and comments as ignored tokens, so `mutation,{...}` and
# `mutation#c` followed by the body are valid documents, and a
# document carrying more than one operation puts `}` immediately
# before the second one. Each of those sat just outside a narrower
# boundary. The class excludes `_` on both sides, so a field called
# foo_mutation is not a mutation.
if [[ "$arg" =~ (^|[^A-Za-z0-9_])mutation([^A-Za-z0-9_]|$) ]]; then
DEJIMA_GUARD_REASON="a GraphQL mutation reaches any repository and any write the token allows, and no repository scoping can be read out of it. Use the subcommands, and leave a merge to the operator."
return 1
fi
# A body this cannot read is a body it cannot tell a query from a
# mutation in. Two spellings put one out of reach: a field value
# read from a file, and --input, which takes the whole request body
# from a file or from stdin. Refused under this subcommand only;
# the document is one argument, so passing it inline is the fix.
case "$arg" in
*=@*|--input|--input=*)
DEJIMA_GUARD_REASON="'$1' takes its body from a file or from stdin, so what it sends is not in the command for this to read. Pass the document inline."
return 1
;;
esac
# Everything from here needs to know which argument is the
# endpoint, so the flags go.
case "$arg" in -*) continue ;; esac
[ "$arg" = graphql ] && { scopable=1; continue; }
# gh substitutes {owner} and {repo} from the checkout, so a path
# carrying them names this repository by construction and comparing
# the literal braces would refuse it.
case "$arg" in *'{'*) scopable=1; continue ;; esac
path="${arg#/}"
case "$path" in "$segment"/*/*) ;; *) continue ;; esac
path="${path#"$segment"/}"
owner="${path%%/*}"
path="${path#*/}"
name="${path%%/*}"
[ -n "$owner" ] && [ -n "$name" ] || continue
if ! _dejima_same_repo "$owner/$name"; then
_dejima_refuse_repo "the path '$arg'" "$owner/$name"
return 1
fi
scopable=1
done
if [ "$scopable" = 0 ]; then
DEJIMA_GUARD_REASON="'$1' names no endpoint under '$segment/', so there is nothing here saying which repository this reaches. Name the endpoint as '$segment/${DEJIMA_FORGE_REPO:-<owner>/<name>}/...', or use the subcommands."
return 1
fi
done
return 0
}
_dejima_refuse_repo() {
DEJIMA_GUARD_REASON="this session was launched against ${DEJIMA_FORGE_REPO:-no repository}, and $1 names '$2'. Act on the repository the sandbox mounts."
}
# Case-insensitively: forges treat an owner and a repository name as
# case-preserving but not case-sensitive, and a refusal over capitalisation
# would be a false one.
_dejima_same_repo() {
[ -n "${DEJIMA_FORGE_REPO:-}" ] && [ "${1,,}" = "${DEJIMA_FORGE_REPO,,}" ]
}
-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
+23 -7
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,
@@ -462,6 +464,16 @@ prefer credentials that cannot do anything else: a token that raises an
anonymous API rate limit should be a fine-grained token with no permissions
and no repository access, not a general-purpose one.
**A per-run fetch, not a per-container one.** The privileged job starts
twenty-seven container pairs, and each filter container asks the forge for its
published address ranges before it brings the policy up. Done once per
container that is a burst, and GitHub answers a burst on `/meta` with a `403`
from its secondary rate limit - which is not the hourly ceiling a token raises,
so a valid credential does not prevent it (#357). The job fetches once into the
directory it already shares with those containers, and the suite points every
filter at the result; a container falls back to fetching for itself when the
file is absent, which is what happens outside CI.
**The cache server.** `act_runner` enables it by default, and it binds on the
runner host specifically so that job containers can reach it. That is an
inbound service exposed to untrusted code. Turn it off where it is not
@@ -521,7 +533,11 @@ can still add a workflow of its own.
daemon, the answer is a host you are willing to lose, not a smaller grant.
- **Before Gitea 1.27, the job token stays permissive.** Branch protection
bounds where it can write; it does not take the token away.
- **None of this is checked by anything.** The rest of dejima's security
properties have tests behind them. A runner's configuration lives on a
machine this repository has no access to, so it is on you to re-check it
after upgrades and rebuilds.
- **Only part of this is checked, and only from inside a job.**
`test/ci-runner.bats` runs on both tiers and asserts what a job can see from
where it stands: which tier it landed on, that the confined one has no docker
socket and the privileged one does, and that the egress rules are in front of
the confined job. Everything outside a job - the runner's own configuration
file, its labels, what else shares the machine - lives somewhere this
repository has no access to, so it is on you to re-check that after upgrades
and rebuilds.
+114 -30
View File
@@ -70,6 +70,7 @@ which `[forge.<name>]`.
| `command` | *(the selected harness's own)* | container's main process (e.g. `nvim`), also settable per launch with `--cmd`. Overriding it changes what dejima launches, not which harness is wrapped |
| `tools` | unset | optional tools to put in the image, colon-separated; also `.dejima/tools` in a project (approved first) and `--include` per launch. None by default. See [docs/tools.md](tools.md) |
| `firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
| `internal_domains` | unset | approved domains that really do resolve inside your own network, colon-separated. The policy refuses an answer for a permitted name that points at a private address, because an answer decides what the allowlist contains and comes from outside; this exempts the ones you know are internal. It exempts only - a name here is not reachable unless it is also approved, and naming one that is not stops the launch saying so. The configured forge, the Loki host and a self-served backend's `base_url` host are permitted and exempt already, so this is for destinations dejima does not configure itself. `dejima-allow` reads this same key when adding a domain to a running session |
| `share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir. One directory per project underneath it, and only that project's is mounted at `~/shared`. `dejima-share` is the host side of it |
| `known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
| `update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord. The answer is cached in `~/.local/share/dejima/update-check`, which is safe to delete |
@@ -156,14 +157,15 @@ you selected, the second is push access when GitHub *is* your forge.
| `[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 |
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh`, and it should stay read-only. Read for every session EXCEPT when `[core] forge = github` and `push_token_file` is set: both fill `GH_TOKEN`, the forge's wins, and the launch says so. Which token can push is the half that matters |
### `[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; refused if they overlap anything dejima needs out of a session's reach |
| `read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session and be shared by every project; refused if they overlap anything dejima needs out of a session's reach |
| `read_write_per_project` | unset | the same, mounted at the path named but backed by `<path>/projects/<slug>` - one directory per project rather than one for the host. For anything a session writes to be read later as background, where another project reading it would be a channel rather than a convenience |
| `nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
### `[audit]` - the exit-time audit
@@ -241,11 +243,28 @@ 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 value that is not a host name of at least two labels is
refused when the entry is declared and again at launch, naming the key it came
from - which includes a value that reached the launcher through a URL and lost
its host on the way, such as a `loki_url` carrying userinfo.
An approval granted before this rule existed is still read rather than called
corrupt, so an upgrade does not present as tampering; the launch stops instead,
naming the entry. There is no `remove` verb, so clearing one means editing the
store by hand - `~/.config/dejima/allowed-domains` for a global approval, or
`~/.config/dejima/projects/<slug>/allowed-domains` for a project's.
**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
@@ -258,6 +277,7 @@ dejima-project status ~/myproject # declared vs approved vs missing
dejima-project approve ~/myproject # review and approve the declared set
dejima-allow --project ~/myproject crates.io
dejima-allow --global crates.io # applies to every project
dejima-allow --container <id> dl.example.com files.example.org # several
dejima-dep add --project ~/myproject cargo:cargo-nextest
dejima-dep add --global cargo:cargo-nextest
```
@@ -265,7 +285,19 @@ dejima-dep add --global cargo:cargo-nextest
Two of those tools also write the declaration, so an approval you make by
hand does not stay invisible to everyone else on the project:
`dejima-allow --project` appends to `.dejima/allowed-domains`, and
`dejima-dep add --project` appends to `.dejima/dependencies`. Both write the
`dejima-dep add --project` appends to `.dejima/dependencies`.
`dejima-allow` takes up to twenty domains at once, which is what a toolchain
fetching from several hosts actually needs. One goes through as a single
decision you already made by typing it; more than one is printed back to you
one per line and confirmed before anything is added, defaulting to no. The cap
and the confirmation are the same point: a list is where an unwanted name rides
along unread, the agent that suggested the command is what might have put it
there, and neither mechanism helps if the list is too long to finish reading.
Every name is validated before any of them is shown, so what you are confirming
is what will be added and no entry can carry an escape sequence that rewrites
the lines above it. Confirming needs a terminal, so a script does them one at a
time. Both write the
approval first - that is the part that grants - and report separately on the
declaration, so a project you can't write to still gets you the approval.
`--global` writes no declaration at all: there is no project to declare in.
@@ -785,8 +817,15 @@ ssh_key = ~/.ssh/dejima_ed25519
`entrypoint.sh` loads it into a container-local `ssh-agent` at startup and
deletes the plaintext copy immediately after - the sandbox user itself never
has a path to the raw key, only the agent socket (see
[security notes](security.md) for the full mount rationale). Setting it means
a live forwarded agent is no longer required at all.
[security notes](security.md) for the full mount rationale).
Setting it means a forwarded agent is not merely unnecessary but **not
mounted**: your own `SSH_AUTH_SOCK` is not passed to the container at all, so
nothing in a session can sign with your personal identities by naming the
socket directly. That is the point of choosing a dedicated key, and it is worth
knowing before you choose one - a session that also needed the forwarded agent
for something else (a second remote, a private submodule over ssh) no longer
has it. Leave this unset to keep the agent.
## Using a different editor/application
@@ -842,15 +881,41 @@ session is meant to write to and whose contents should outlive the container:
```
[mounts]
read_write = ~/specs
read_write = ~/.cache/shared-toolchain
```
Both lists mount at the host path they name, so a convention that says
`[mounts] read_write_per_project` mounts at the same path, but backs it with
one directory per project instead of the whole thing:
```
[mounts]
read_write_per_project = ~/specs
```
The container sees `~/specs` either way. Behind it, `read_write` puts the
directory itself - every session's contents, including other projects' - and
`read_write_per_project` puts `~/specs/projects/<slug>`, keyed the same way
`[core] share_dir` and the audit store are.
**Which to reach for is decided by one question: should another project's
session read what this one leaves there?** For a build cache or a directory of
scripts, yes, and `read_write` is right. For design docs, scratch notes,
anything an agent writes to be read later as background - no, and that is what
`read_write_per_project` is for. A spec is written to be read as context, so a
shared one is a channel from any project into every other.
All three lists mount at the host path they name, so a convention that says
"specs live in `~/specs`" keeps working inside the container without being
rewritten. That is the difference from `[core] share_dir`, which is
mounted at `~/shared` regardless of where it lives on the host because its job
is to be a well-known handoff point.
dejima creates the per-project directory; it never creates the one you named,
so a path that is not there is refused as the typo it is. Naming a
`read_write_per_project` root's `projects` directory in either of the other
two lists is refused as well - handing that over is handing over every
project's.
An entry is **refused, not warned about**, if it overlaps anything dejima
relies on being out of a session's reach: the approval store, either audit
store, the epitaph store, every project's share, `~/.local/bin`, dejima's own
@@ -910,11 +975,18 @@ dejima takes that one character back for the host while a session runs
(`bin/dejima-tty-suspend`). Ctrl-C and Ctrl-\ are deliberately left alone and
still reach the session, which is how a harness expects to be interrupted.
Suspending is not detaching. A suspended session is stopped, so it makes no
progress until you resume it, and it belongs to the terminal you started it
from. For a session that keeps working while you are away, or that you want to
pick up from somewhere else, run dejima inside tmux and use
[`dejima-attach`](remote-access.md).
Suspending is not pausing, and the difference is worth knowing before you reach
for it. What stops is the host side: `dejima` itself, the container run, the
credential proxy and the conversation shipper, which share one process group so
the kernel stops and continues them together. The container is not in that
group. It keeps running under the container runtime, with its mounts, its
firewall grants and its forge token, and keeps making requests while the
terminal looks stopped - see
[what dejima does not sandbox](security.md#what-dejima-does-not-sandbox).
Suspending is not detaching either: the job belongs to the terminal you started
it from. For a session you want to pick up from somewhere else, run dejima
inside tmux and use [`dejima-attach`](remote-access.md).
## Shared scratch space
@@ -958,13 +1030,14 @@ Treat it as a handoff point, not as general-purpose storage. If your own
project instructions tell agents to write somewhere at a fixed host path -
design docs, scratch notes, anything meant to outlive the session but stay out
of the project checkout - mount that directory instead, with
`[mounts] read_write`. It arrives at its own absolute path, so a convention
that says "put specs in `~/specs`" keeps working inside the container without
being rewritten to point at `~/shared`.
`[mounts] read_write_per_project`. It arrives at its own absolute path, so a
convention that says "put specs in `~/specs`" keeps working inside the
container without being rewritten to point at `~/shared` - and, like `~/shared`
itself, what a session finds there is this project's and not every project's.
`~/shared`, the project mount, and anything in `[mounts] read_write` are the
only writable host paths in the container. Everything else a session writes
goes away with it.
`~/shared`, the project mount, and anything in `[mounts] read_write` or
`[mounts] read_write_per_project` are the only writable host paths in the
container. Everything else a session writes goes away with it.
**Clear it out periodically.** The per-project split bounds which sessions can
read what you left, and bounds nothing about how long. A project's share
@@ -1102,22 +1175,33 @@ without the reason it is normally off (see
[docs/security.md](security.md#github-push-blocked-by-default-optional-opt-in)),
so the reason is supplied where it will be read.
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks
any `git push` whose resolved remote isn't the Gitea host, and any push to
`main`/`master` even there. Like the other hooks, this is a fast-fail guard
against an overeager agent, not a hard security boundary - a session has full
shell access and could in principle edit `.git/config` to route around it.
`git-push-guard.sh` (`PreToolUse`/`Bash`, also `DEJIMA`-gated) blocks any `git
push` whose resolved remote is not one of the configured forge's hosts, and any
push straight to a branch the workflow protects. Neither list is named here:
both are resolved host-side from the selected forge and workflow, so this is
the same rule whichever forge is in use. Like the other hooks, this is a
fast-fail guard against an overeager agent, not a hard security boundary - a
session has full shell access and could in principle edit `.git/config` to
route around it.
GitHub push is blocked by default too (see
[GitHub push](security.md#github-push-blocked-by-default-optional-opt-in) in
docs/security.md) unless `--allow-github-push` is given. For a real guarantee
against pushes to main/master, use branch protection on the remote itself.
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) blocks
`gitea-pr merge` inside the sandbox. It's the API-side counterpart to the rule
above: a merge puts a branch on `main` without ever performing a push, so
`git-push-guard.sh` never sees it. A hook is handed a command rather than a
conversation, so it can't tell an instructed merge from an unprompted one -
which is why it's scoped to the sandbox rather than applied everywhere. On the
Both this hook and the PATH shadow beside it read the decision out of
`docker/guard-lib.sh`, which is the only copy of the rules. A push that the
library cannot read is refused rather than passed through: an unresolvable
remote, `--mirror`, and `--git-dir` or `--work-tree`, each of which names a
repository other than the one the remote would be resolved in.
`merge-guard.sh` (`PreToolUse`/`Bash`, `DEJIMA`-gated) applies the configured
forge's policy inside the sandbox: the blocked merge command, and a command
aimed at a repository the session was not launched against. It's the API-side
counterpart to the rule above: a merge puts a branch on `main` without ever
performing a push, so `git-push-guard.sh` never sees it. A hook is handed a
command rather than a conversation, so it can't tell an instructed merge from
an unprompted one - which is why it's scoped to the sandbox rather than
applied everywhere. On the
host it does nothing, and you can still ask an agent to merge. The same
fast-fail caveat applies with more force than usual here: the session has the
Gitea token in its environment and could call the merge endpoint with `curl`
+67 -54
View File
@@ -12,19 +12,23 @@ 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 |
| `.dejima.md` | Project-level agent instructions, harness-agnostic: `CLAUDE.md`'s role under a neutral name. dejima loads a project's into the session context; this one is dejima's own |
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches |
| `hooks/git-push-guard.sh` | `PreToolUse` hook: blocks pushes outside the allowed host(s)/branches. Turns the reported command into argv and asks `docker/guard-lib.sh` |
| `hooks/deny-dangerous-bash.sh` | `PreToolUse` hook: blocks obviously destructive Bash commands |
| `hooks/merge-guard.sh` | `PreToolUse` hook: blocks the configured forge's merge command (`DEJIMA_MERGE_BLOCK`) in the sandbox: merging is a human decision |
| `hooks/merge-guard.sh` | `PreToolUse` hook: applies the forge policy - the blocked merge command (`DEJIMA_MERGE_BLOCK`), and a command aimed at another repository - to a reported command. Named for the merge block, which is the rule it was created for |
| `docker/guard-lib.sh` | The push and merge policy itself, read out of argv. The two hooks above and the two PATH shadows all call it, so a bypass is fixed once rather than twice |
| `hooks/dejima-notify.sh` | Drops an empty `waiting`/`done` marker for a remote-enabled session. Registered as a `Stop` hook only; `waiting` is there for a harness that can say it without having stopped - see [docs/remote-access.md](remote-access.md) |
| `bin/dejima-attach` | Lists live sessions and attaches to one; the shared core behind both remote front doors |
| `bin/dejima-audit` | Advisory exit-time diff of host-persistence surfaces (git config, `.claude/`, etc.). `dejima-audit show` / `dejima-audit ack` default to the enclosing repo |
@@ -46,7 +50,6 @@ run them.
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. `save` runs from `bin/dejima`'s exit trap; `list`/`search`/`show` are the operator's, and only ever read |
| `bin/dejima-harness-lib.sh` | Loads the selected harness's plugin and provides the `emit_*` functions it declares through; the guarded-store refusal runs in `emit_mount`. Not on PATH |
| `bin/harness/<name>.sh` | One per supported harness: what dejima does for that harness that no manifest value could state. Trusted computing base, sourced by `bin/dejima` |
| `bin/dejima-color-lib.sh` | The colour and terminal-detection helpers the CLIs share. Not on PATH |
| `bin/dejima-slug-lib.sh` | The `~/.claude/projects` slug scheme and which slugs belong to a workspace; sourced by `bin/dejima` (what to mount) and `bin/dejima-epitaph` (whose transcripts to keep), so the two cannot disagree. Not on PATH |
| `bin/dejima-ttyd` | Launches `ttyd` on loopback in front of `dejima-attach` |
| `systemd/*.service` | User units for `dejima-ttyd` and `dejima-notify-watch`; ship disabled, installed by hand |
@@ -76,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
@@ -100,29 +106,31 @@ notification path carries no sandbox-controlled byte, and that no marker
name outside `waiting`/`done` is ever acted on. `audit.bats` covers
`bin/dejima-audit`'s own diffing.
`config.bats`, `providers.bats` and `proxy.bats` round out the suites that
need no Docker either: `config.bats` covers `~/.config/dejima/config`
parsing and `install.sh`'s legacy-variable warnings (stubbing `docker` itself,
since the fast CI job's container deliberately has no daemon), `providers.bats`
covers the forge/workflow/harness manifest contract documented in
[docs/providers.md](providers.md), and `proxy.bats` covers the credential
proxy's own request handling. `conversation.bats` runs
`bin/dejima-conversation-ship` against a real transcript and a real HTTP
endpoint standing in for Loki, and asserts on what arrives: that a session's
lines ship, that a torn line waits until it is whole, that a sibling project
whose directory name merely extends this one is not swept up with it, and
that neither the in-memory batch nor the unread tail is lost to the kill that
ends a session.
`config.bats`, `providers.bats`, `proxy.bats`, `launch.bats` and `share.bats`
round out the suites that need no Docker either: `config.bats` covers
`~/.config/dejima/config` parsing and `install.sh`'s legacy-variable warnings
(stubbing `docker` itself, since the fast CI job's container deliberately has
no daemon), `providers.bats` covers the forge/workflow/harness manifest
contract documented in [docs/providers.md](providers.md), `proxy.bats` covers
the credential proxy's own request handling, `launch.bats` the command line a
launch builds, and `share.bats` the shared scratch directory's own layout.
`conversation.bats` runs `bin/dejima-conversation-ship` against a real
transcript and a real HTTP endpoint standing in for Loki, and asserts on what
arrives: that a session's lines ship, that a torn line waits until it is whole,
that a sibling project whose directory name merely extends this one is not
swept up with it, and that neither the in-memory batch nor the unread tail is
lost to the kill that ends a session.
One assertion is worth calling out because it can only run for real on a
Docker host: `runtime.bats` proves that a service bound to the host's
loopback is unreachable from inside a container while one bound to the
bridge gateway *is* reachable. The second half is a positive control - it is
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.
Docker host: `runtime.bats` proves that a service on the host is unreachable
from inside a container - bound to loopback, and bound to the bridge gateway
on a port the policy was not told to open. A second container, given that one
port, reaches it; that is the positive control, and it is what stops the
others from passing in an environment where the container reaches nothing at
all. That set 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
@@ -172,10 +180,12 @@ 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`,
`conversation.bats`, `project.bats`, `providers.bats`, `proxy.bats`,
`remote.bats` and `static.bats`, runs anywhere a shell does, including inside
a dejima sandbox itself, which has no Docker daemon by design.
`ci-runner.bats` needs something rarer - a real CI runner of either tier - and
skips itself anywhere else. Everything in between runs anywhere a shell does,
including inside a dejima sandbox itself, which has no Docker daemon by design.
`test/run-confined.sh` discovers that set rather than naming it, so a suite
added under `test/` is in the lane the day it lands - which is also why no list
of them is kept here.
`.gitea/workflows/tests.yml` also runs `shellcheck` over every shell script in
the repo, as its own job. `test/shellcheck.sh` holds the file list, discovered
@@ -193,9 +203,10 @@ obscurely partway through; the file says why each is needed.
## Adding a new harness
`[core] harness` picks a directory under `harnesses/`, and that harness's
manifest declares eleven keys (see [providers.md](providers.md)). The
inventory below is everything dejima does that is shaped by *which* harness is
selected: which parts are the manifest's, which are the plugin's, and
manifest declares what dejima needs to know about it - every key is in
[providers.md](providers.md). The inventory below is everything dejima does
that is shaped by *which* harness is selected: which parts are the manifest's,
which are the plugin's, and
which are still written against Claude Code by name. Taken from a pass over
the tree, so each row names the file rather than describing a sketch - and a
row that says "in the plugin" is one a second harness supplies rather than one
@@ -239,12 +250,14 @@ R2 was a hard gate until the guards stopped needing it. Two of the three it
hosted - the push guard and the merge guard - are now PATH shadows over `git`
and the forge CLI (`docker/git-guard.sh`, `docker/forge-guard.sh`), which hold
whatever the harness can or cannot intercept, and read argv rather than
regex-matching a reconstructed command string. That left one guard depending
on interception, and it is the one this repository already describes as "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" (docs/security.md). A gate that costs a harness its support should be
guarding more than that.
regex-matching a reconstructed command string. The hooks beside them read argv
too: they split the reported command into words and ask the same library
(`docker/guard-lib.sh`), so the overlap cannot become a disagreement. That
left one guard depending on interception, and it is the one this repository
already describes as "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" (docs/security.md). A gate that costs a harness its
support should be guarding more than that.
Declaring one absent is a real decision with a visible cost, not a
formality. It is there so a harness can be supported honestly at a smaller
+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
+19 -10
View File
@@ -127,8 +127,8 @@ operator can set, resolved the same way as any other config key
| `capabilities` | `?list:identifier` | (empty) | What this forge can do - `pr`, `issues`, etc. A workflow's own `requires` is checked against this set at launch; a workflow asking for a capability this forge lacks is refused. |
| `pr_command` | `?command` | (empty) | The PR-management command name the forge's briefing prose and `env_export`/`merge_block` refer to. |
| `issue_command` | `?command` | (empty) | The issue-management command name, same idea. |
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a command string, for the harnesses that can. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling of the flag itself: `--repo x`, `--repo=x`, `-R x`, `-Rx`, and the letter inside a short-flag cluster (`-wR x`, `-wRx`). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. |
| `merge_block` | `?command` | (empty) | The command prefix refused inside the sandbox - only when the workflow's `block_merge = on`. Enforced by `docker/forge-guard.sh`, which shadows the named CLI on PATH and compares argv, so it holds for a harness that cannot intercept a tool call; `hooks/merge-guard.sh` applies the same rule to a reported command for the harnesses that can, through the same library, so the two cannot disagree. Matched as a run of consecutive non-flag arguments, so a flag before the subcommand does not slip past it. |
| `repo_flags` | `?list:flag` | (empty) | The option names this forge's CLI accepts for naming a repository other than the one the session was launched against - `--repo -R` for `gh`. `docker/forge-guard.sh` refuses a value that is not that repository, in every spelling of the flag itself: `--repo x`, `--repo=x`, `-R x`, `-Rx`, and the letter inside a short-flag cluster (`-wR x`, `-wRx`). Fast-fail, not a boundary, for the same reason `merge_block` is not one: the session holds the token. Leave it empty for a CLI that resolves the repo itself and refuses another host, as the gitea ones do - a forge is not required to be aimable. **Enforced only for a CLI that also appears in `merge_block` or `disclose`:** those two are what name the commands the shadow is linked over and the hook checks against, so a forge declaring this and neither of them would get the rule applied to nothing. |
| `identity_keys` | `?list:identifier` | (empty) | Which of this forge's own `keys` name an **account** rather than the forge itself - the credential paths, which differ per session, where an API URL does not. One declaration, two rules: a key named here is one an `[identity.<name>]` section may set, and one a project may not declare in its own `.dejima/`. Core keeps no list of its own, so a forge whose credential is spelled differently needs no change here. The identity inherits the type from this forge's `keys` entry, not a second copy of it. |
| `repo_env` | `?identifier` | (empty) | The environment variable this forge's CLI reads for the same thing - `GH_REPO`. Declared separately because it takes no argument and would otherwise be the way around every flag above. |
| `repo_paths` | `?list:repopath` | (empty) | A repository this forge's CLI names in a path rather than in a flag, as `<subcommand>:<first segment>` - `api:repos` for `gh api repos/<owner>/<name>/...`. Scoped to a subcommand on purpose: comparing every positional argument that contains two slashes would refuse an issue title that happens to look like one, and a false refusal costs an honest agent a working command. Paths carrying `gh`'s own `{owner}`/`{repo}` placeholders are left alone, since those resolve to this repository by construction. |
@@ -138,7 +138,7 @@ operator can set, resolved the same way as any other config key
| `host_keys` | `?list:identifier` | (empty) | Config keys (from `keys`) that hold a literal hostname - unioned by `forge_hosts` into the firewall allowlist and the push guard's allowed hosts. |
| `url_keys` | `?list:identifier` | (empty) | Config keys that hold a URL - `forge_hosts` strips scheme and path and unions the bare host in the same way as `host_keys`. |
| `fixed_hosts` | `?list:hostname` | (empty) | Hostnames this forge always needs reachable, independent of any config value (`github` uses this - `github.com`, `ssh.github.com`, `api.github.com` are the same three hosts for every operator, so there's nothing to configure and nothing to get wrong). |
| `hosts_allowlisted_by_core` | `bool` | `off` | `on` means this forge's hosts are ones core already allowlists in its own right - GitHub's published ranges, which every session can read from over 443 whatever forge is selected. Core then keeps them out of `DEJIMA_FORGE_HOSTS` (the extra-domains path, which resolves a name into the port-unrestricted `allowed-domains` ipset and exempts it from DNS-rebind protection - right for an internal self-hosted host, a silent widening for a public one) and, when the workflow pushes, opens core's own GitHub push grant to them instead: port 22 to those same ranges, logged and verified by the firewall's startup self-check. They still reach `DEJIMA_PUSH_HOSTS`, so `git-push-guard.sh` permits the push. Name-free in core, but GitHub-specific in effect: this key means "core already allowlists these hosts in its own right", which today is only true of GitHub's ranges, so a future forge whose hosts are not among those must leave it `off` - setting it `on` for anything else grants no firewall allowance at all plus an irrelevant GitHub push grant. |
| `hosts_allowlisted_by_core` | `bool` | `off` | `on` means this forge's hosts are ones core already allowlists in its own right - GitHub's published ranges, which every session can read from over 443 whatever forge is selected. Core then keeps them out of `DEJIMA_FORGE_HOSTS` (the open-class path, which permits a name on every port and exempts it from the check that refuses an answer pointing inside a private network - right for an internal self-hosted host, a silent widening for a public one) and, when the workflow pushes, opens core's own GitHub push grant to them instead: port 22 to those same ranges, logged 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. |
@@ -231,13 +231,14 @@ A workflow's `briefing.md` is plain Markdown plus two marker forms that
| `image_env` | `?list:envpair` | (empty) | `NAME=VALUE` pairs set in this harness's image layer, for anything it wants in the environment of every session of its own. Deliberately narrow - no whitespace, nothing shell-active - because each becomes an `ENV` line in a generated Dockerfile. |
| `command` | `identifier` | `claude` | The container's main process for this harness, and the name the wrapper shadows on `PATH`. `[core] command` defaults to it. |
| `command_path` | `?path` | (empty) | Where the real binary lives, behind the shadow. |
| `api_domains` | `?list:hostname` | (empty) | Hostnames this harness's own machinery must reach - its update host, its telemetry endpoint. They join the *fixed* allowlist rather than the extra one, which `dns-proxy.sh` exempts from stop-dns-rebind and `init-firewall.sh` treats as non-fatal when it fails to resolve. |
| `api_domains` | `?list:hostname` | (empty) | Hostnames this harness's own machinery must reach - its update host, its telemetry endpoint. They join the built-in part of the permitted set rather than the operator's, and are not exempted from the check that refuses an answer pointing inside a private network - a harness's endpoints are public names, and exempting a public name is what turns one influenced answer into egress to an internal host. |
| `api_styles` | `?list:identifier` | (empty) | Which backend `api_style`s this harness has a client for. `claude-code` speaks `anthropic`; `cline` speaks both, which is what makes it the backend-agnostic one. Empty means unchecked. |
| `guards` | `?list:identifier` | (empty) | Which of dejima's guard rails this harness can host: `bash-deny`, `push-guard`, `merge-guard`, `notify`. A name outside that set is refused rather than treated as an unknown guard, since a typo would otherwise turn a guard off silently. The ones a harness does not declare are named at launch - see "What a harness must be able to do" in [development.md](development.md). |
| `assumed_backend` | `?provider:backend` | (empty) | Which backend this harness talked to before `[harness.<name>] backend` existed. Read only when that key is unset, and it writes into the operator's config rather than being consulted at launch - the one key here that does. |
| `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 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. |
| `no_confirm_args` | `?args` | (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` | `?args` | (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`. |
| `resume_command` | `?template` | (empty) | Prose template (may use `{{session_id}}`) shown at the end of the briefing for resuming this exact session on the host. Substitution is not recursive and the `template` type has no `=`, so a resume command that needs a second placeholder or an environment variable goes in the harness's briefing fragment instead, where both resolve. |
| `transcript_update` | `enum:append\|rewrite` | `append` | How this harness's transcript files change, which decides how they can be read incrementally. `append` means the file only grows, one record per line, so the conversation shipper reads forward from a byte offset and consumes only whole lines. `rewrite` means the file is replaced wholesale, where an offset would land in the middle of a document - it is re-read when it changes and the records already shipped are skipped by count. Declared rather than sniffed, because append semantics against a rewritten file ship garbage. |
@@ -247,7 +248,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
@@ -675,8 +676,13 @@ knowing about because they are what a second forge costs:
as such, and a count of what was hidden. Everything else `gitea-pr` does has
a `gh` subcommand already.
- **`gh` can be aimed past the sandboxed repository, and is stopped.** The
gitea CLIs refuse an origin that is not on the configured host, so they can
only act on the repo the sandbox was launched against. `gh` takes `--repo`
gitea CLIs resolve the repository from `origin` and refuse an origin that is
not on the configured host, so there is no flag or variable to compare and
nothing for this guard to declare. That is a narrower confinement than it
sounds: `origin` lives in `.git/config`, inside the read-write project mount,
and the gitea token is account-wide - so a session that rewrites `origin` to
another repository **on the same host** reaches it. What bounds that is the
token's scope, as it is for `gh`. `gh` takes `--repo`
on nearly every subcommand, reads `GH_REPO`, and names repositories in `gh
api` paths, so this forge declares all three (`repo_flags`, `repo_env`,
`repo_paths`) and `docker/forge-guard.sh` refuses a value that is not the
@@ -751,7 +757,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
+15 -10
View File
@@ -52,13 +52,17 @@ 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
The session's default-route address - the Docker bridge gateway - is your
host, and it is the one host address a sandbox can route to at all. Combine
that 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` opens no port on that address unless told to, so a
front door bound to it is refused by the packet filter as well. Do not spend
that: the clamp is the second layer, and binding the proxy correctly is the
first.
So:
- `ttyd` is bound to `127.0.0.1` by `dejima-ttyd`, hardcoded, not
@@ -901,10 +905,11 @@ invisibly, and on the same `XDG_RUNTIME_DIR` as the gotcha above.
**The marker directory is a write channel into your runtime tmpfs**, and
nothing bounds it. `/run/user/<uid>` is RAM-backed and sized at a fraction of
physical memory, so a session filling `/run/dejima-notify` degrades your
whole login session - D-Bus, the systemd user manager - not just dejima. A
session can already fill the workspace mount, so this is an increment rather
than a new exposure, but a RAM-backed filesystem fails harder and sooner.
physical memory, so a session filling `${XDG_RUNTIME_DIR}/dejima/notify`
degrades your whole login session - D-Bus, the systemd user manager - not just
dejima. A session can already fill the workspace mount, so this is an increment
rather than a new exposure, but a RAM-backed filesystem fails harder and
sooner.
There is deliberately no quota here; `rm -rf` on that run's marker directory (or
logging out, which discards the tmpfs) is the recovery.
@@ -1014,7 +1019,7 @@ name, the session's age, its state, and the container id.
The picker is coloured (gruvbox) when stdout is a terminal that says it can
show it; `NO_COLOR=1` or a `dumb` `$TERM` turns that off, along with the
box-drawing glyphs. `--list` is never coloured, terminal or not - the picker
parses those rows itself and the README calls them scriptable.
parses those rows itself and `dejima-attach --help` calls them scriptable.
Three states, from a registry `dejima` writes on launch and removes on
exit:
+9 -9
View File
@@ -12,7 +12,7 @@ August, turned up two things worth having found sooner than that:
a `.google.com` suffix while the resolver read `attacker-host`. dejima's
matcher refused that shape and every neighbouring one, for structural reasons
now recorded beside it - it matches whole domains rather than suffixes, and
the decision and the dial are bound by the ipset being filled from the
the decision and the dial are bound by the address set being filled from the
answer the resolver returned. One laxity turned up and was fixed.
- **The closest comparable project ships the opposite default.** Trail of Bits'
Claude Code devcontainer lists network under "not sandboxed" and has full
@@ -156,11 +156,11 @@ both distinctions vanish within a week of writing:
A bypass from the literature is a hypothesis about *their* implementation until
it is run against this one. dejima differs in ways that plausibly change the
answer: enforcement is `iptables` and a DNS proxy rather than an in-process
proxy, the matcher is an anchored shell regex rather than a string-suffix test,
the allowlist matches whole domains rather than suffixes, and the ipset is
populated from the answer the resolver returned rather than from a second parse
of a string.
answer: enforcement is a packet filter and a restricting resolver rather than an
in-process proxy, the matcher is an anchored shell regex rather than a
string-suffix test, the allowlist matches whole domains rather than suffixes,
and the address sets are populated from the answer the resolver returned rather
than from a second parse of a string.
Each entry states a prediction that can fail.
@@ -170,8 +170,8 @@ Each entry states a prediction that can fail.
has since been fixed.*
- **A published SOCKS-style parser differential, run end to end** rather than
against the validator alone. Prediction: no path, because nothing in dejima
speaks SOCKS and the decision and the dial are bound by the ipset. Not yet
speaks SOCKS and the decision and the dial are bound by the address set. Not yet
run.
- **Redirect chains to a host outside the allowlist.** Prediction: fail at the
ipset, because dejima does not mediate HTTP and the connection is judged by
address. Not yet run.
packet filter, because dejima does not mediate HTTP and the connection is
judged by address. Not yet run.
+284 -84
View File
@@ -87,7 +87,7 @@ last is what remains once something is.
| Layer | Reached by | Radius without | Bounded by | Radius with |
| --- | --- | --- | --- | --- |
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the `PreToolUse` hooks | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the command guards | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
| Container | ordinary operation | the operator's home directory, every credential in it, and the open internet | dejima | the workspace, allowlisted domains, and the forge token's scope |
| Devbox VM, unprivileged | container escape | everything the account can read, and unrestricted egress to the internet and the LAN | `dejima-devbox-hardener` | the same VM, with LAN reach cut to what is declared; internet egress stays open, deliberately |
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same egress, and the model API credential | `dejima-devbox-hardener`, enforced outside the guest | the whole VM and no further: root cannot reach the rule set that holds the LAN boundary |
@@ -176,8 +176,8 @@ the sandbox at it instead of at you:
- `[forge.gitea] token_file`: that account's token, rather than one issued
under yours.
- `[core] ssh_key`: a deploy key belonging to it, rather than your forwarded
agent. See "Headless git push without agent forwarding" in
[docs/configuration.md](configuration.md).
agent - which is then not mounted at all. See "Headless git push without
agent forwarding" in [docs/configuration.md](configuration.md).
With the two identities separated, the same settings stop being decorative:
@@ -202,8 +202,7 @@ collaborator on the repositories it works on. Write is the ceiling here, not
a floor: an account that owns a repository, or holds admin on it, can merge
its own work and edit the settings in the table above, so every row of that
table goes back to being decorative. `write:repository` and `write:issue` are
the scopes its token needs (README, "Gitea/Forgejo PR workflow"), and nothing
about that changes for a collaborator.
the scopes its token needs, and nothing about that changes for a collaborator.
One consequence presents as a misconfiguration, and the obvious fix is the
wrong one. Parts of Gitea's API are gated on *ownership* rather than on
@@ -271,7 +270,7 @@ In that configuration the provider directories are in the exit-time audit's
TCB list, alongside `bin/`, `hooks/`, `docker/`, `test/` and `tools/`. They
hold no code and nothing executes them, but what they hold decides what the
next launch permits: a forge's `fixed_hosts` reaches `DEJIMA_FORGE_HOSTS`,
which `entrypoint.sh` joins into the domains `init-firewall.sh` allowlists, so
which `firewall-up.sh` joins into the permitted set the egress policy is given, so
a line added to a manifest widens the egress boundary on the next run.
`tools/*/tool.conf` is the same class, and is read by the host rather than the
container: it names a tarball URL, the sha256 checked against it and the
@@ -782,7 +781,22 @@ reach the model - which reads as a dejima fault rather than a missing
prerequisite. Sign in on the host first; see the README's
[Sign in on the host first](../README.md#sign-in-on-the-host-first).
Refreshing an expired credential is the same: on the host, not from inside a
session, where the credential deliberately never goes.
session, where the credential deliberately never goes. A session that outlives
its credential - one left open overnight is the ordinary way - meets a 502 from
the proxy naming that as the remedy, and the same line is the last thing the
proxy logs, so the exit audit repeats it on the host terminal.
**What the proxy may tell a session about a credential failure.** Anything it
learned from the provider stays on the host: a response body, a status line, an
exception. What crosses into the container is text this repository wrote, and
only when it names something the operator has to do - "log in again on the
host" is useful to an agent that would otherwise report itself broken, and
discloses nothing the session did not already know. The distinction is the
credential proxy's `CredentialNeedsOperator`, which carries the two separately
so neither can be sent by accident. The proxy's own log is bound by the same
rule - it keeps provider statuses and error-body prefixes for the exit audit -
so it lives outside the socket directory, which is the one proxy path the
session mounts.
### The refreshing OAuth path, in detail
@@ -999,6 +1013,9 @@ reasoning below is what both are built on.
Gitea repo it has access to" shape as the forwarded agent above - the
mitigation is that it's meant to be a dedicated deploy key scoped to only
the repos that actually need push access, not your personal identity.
Setting it also withdraws the forwarded agent: `SSH_AUTH_SOCK` is not passed
to the container, so the two paths are alternatives rather than both being
open at once.
That scoping is a convention, not something enforced here: nothing checks
what `[core] ssh_key` actually points at, so pointing it at your real
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
@@ -1017,15 +1034,54 @@ reasoning below is what both are built on.
SSH-agent/token story documented above. Keep credentials out of your
global gitconfig, or expect them to be reachable from inside the
sandbox.
- **The egress filter is not in the container the session runs in.** A launch
starts two: a filter container that owns the network namespace and installs
the packet filter, and the sandbox, which joins that namespace with
`network_mode: container:` rather than getting one of its own. Capabilities
are per process rather than per namespace, so the sandbox cannot alter the
filter bounding it even at uid 0 - measured, not reasoned: `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
@@ -1033,17 +1089,45 @@ reasoning below is what both are built on.
anyone. `docker inspect` still shows the resolved value to whoever can
reach the daemon - that is daemon access rather than any process at all,
and it is not something the launcher can change.
- The container runs with `no-new-privileges` and an explicit capability
floor - `cap_drop: ALL` plus the ones it actually uses: what the
entrypoint's root phase needs, and `KILL`. `KILL` is there for
`dejima-allow --container`, not for the entrypoint - adding a domain to a
running session signals dnsmasq, and dnsmasq runs as its own user. The list
itself is in `docker/docker-compose.yml`, which is where it stays correct. The first means a setuid binary or a file capability cannot raise
- **Each container runs with `no-new-privileges` and a capability floor of its
own**, `cap_drop: ALL` plus what that container actually uses. The two lists
are the arrangement stated as configuration:
The session container declares **no network capability at all** - not
`NET_ADMIN`, not `NET_RAW`. It is inside a namespace the filter container
owns and has already installed the rules in, and capabilities are per
process, so uid 0 in there is refused by the kernel rather than by policy.
What it keeps is what its root phase does before the session starts: write
and chown files, and drop privilege once. `SETPCAP` is there to spend rather
than to use - it is what lets the entrypoint take the rest of the list away
again before execing the session, and since Linux 4.3 it grants nothing a
process does not already hold.
The filter container holds the network capabilities. The session's list is in
`docker/docker-compose.yml` and the filter's is in `bin/dejima`, each beside
the container it describes.
`KILL` is on both lists, for unrelated reasons, and it is the one worth
reading twice. The filter container needs it because adding a domain to a
running session signals dnsmasq, which runs as its own user. The session
container needs it because `init: true` makes PID 1 an init that forwards
signals to the session - and the entrypoint changes uid in place before
exec'ing it, so that is uid 0 signalling a process whose real and saved uids
are the sandbox user's. Without the capability every forwarded signal is
`EPERM`, tini treats a failed forward as fatal, and an ordinary terminal
resize ends the session.
`no-new-privileges` means a setuid binary or a file capability cannot raise
privileges on exec, which is the shape any escalation from the session user
would have to take; the second means the capabilities nothing here uses were
never in the bounding set to be regained. Both are asserted against a running
container rather than against the compose file, and the capability list the
test uses is read out of that file so the two cannot drift.
would have to take. The floor means the capabilities nothing uses were never
in the bounding set to be regained. Both are asserted against running
containers rather than against the files, and both lists the tests use are
read out of those files so neither can drift.
What a floor describes is a container's **root phase**. A launch runs with
`init: true`, so PID 1 is the init that reaps for the session and it holds
that container's floor for as long as the container lives; the session is its
child and holds nothing.
- The approval store (`${DEJIMA_CONFIG_DIR:-$HOME/.config/dejima}`,
see
[Global and per-project domains and dependencies](../README.md#global-and-per-project-domains-and-dependencies)
@@ -1147,12 +1231,14 @@ 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)
- nothing on the Docker host. Its address is a named destination so that
reaching for it is counted and refused rather than falling off the end of the
chain, but no port on it is open (see "The host entry" below)
- GitHub's published IP ranges, port 443 only (see "GitHub push" below)
- `registry.npmjs.org`, plus whatever the selected harness declares as its own
(`platform.claude.com` for Claude Code; Cline declares none, so a session
@@ -1186,33 +1272,116 @@ 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 also resolves every permitted name and reads each answer
back out of the address set its class names, refusing to start if one is
missing. A name that resolves and whose address is not where the ports are
keyed to would be a session that reaches a configured destination only by
accident, or not at all - and the class an entry carries, the resolver line
built from it, and the set the rules use are three separate statements that can
each be right about a different one. A name that returns no address is reported
rather than fatal: that is the world being unavailable, not the policy
disagreeing with itself.
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.
### The host entry
Every other destination in the policy is there because something declared it.
The host entry is not: it is the gateway of the container's default route,
derived from the routing table. Docker publishes container ports on that
address, so permitting it on every port would reach the host's sshd, anything
bound to `0.0.0.0`, and every published port of every other container on the
machine - including dejima's own remote-access front door, which is a host
shell behind a tmux prefix key.
So it opens no ports. The address is still named in the ruleset, with its own
refusal rather than a fall-through to the end of the chain, so that a session
reaching for the host shows up as a counted refusal instead of an anonymous
one.
Nothing in a launch needs it open. A host-resident forge, a host-resident Loki
and a self-served model endpoint are each reached through their own entry - by
name, or as a literal address - and each lands in a set of its own with its own
rules. Configuring one of those is how a host service becomes reachable.
For anything else, `EGRESS_HOST_PORTS` on the filter container takes TCP ports
and low-high ranges, comma or space separated; `EGRESS_HOST_PORTS=any` restores
the unclamped behaviour explicitly, and `EGRESS_HOST_ADDR=none` removes the
entry altogether. A value that is not a port stops the container rather than
becoming a rule nobody meant.
### 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
@@ -1225,14 +1394,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
@@ -1256,23 +1432,33 @@ sandbox, so there is no need for it to guess one and risk "successfully"
updating someone else's session instead.
A domain allowed this way stays allowed for the rest of the session even
if its CDN later rotates edge IPs: `dns-proxy.sh` tops up the firewall's
`ipset` from every DNS answer it serves, not just the one resolved at
allow-time, since only allowlisted names get an upstream to answer from in
the first place. GitHub's own domains sit outside this - their existing
port-443-only `ipset` entries still win by dnsmasq's longest-match rule -
so that restriction is unaffected.
if its CDN later rotates edge IPs: the resolver writes every answer it serves
into the address set its entry's class names, not just the one resolved at
allow-time, since only permitted names get an upstream to answer from in the
first place. GitHub's own domains keep their port-443 restriction through it:
what confines them is which class the name is in, and `dejima-allow` reads that
from the same file the container composes its own lists from. The resolver's
longest match decides which set an answer is written into; the ruleset's order
decides which ports that set is reachable on, with the forge class's
permit-and-refuse pair ahead of the open class. Adding a domain can widen what
a session reaches, never how.
One gap remains: allowing an *internal* hostname (one that resolves to an
RFC1918 address) mid-session doesn't work. dnsmasq's `stop-dns-rebind`
rejects private answers for any domain not exempted at startup, and that
exemption list can only be extended by restarting dnsmasq. `dejima-allow`
re-asks the container's upstream resolver when a name fails to resolve, so it
names this case and the relaunch that fixes it rather than reporting the host
as unresolvable and sending you to look at a DNS server that is working.
Internal hosts named before the container starts (`[forge.gitea] ssh_host`,
`api`, `[logging] loki_url`, and any globally or per-project approved domain)
are already exempted and unaffected by this.
An *internal* hostname - one that resolves to an address inside your own
network - is the one case that needs saying twice. The policy refuses such an
answer for a permitted name, because an answer decides what the allowlist
contains and comes from outside, so one influenced reply for a public name
would be egress to a host on your network. Destinations dejima resolves itself
(`[forge.gitea] ssh_host`, `api`, `[logging] loki_url`) are exempt, because you
configured their addresses and there is nothing for an answer to decide. An
approved domain is not: `.dejima/allowed-domains` is reviewed with the project
and expected to carry public names.
So an internal host you approve also goes in `[core] internal_domains`. Both
halves read that one key: a launch exempts those entries and nothing else, and
`dejima-allow` passes the same exemption only for a name that is in it. Adding
such a name to a running session works when it is listed and is refused when it
is not, with the refusal naming the key - rather than the two commands having
different ideas and the disagreement surfacing as a failed launch later.
The session briefing tells the agent to ask for this by name when a task is
blocked on a single missing domain, rather than stopping the whole session
@@ -1363,19 +1549,26 @@ execution, and the audit reports the config change it took.
## Network request logging
If `[logging] loki_url` is set (see Prerequisites in the README), every DNS query and every new
If `[logging] loki_url` is set (see [docs/configuration.md](configuration.md)),
every DNS query and every new
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
@@ -1438,7 +1631,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"
@@ -1558,8 +1751,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
@@ -1592,9 +1792,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.
+1 -1
View File
@@ -110,7 +110,7 @@ if nothing is arranged against it, the last is what remains once something is.
| Layer | Reached by | Radius without | Bounded by | Radius with |
| --- | --- | --- | --- | --- |
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the `PreToolUse` hooks | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
| Agent, not yet hostile | ordinary operation | the workspace, its history, every mounted path, and the forge, at full speed | the session briefing and `.dejima.md`; the command guards | obvious destruction and out-of-remote pushes fail fast, and merging stays a human decision |
| Container | ordinary operation | the operator's home directory, every credential in it, and the open internet | dejima | the workspace, allowlisted domains, and the forge token's scope |
| Devbox VM, unprivileged | container escape | everything the account can read, and unrestricted egress to the internet and the LAN | `dejima-devbox-hardener` | the same VM, with LAN reach cut to what is declared; internet egress stays open, deliberately |
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same egress, and the model API credential | `dejima-devbox-hardener`, enforced outside the guest | the whole VM and no further: root cannot reach the rule set that holds the LAN boundary |
+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
+1 -1
View File
@@ -3,7 +3,7 @@ image_dirs = .claude .claude/projects .claude/file-history
image_files = .claude/.credentials.json
image_env = CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
command = claude
command_path = /usr/local/share/npm-global/bin/claude
command_path = /opt/node-tools/bin/claude
api_domains = platform.claude.com
guards = bash-deny push-guard merge-guard notify
+7 -4
View File
@@ -24,7 +24,7 @@ image_dirs = .cline .cline/data .cline/hooks .agents .agents/skills .age
image_env = DO_NOT_TRACK=1 DISABLE_TELEMETRY=1 CLINE_NO_AUTO_UPDATE=1
command = cline
command_path = /usr/local/share/npm-global/bin/cline
command_path = /opt/node-tools/bin/cline
# Nothing is fetched at startup: the whole assessment ran against a loopback
# endpoint with the firewall up, so there is no catalogue or feature-flag host
@@ -35,9 +35,12 @@ api_domains =
# 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
# hosts them any more - they are PATH shadows and hold regardless. Launch
# names what this costs; see harness_report_guards.
# a notification.
#
# push-guard and merge-guard are not listed because this harness cannot host
# them, not because nothing does - claude-code declares both, and the PATH
# shadows cover a harness that does not. Declaring none here costs the hook
# layer, not the guard. Launch names the cost; see harness_report_guards.
guards =
# Both, which is what makes this harness the backend-agnostic one: it has a
+23 -9
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")
@@ -207,7 +221,7 @@ if [ -f "$decl_dir/allowed-domains" ]; then
Tell the user to run, outside the sandbox:
dejima-project approve \"$project_dir\"
(or, for one domain right now:
dejima-allow --container $container_id <domain>)"
dejima-allow --container $container_id <domain>...)"
fi
fi
+35 -155
View File
@@ -3,19 +3,19 @@
# DEJIMA_PUSH_HOSTS, and never directly to a branch listed in
# DEJIMA_BLOCKED_BRANCHES - both resolved host-side by bin/dejima from the
# configured forge and workflow (see dejima-provider-lib.sh), not hardcoded
# to any one forge. This is a fast-fail guard against an overeager agent,
# 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
# sandbox (DEJIMA unset), since this restriction doesn't apply to normal
# host usage.
# to any one forge.
#
# DEJIMA_PUSH_HOSTS fails CLOSED: empty means no host is allowed, so every
# push is refused. That is deliberate - it is how a no-forge or
# non-pushing workflow becomes enforced rather than merely described.
# DEJIMA_BLOCKED_BRANCHES fails OPEN: empty means no branch is protected,
# which is correct for a workflow that genuinely has no branch policy.
# The rules themselves are docker/guard-lib.sh, which docker/git-guard.sh
# applies to the same push as a PATH shadow. This file's whole job is to turn
# the command string a harness reports into the argv that library reads, so
# that the two cannot reach different verdicts - which they had already started
# to do.
#
# A fast-fail guard against an overeager agent, 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, and 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.
if [ -z "${DEJIMA:-}" ]; then
exit 0
@@ -25,151 +25,31 @@ input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')
cwd=$(echo "$input" | jq -r '.cwd // empty')
# Only care about `git push` invocations - found by walking tokens rather than
# by matching a shape.
# shellcheck source=../docker/guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
# A push can be the second half of a compound command, and only a segment whose
# first word is git is a git invocation. Every segment that is one gets the same
# verdict function the shadow calls, on the words after `git`, so git's own
# options do not need anticipating here: they are argv either way.
#
# 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
# 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=()
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"
refspec=""
if [ "${#positional[@]}" -ge 1 ]; then
remote="${positional[0]}"
fi
if [ "${#positional[@]}" -ge 2 ]; then
refspec="${positional[1]}"
fi
# --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
# 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#')
host_allowed=0
for allowed_host in ${DEJIMA_PUSH_HOSTS:-}; do
if [ "$host" = "$allowed_host" ]; then
host_allowed=1
break
fi
done
if [ "$host_allowed" != 1 ]; then
echo "Blocked by git-push-guard.sh: this sandbox may only push to ${DEJIMA_FORGE_NAME:-the configured forge}'s allowed host(s) (${DEJIMA_PUSH_HOSTS:-none allowed}), not '$host'." >&2
exit 2
fi
# Resolve the target branch: a src:dst refspec's dst, a bare branch name, or
# (if no refspec was given at all, or it's HEAD) the current branch - `git
# push origin` and `git push origin HEAD` both push the current branch.
if [[ "$refspec" == *:* ]]; then
branch="${refspec#*:}"
elif [ -n "$refspec" ] && [ "$refspec" != "HEAD" ]; then
branch="$refspec"
else
branch=$(git -C "$repo_dir" branch --show-current 2>/dev/null)
fi
branch="${branch#refs/heads/}"
for blocked_branch in ${DEJIMA_BLOCKED_BRANCHES:-}; do
if [ "$branch" = "$blocked_branch" ]; then
echo "Blocked by git-push-guard.sh: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead." >&2
# What a segment's FIRST word has to be is still git. A `FOO=bar git push`
# prefix, or `env git push`, is a git invocation this does not see - the PATH
# shadow does, which is the division of labour the pair exists for rather than
# a gap in it.
# Basenamed, like hooks/merge-guard.sh and like the shadow, which reads its own
# $0. `/usr/bin/git push origin main` is the one spelling that reaches neither
# layer otherwise - the absolute path steps over the PATH shadow, and comparing
# the whole word meant this hook did not see a git invocation at all. It is
# also the form the library's own header names as the reason both layers exist.
while IFS= read -r segment; do
read -ra tokens <<< "$segment"
[ "${#tokens[@]}" -gt 0 ] || continue
[ "$(basename -- "${tokens[0]}")" = git ] || continue
if ! dejima_push_verdict "$cwd" "${tokens[@]:1}"; then
echo "Blocked by git-push-guard.sh: $DEJIMA_GUARD_REASON" >&2
exit 2
fi
done
done < <(dejima_guard_segments "$command")
exit 0
+36 -22
View File
@@ -1,10 +1,15 @@
#!/usr/bin/env bash
# PreToolUse hook: inside dejima, block a merge command matching
# DEJIMA_MERGE_BLOCK - a literal command prefix resolved host-side by
# bin/dejima from the configured forge and workflow, never hardcoded to one
# forge. What lands on the default branch is an operator decision, as it
# already is for `git push`; this is the API-side counterpart to
# git-push-guard.sh, since a merge reaches the branch without a push.
# PreToolUse hook: inside dejima, apply the forge policy to a forge CLI call -
# the merge a workflow blocks, and a command aimed at a repository the session
# was not launched against. What lands on the default branch is an operator
# decision, as it already is for `git push`; this is the API-side counterpart
# to git-push-guard.sh, since a merge reaches the branch without a push.
#
# The rules are docker/guard-lib.sh, which docker/forge-guard.sh applies to the
# same command as a PATH shadow. This file's job is to turn the command string
# a harness reports into the argv that library reads, so the two cannot reach
# different verdicts. Named for the merge block because that is the rule it was
# created for; the rest came with the shared policy.
#
# Not conditional on intent: a hook is handed a command, not a conversation,
# so it cannot tell an instructed merge from an unprompted one. The line is
@@ -16,32 +21,41 @@
# shell access and the forge token, so it could call the merge endpoint with
# curl. It stops the mistake, not the attack.
#
# Fails OPEN - empty means nothing is blocked, so a workflow with no
# merge-block policy genuinely has none rather than the hook guessing.
# Fails OPEN - a forge that declares no policy has none blocked, rather than
# the hook guessing.
if [ -z "${DEJIMA:-}" ]; then
exit 0
fi
if [ -z "${DEJIMA_MERGE_BLOCK:-}" ]; then
if [ -z "${DEJIMA_MERGE_BLOCK:-}" ] && [ -z "${DEJIMA_DISCLOSE:-}" ] &&
[ -z "${DEJIMA_FORGE_REPO_FLAGS:-}" ] && [ -z "${DEJIMA_FORGE_REPO_ENV:-}" ] &&
[ -z "${DEJIMA_FORGE_REPO_PATHS:-}" ]; then
exit 0
fi
command=$(jq -r '.tool_input.command // empty')
# DEJIMA_MERGE_BLOCK is a literal command prefix from the forge manifest,
# not a regex - escape its regex metacharacters before matching so a value
# like ".*" matches only that literal text, never "everything".
escaped_block=$(printf '%s' "$DEJIMA_MERGE_BLOCK" | sed -E 's/[][(){}.*+?^$\\|]/\\&/g')
# shellcheck source=../docker/guard-lib.sh
source "${DEJIMA_GUARD_LIB:-/usr/local/lib/dejima/guard-lib.sh}"
# The leading class includes '/' so a path-qualified call is caught too -
# the Dockerfile installs each forge's CLI under /usr/local/bin, and that's
# what an agent reaching past a shadowed PATH would type. Word-splitting
# rather than a full shell parse, the same pragmatic tradeoff the sibling
# hook makes.
if [[ "$command" =~ (^|[[:space:]&\|\;/])$escaped_block([[:space:]]|$) ]]; then
echo "Blocked by merge-guard.sh: merging is a human decision in the sandbox - open a PR/MR, then the operator merges. Report its URL and stop." >&2
exit 2
fi
forge_commands="$(dejima_forge_commands | sort -u)"
[ -n "$forge_commands" ] || exit 0
# Basenamed, so a path-qualified call is checked too - the Dockerfile installs
# each forge's CLI under /usr/local/bin, and the real one sits behind the
# shadow at a fixed path, which is what an agent reaching past a shadowed PATH
# would type. It is also what the shadow itself compares, since that reads its
# own basename.
while IFS= read -r segment; do
read -ra tokens <<< "$segment"
[ "${#tokens[@]}" -gt 0 ] || continue
self="$(basename -- "${tokens[0]}")"
grep -qxF -- "$self" <<< "$forge_commands" || continue
if ! dejima_forge_verdict "$self" "${tokens[@]:1}"; then
echo "Blocked by merge-guard.sh: $DEJIMA_GUARD_REASON" >&2
exit 2
fi
done < <(dejima_guard_segments "$command")
exit 0
+3 -4
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env bash
# SessionStart hook: when running inside the dejima Docker container
# (see https://github.com/anthropics/claude-code/tree/main/.devcontainer for
# the reference this is based on, and this repo's own README for the actual
# setup), hand Claude Code the session briefing.
# SessionStart hook: when running inside the dejima Docker container, hand
# Claude Code the session briefing. The container is dejima's own - the README
# describes what it is and docs/security.md what it bounds.
#
# Everything that composes that briefing lives in dejima-briefing-render.sh,
# baked into the image and shared with every harness. What is left here is the
+3 -1
View File
@@ -140,7 +140,9 @@ else
# 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'
printf '# This file holds what you have decided, not every key there is.\n'
printf '# defaults.conf beside it is the complete reference, rewritten by\n'
printf '# every ./install.sh - do not edit that one.\n'
printf '#\n'
printf '# [core] harness, forge and workflow are written out uncommented, so\n'
printf '# this file states what it selects rather than inheriting a future\n'
+1 -1
View File
@@ -2112,7 +2112,7 @@ EOF
@test "TCB: a manifest hidden from git is a CRITICAL finding" {
# A forge's fixed_hosts reaches DEJIMA_FORGE_HOSTS, which entrypoint.sh
# joins into extra_domains and init-firewall.sh allowlists - so a line
# joins into the permitted set the egress policy is given - so a line
# added to a manifest widens the egress allowlist on the next launch.
# Manifests are data and are never executed, which is why they were not
# in the TCB list; what they are is a decision about what the next launch
+4 -10
View File
@@ -77,16 +77,10 @@ PY
[ -e /var/run/docker.sock ]
}
@test "the two runners are not the same runner" {
# Whatever else is true, exactly one of them gets the daemon. If both jobs
# report the same answer here, the labels are not routing to two different
# runners and the tiering exists only on paper.
if [ "$DEJIMA_CI_TIER" = confined ]; then
[ ! -e /var/run/docker.sock ]
else
[ -e /var/run/docker.sock ]
fi
}
# The two directions above are each one job's answer: the confined runner must
# not have the docker socket and the privileged one must. That the two jobs land
# on DIFFERENT runners does not follow from either, and a suite cannot see it -
# it runs in one job. Comparing the two jobs' results is the workflow's to do.
# --- egress, for the tier the rules actually apply to ------------------------
#
+362 -17
View File
@@ -63,6 +63,46 @@ EOF
[ "$status" -ne 0 ]
}
@test "core.internal_domains parses as a list of hostnames, and rejects what is not one" {
# The key gates whether a security check applies to a name, so a typo in it
# must not be read as a value. `?list:hostname` is what makes the whole
# file refuse rather than the entry being skipped.
run lib config_type core.internal_domains
[ "$output" = "?list:hostname" ]
write_config '[core]' 'internal_domains = mirror.example:logs.example'
run lib config_get core internal_domains
[ "$status" -eq 0 ]
[ "$output" = "mirror.example:logs.example" ]
# A colon-separated list, so the launch path's `tr ':' ' '` gets two
# entries rather than one nonsense destination.
local -a entries=()
IFS=':' read -ra entries <<< "$output"
[ "${#entries[@]}" -eq 2 ] \
|| { echo "the list did not split into two entries: ${entries[*]}"; return 1; }
# And it comes back with no trailing newline, which every consumer that
# reads it line by line has to allow for: a `while read` over a
# single-entry list gets EOF on the only line and never runs its body.
# bin/dejima-allow was written without that guard and silently treated
# every declared name as undeclared.
run bash -c 'source "$1"; printf "[%s]" "$(config_get core internal_domains)"' _ "$LIB"
[ "$output" = "[mirror.example:logs.example]" ]
write_config '[core]' 'internal_domains = not a hostname'
run lib config_get core internal_domains
[ "$status" -ne 0 ] \
|| { echo "a malformed entry was accepted: $output"; return 1; }
# Unset is the default and is not an error - most hosts have no internal
# destination to declare.
write_config '[core]' 'firewall = on'
run lib config_get core internal_domains
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "config_type and config_default split the spec" {
run lib config_type audit.keep
[ "$output" = "int:1:1000" ]
@@ -751,7 +791,7 @@ write_defaults() { printf '%s\n' "$@" > "$CONFIG_DIR/defaults.conf"; }
_identity_fixture() {
ID_DIR="$(mktemp -d)"
: > "$ID_DIR/default-key"; : > "$ID_DIR/work-key"; : > "$ID_DIR/oss-key"
printf "k\n" | tee "$ID_DIR/default-key" "$ID_DIR/work-key" > "$ID_DIR/oss-key"
: > "$ID_DIR/default-token"; : > "$ID_DIR/work-token"
write_config '[core]' 'forge = gitea' "ssh_key = $ID_DIR/default-key" \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
@@ -969,7 +1009,7 @@ _identity_fixture() {
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
# forge = none, workflow = local-only: provider_validate_selection runs
# ahead of this check, and the default gitea/worktree-pr pair is refused
@@ -1245,7 +1285,7 @@ _identity_fixture() {
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only'
workspace="$(mktemp -d)"
@@ -1275,7 +1315,7 @@ EOF
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' "ssh_key = $key" \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://api.example/v1' \
@@ -1394,8 +1434,8 @@ EOF
@test "the github forge's hosts reach the push guard, never the extra-domains allowlist" {
# entrypoint.sh folds DEJIMA_FORGE_HOSTS into EXTRA_ALLOWED_DOMAINS,
# which init-firewall.sh resolves into the port-UNRESTRICTED
# allowed-domains ipset and dns-proxy.sh exempts from stop-dns-rebind.
# which firewall-up.sh puts in the port-unrestricted open class and
# exempts from the check that refuses an answer pointing inside.
# Right for a self-hosted forge host, a silent widening for GitHub's
# public names - and the firewall's own startup self-check refuses to
# bring the container up when github.com:22 is reachable without the
@@ -1409,7 +1449,7 @@ EOF
# dedicated key file is the headless form of that, and keeps this test
# independent of whether the host running it has an agent.
local key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' 'forge = github' 'workflow = worktree-pr' \
"ssh_key = $key" \
@@ -1466,7 +1506,7 @@ EOF
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
local key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' 'forge = gitea' 'workflow = branch-push' \
"ssh_key = $key" \
@@ -1490,7 +1530,7 @@ EOF
mkdir -p "$FAKE_HOME/.claude"
echo '{}' > "$FAKE_HOME/.claude/settings.json"
key="$FAKE_HOME/sandbox-key"
: > "$key"
printf "not-a-real-key\n" > "$key"
chmod 600 "$key"
write_config '[core]' "ssh_key = $key" 'forge = none' 'workflow = local-only' \
'[logging]' 'loki_url = https://loki.example.invalid'
@@ -1667,7 +1707,7 @@ _rw_setup() {
mkdir -p "$RW_HOME/.claude"
echo '{}' > "$RW_HOME/.claude/settings.json"
RW_KEY="$RW_HOME/sandbox-key"
: > "$RW_KEY"
printf "not-a-real-key\n" > "$RW_KEY"
chmod 600 "$RW_KEY"
}
@@ -1791,6 +1831,118 @@ _kill_agent() { [ -n "${RW_AGENT_PID:-}" ] && kill "$RW_AGENT_PID" 2>/dev/null;
rm -rf "$workspace"
}
# A launch that really reaches `docker compose`, with a pushing workflow, so the
# rule that blanks the agent socket for a workflow that never pushes is not the
# one doing the work. Its own fixture rather than _rw_setup's: this needs a
# forge configured, and stdin closed because a full launch opens /dev/tty,
# which under bats is not the terminal it expects.
#
# The stub records its own environment rather than its arguments, because the
# volume line resolves ${SSH_AUTH_SOCK:-/dev/null} inside docker-compose - so
# the environment handed to it IS whether the agent is mounted. Into a file,
# because the launch prints plenty of its own and the assertion is about one
# value.
#
# Leaves AGENT_LOG and AGENT_OUT for the caller to read on failure.
_agent_launch() { # <ssh_key or "">
local key="$1" h c s w
# The suite exports DEJIMA_CONFIG_DIR, which is where a launch under it
# reads its config from - a config written under $HOME would not be found.
h="$(mktemp -d)"; c="$CONFIG_DIR"
printf 'tok\n' > "$c/token"
{
printf '[core]\nharness = claude-code\nfirewall = on\nupdate_check = off\n'
printf 'forge = gitea\nworkflow = worktree-pr\n'
printf '[harness.claude-code]\nbackend = anthropic\n'
printf '[forge.gitea]\nssh_host = git.example\napi = https://api.example\n'
# The forge credential moves under the identity when there is one: an
# identity that leaves one behind is a session acting under whichever
# account owns it, which the launch refuses outright.
if [ -n "$key" ]; then
printf '[identity.default]\nssh_key = %s\ntoken_file = %s/token\n' "$key" "$c"
else
printf 'token_file = %s/token\n' "$c"
fi
} > "$c/config"
AGENT_LOG="$(mktemp)"
s="$(mktemp -d)"
cat > "$s/docker" <<'STUB'
#!/bin/sh
printf 'SSH_AUTH_SOCK=[%s]\n' "${SSH_AUTH_SOCK-unset}" >> "$AGENT_SOCK_LOG"
exit 0
STUB
chmod +x "$s/docker"
w="$(mktemp -d)"; git -C "$w" init -q
# `|| true` because a launch here ends however the stubbed session ended,
# and this is not a test about its exit status.
AGENT_OUT="$(HOME="$h" PATH="$s:$PATH" SSH_AUTH_SOCK="$AGENT_SOCK" \
AGENT_SOCK_LOG="$AGENT_LOG" \
bash "$REPO_ROOT/bin/dejima" "$w" </dev/null 2>&1 || true)"
rm -rf "$h" "$s" "$w"
}
# A real agent, because bin/dejima tests the socket with `[ -S ]` and refuses a
# path that is not one.
_real_agent() {
eval "$(ssh-agent -s)" >/dev/null
AGENT_SOCK="$SSH_AUTH_SOCK"
AGENT_PID="$SSH_AGENT_PID"
}
@test "a configured ssh_key means the operator's agent is not in the container at all" {
# entrypoint.sh loads that key into an agent of its own and points
# SSH_AUTH_SOCK at it, so the forwarded one was already unused - but it
# stayed mounted at the path docker-compose.yml names, where
# `SSH_AUTH_SOCK=/ssh-agent ssh ...` signs with the operator's personal
# identities. Choosing a dedicated key has to mean the personal agent is
# not in the container, not that it is merely not the default.
local key
_real_agent
key="$(mktemp)"; printf 'not-a-real-key\n' > "$key"; chmod 600 "$key"
_agent_launch "$key"
kill "$AGENT_PID" 2>/dev/null || true
rm -f "$key"
[ -s "$AGENT_LOG" ] \
|| { echo "the launch never reached docker:"; echo "$AGENT_OUT" | tail -15; return 1; }
[ "$(sort -u "$AGENT_LOG")" = "SSH_AUTH_SOCK=[]" ] \
|| { echo "the forwarded agent still reached compose:"; sort -u "$AGENT_LOG"; return 1; }
}
@test "without a configured ssh_key the forwarded agent still reaches the container" {
# The positive control for the test above, and the reason it means
# anything: without it, a launch that simply never set the variable would
# satisfy that assertion too.
_real_agent
_agent_launch ""
local want="SSH_AUTH_SOCK=[$AGENT_SOCK]"
kill "$AGENT_PID" 2>/dev/null || true
[ -s "$AGENT_LOG" ] \
|| { echo "the launch never reached docker:"; echo "$AGENT_OUT" | tail -15; return 1; }
[ "$(sort -u "$AGENT_LOG")" = "$want" ] \
|| { echo "the forwarded agent did not reach compose:"; sort -u "$AGENT_LOG"; return 1; }
}
@test "an empty ssh_key file is not a key, and a pushing launch says so" {
# It passes the is-a-file check and loads nothing in the container, so it
# cannot be what stops the forwarded agent being mounted. Treated as no key
# at all: harmless for a workflow that never pushes - which is what most of
# this suite's fixtures are, with an empty file standing in for a path -
# and named for one that does.
# A pushing workflow with an empty key and no agent: the one case where it
# costs anything, and the message names the file rather than only saying no
# key is set. The with-an-agent direction is its own test above.
AGENT_SOCK=""
local key; key="$(mktemp)"; chmod 600 "$key"
_agent_launch "$key"
rm -f "$key"
[[ "$AGENT_OUT" == *"names an empty file"* ]] \
|| { echo "an empty key went unmentioned:"; echo "$AGENT_OUT" | tail -12; return 1; }
[ ! -s "$AGENT_LOG" ] \
|| { echo "the launch reached docker with no credential at all"; return 1; }
}
# The positive control for the test above: with no key configured the
# forwarded agent really is the only way to push, and an empty one is worth
# saying so before it fails deep into a session.
@@ -2221,6 +2373,139 @@ EOF
rm -rf "$workspace" "$RW_HOME"
}
@test "a read_write_per_project entry mounts this project's slice at the path it names" {
_rw_setup
pp="$(mktemp -d)"
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write_per_project = $pp"
_rw_launch "$workspace"
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
# The container gets the path the operator named; the host gives it one
# directory of that path, keyed the same way share_dir and the audit store
# are. Both halves asserted, because either alone is satisfied by the
# arrangement this replaces.
slug="$(printf '%s' "$workspace" | sed 's/[^a-zA-Z0-9]/-/g')"
grep -qxF "$pp/projects/$slug:$pp" "$docker_log" \
|| { echo "not mounted as a per-project slice:"; grep "$pp" "$docker_log"; return 1; }
run grep -qxF "$pp:$pp" "$docker_log"
[ "$status" -ne 0 ] \
|| { echo "the whole directory was handed over as well"; return 1; }
[ -d "$pp/projects/$slug" ] || { echo "the slice was never created"; return 1; }
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
}
@test "two projects get different slices of one read_write_per_project entry" {
# The whole point, and not deducible from one launch: the same declared
# directory has to give two workspaces two directories, or it is a shared
# pile with extra steps.
_rw_setup
pp="$(mktemp -d)"
ws_a="$(mktemp -d)"; ws_b="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write_per_project = $pp"
_rw_launch "$ws_a"; [ "$status" -eq 0 ] || { echo "$output"; return 1; }
a_log="$docker_log"
_rw_launch "$ws_b"; [ "$status" -eq 0 ] || { echo "$output"; return 1; }
a="$(grep -oE "$pp/projects/[^:]+" "$a_log" | head -1)"
b="$(grep -oE "$pp/projects/[^:]+" "$docker_log" | head -1)"
[ -n "$a" ] && [ -n "$b" ] || { echo "no slice mounted for one of them"; return 1; }
[ "$a" != "$b" ] \
|| { echo "both projects were given the same directory: $a"; return 1; }
rm -rf "$pp" "$ws_a" "$ws_b" "$RW_HOME" "$a_log" "$docker_log"
}
@test "read_write is refused when it names a read_write_per_project root" {
# The share guard's reason, one directory over: handing over the root that
# holds every project's slice puts back the pile the slicing removed.
_rw_setup
pp="$(mktemp -d)"
workspace="$(mktemp -d)"
mkdir -p "$pp/projects"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $pp/projects" "read_write_per_project = $pp"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "the pile was handed back: $output"; return 1; }
[[ "$output" == *"holds one directory per project"* ]] || { echo "$output"; return 1; }
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
}
@test "readonly is refused when it names a read_write_per_project root" {
_rw_setup
pp="$(mktemp -d)"
workspace="$(mktemp -d)"
mkdir -p "$pp/projects"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "readonly = $pp/projects" "read_write_per_project = $pp"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "every project's slice was readable: $output"; return 1; }
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
}
@test "a read_write_per_project entry overlapping a protected path is refused" {
# Slicing does not make a guarded store safe to hand over - it only makes
# less of it reachable, which is not the property the guard is about.
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write_per_project = $CONFIG_DIR"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "the approval store was mounted: $output"; return 1; }
[[ "$output" == *"only how much of it there is"* ]] || { echo "$output"; return 1; }
rm -rf "$workspace" "$RW_HOME" "$docker_log"
}
@test "the same path in read_write and read_write_per_project is refused" {
# Not by a duplicate check: the two mean different things, so naming a path
# in both makes it an ancestor of its own per-project root, and the refusal
# that fires is the one about handing over every project's directory. That
# is the right answer, and it says which list the path belongs in.
_rw_setup
pp="$(mktemp -d)"
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $pp" "read_write_per_project = $pp"
_rw_launch "$workspace"
[ "$status" -ne 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"holds one directory per project"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"one list or the other and not in both"* ]] || { echo "$output"; return 1; }
rm -rf "$pp" "$workspace" "$RW_HOME" "$docker_log"
}
@test "a read_write_per_project entry that is not an existing directory is refused" {
# The slice is dejima's to create; the root is not. A path that is not
# there is a typo, and creating it would turn the typo into a directory.
_rw_setup
workspace="$(mktemp -d)"
write_config '[core]' "ssh_key = $RW_KEY" 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write_per_project = /nonexistent/specs"
_rw_launch "$workspace"
[ "$status" -ne 0 ]
[[ "$output" == *"not an existing directory"* ]] || { echo "$output"; return 1; }
[ ! -d /nonexistent/specs ]
rm -rf "$workspace" "$RW_HOME" "$docker_log"
}
@test "--print-briefing names a per-project mount as this project's own" {
# A separate sentence from the read_write one, because the read_write
# sentence is not true of these: a session told "these host directories at
# their own paths" would believe the whole directory is behind the mount.
pp="$(mktemp -d)"
rw="$(mktemp -d)"
write_config '[core]' 'forge = none' 'workflow = local-only' \
'[mounts]' "read_write = $rw" "read_write_per_project = $pp"
run "$REPO_ROOT/bin/dejima" --print-briefing
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"at their own paths"*"$rw"* ]] || { echo "$output"; return 1; }
[[ "$output" == *"THIS project's own directory"*"$pp"* ]] || { echo "$output"; return 1; }
# And composing a briefing leaves nothing behind: the slice belongs to a
# launch, not to a diagnostic that prints one.
[ ! -d "$pp/projects" ] || { echo "--print-briefing created the slice"; return 1; }
rm -rf "$pp" "$rw"
}
@test "a path in both read_write and readonly is refused rather than resolved by mount order" {
_rw_setup
d="$(mktemp -d)"
@@ -3027,10 +3312,30 @@ _upd_cache() { printf '%s/.local/share/dejima/update-check' "$HOME"; }
[ "$status" -eq 0 ]
[[ "$output" == *"origin/trunk"* ]] \
|| { echo "does not name what it is behind: $output"; return 1; }
# The path, because the checkout that is behind is not necessarily the
# The paths, because the checkout that is behind is not necessarily the
# directory the operator is standing in when they read this.
[[ "$output" == *"git -C $UPD_CHECKOUT pull"* ]] \
|| { echo "does not say how to update: $output"; return 1; }
# install.sh too, because pulling does not re-link what a new release
# adds on PATH.
[[ "$output" == *"$UPD_CHECKOUT/install.sh"* ]] \
|| { echo "does not say to re-run install.sh: $output"; return 1; }
}
@test "the notice is set off by a blank line either side" {
_upd_fixture
_upd_commit "$UPD_ORIGIN" second
# Read from the file the tool wrote: bats' run strips the blank lines at
# either end of what it captures, and those are the thing being asserted -
# they are what keeps the launch's startup lines and image build from
# reading as part of the notice.
local out="$UPD_DIR/notice"
"$UPD_CHECKOUT/bin/dejima-update-check" >"$out" 2>&1
grep -q "commits this checkout" "$out" || { cat "$out"; return 1; }
[ -z "$(head -n 1 "$out")" ] \
|| { echo "no blank line before it: $(cat "$out")"; return 1; }
[ -z "$(tail -n 1 "$out")" ] \
|| { echo "no blank line after it: $(cat "$out")"; return 1; }
}
@test "the commit count is given only when the commits are actually here" {
@@ -3276,7 +3581,7 @@ EOF
# The unnamed default gets a name, which is what lets it carry a git name
# and a forge credential beside the key rather than those coming from
# three different places.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/tok"
ID_DIR="$(mktemp -d)"; printf "k\n" | tee "$ID_DIR/default-key" > "$ID_DIR/tok"
write_config '[core]' 'forge = gitea' \
'[forge.gitea]' 'ssh_host = git.example' 'api = https://git.example/api/v1' \
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
@@ -3291,7 +3596,7 @@ EOF
@test "[core] ssh_key alongside [identity.default] is refused, not ranked" {
# Two spellings of one thing. Which key a session pushed with is not
# something to work out from precedence rules.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/new-key"
ID_DIR="$(mktemp -d)"; printf "k\n" | tee "$ID_DIR/old-key" > "$ID_DIR/new-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
'[identity.default]' "ssh_key = $ID_DIR/new-key"
run "$REPO_ROOT/bin/dejima" --print-config
@@ -3304,7 +3609,7 @@ EOF
@test "[core] ssh_key on its own still works, and says what replaced it" {
# Deprecated, not removed: it is how every config written before
# identities existed says this.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/old-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key"
run "$REPO_ROOT/bin/dejima" --print-config
[ "$status" -eq 0 ] || { echo "a pre-identity config stopped working: $output"; return 1; }
@@ -3315,7 +3620,7 @@ EOF
}
@test "a named identity still beats [identity.default]" {
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/default-key"; : > "$ID_DIR/bot-key"
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/default-key"; printf "not-a-real-key\n" > "$ID_DIR/bot-key"
write_config '[core]' 'forge = none' \
'[identity.default]' "ssh_key = $ID_DIR/default-key" \
'[identity.bot]' "ssh_key = $ID_DIR/bot-key"
@@ -3330,7 +3635,7 @@ EOF
# from the refusal - adding [identity.default] would stop it starting.
# Finding that out on the launch that breaks is worse than being told now,
# so the warning is about the config rather than about this launch.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/old-key"; : > "$ID_DIR/bot-key"
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/old-key"; printf "not-a-real-key\n" > "$ID_DIR/bot-key"
write_config '[core]' 'forge = none' "ssh_key = $ID_DIR/old-key" \
'identity = bot' '[identity.bot]' "ssh_key = $ID_DIR/bot-key"
run "$REPO_ROOT/bin/dejima" --print-config
@@ -3351,7 +3656,7 @@ EOF
# The other half of a deprecation warning: it has to stop. One that fires
# after the migration is one an operator learns to ignore, which is how
# the next warning gets missed too.
ID_DIR="$(mktemp -d)"; : > "$ID_DIR/key"
ID_DIR="$(mktemp -d)"; printf "not-a-real-key\n" > "$ID_DIR/key"
write_config '[core]' 'forge = none' \
'[identity.default]' "ssh_key = $ID_DIR/key" 'user_name = dejima agent'
run "$REPO_ROOT/bin/dejima" --print-config
@@ -3916,6 +4221,46 @@ _scan_staged_has_token() {
rm -rf "$workspace" "$SCAN_STUB" "$SCAN_STAGED"
}
@test "a scrub finds the value again whatever the key is spelled like" {
# Deleting by document order made each later index stale: the second secret
# survived and an innocent value went instead. The staged file is mounted
# into the container, so the operator answers "keep it out" and it goes in.
#
# Driven against the program's own functions rather than a reimplementation
# of the ordering, which is the thing under test.
local scan="$REPO_ROOT/bin/dejima-credential-scan"
run python3 - "$scan" <<'PYEOF'
import json, sys
mod = {}
exec(compile(open(sys.argv[1]).read(), "scan", "exec"), mod)
doc = {"mcpServers": {"x": {"args": ["sk-SECRET-ONE-aaaaaaaaaaaaaaaaaaaa",
"sk-SECRET-TWO-bbbbbbbbbbbbbbbbbbbb",
"--harmless-flag"]}}}
def scrub(doc, want):
pairs = list(mod["walk_json"](json.loads(json.dumps(doc))))
drop = [parts for p, v, parts in pairs if p in want]
out = json.loads(json.dumps(doc))
mod["delete_json_components"](out, drop)
return out
print(json.dumps(scrub(doc, {"mcpServers.x.args[0]",
"mcpServers.x.args[1]"})["mcpServers"]["x"]["args"]))
# A key that spells an index, and a key carrying a dot: both used to be
# re-parsed as traversals, deleting a bystander or raising out of the scrub.
print(json.dumps(scrub({"x[0]": "sk-SECRET-key", "x": ["innocent"]}, {"x[0]"})))
print(json.dumps(scrub({"a.b": "sk-SECRET-dotted"}, {"a.b"})))
PYEOF
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" != *SECRET* ]] \
|| { echo "a secret survived the scrub: $output"; return 1; }
[[ "$output" == *harmless-flag* ]] \
|| { echo "the scrub removed a value that was not a secret: $output"; return 1; }
# No line raised: a KeyError here aborts the scrub and bin/dejima then
# copies the file in unscrubbed.
[ "${#lines[@]}" -eq 3 ] \
|| { echo "the scrub did not complete for every shape: $output"; return 1; }
}
@test "a standing scrub written by hand is honoured, and holds no value" {
# Not offered at the prompt, but available to someone who has read what it
# does - and it is the only way a scrubbed field survives an unattended
+23 -2
View File
@@ -16,8 +16,10 @@ import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit
STATE = {"oauth_hits": 0, "oauth_last": {}, "returned_401": False}
STATE = {"oauth_hits": 0, "oauth_last": {}, "returned_401": False, "oauth_reject": False,
"oauth_reject_say": None}
LOCK = threading.Lock()
@@ -50,11 +52,30 @@ class Handler(BaseHTTPRequestHandler):
}).encode())
def _handle(self):
if self.path == "/v1/oauth/token":
if self.path.startswith("/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. ?say= replaces the error_description, for tests
# about upstream-chosen text landing in the proxy's log.
with LOCK:
STATE["oauth_reject"] = True
STATE["oauth_reject_say"] = \
parse_qs(urlsplit(self.path).query).get("say", [None])[0]
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")
say = STATE.get("oauth_reject_say")
if reject:
# Verbatim from a real overnight expiry.
self._send(400, json.dumps({
"error": "invalid_grant",
"error_description": say or "Refresh token expired",
}).encode())
return
self._send(200, json.dumps({
"access_token": "refreshed-access-token",
"refresh_token": "rotated-refresh-token",
+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"
printf "not-a-real-key\n" > "$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; }
}
+220 -22
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 ;;
@@ -79,7 +84,7 @@ done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
: > "$LAUNCH_DIR/sandbox-key"
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
chmod 600 "$LAUNCH_DIR/sandbox-key"
}
@@ -121,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".
@@ -148,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 ]
@@ -651,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 ]
@@ -695,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" {
+56 -1
View File
@@ -215,6 +215,33 @@ assert_remedy_forges_exist() {
[ "$status" -ne 0 ]
}
@test "the briefing tells an agent how a refusal differs from a hang" {
# The policy rejects rather than drops, for the reason its own chain
# comment gives: silently dropping makes every blocked destination look
# like a hang. The briefing described both as failing to connect, which
# throws that signal away - and two agents in one day reported a host
# firewall on the far side as dejima blocking a domain, because from
# inside a sandbox the two are indistinguishable except by timing.
local lib="$REPO_ROOT/bin/dejima-provider-lib.sh"
grep -q 'refused at once rather than' "$lib" \
|| { echo "the briefing no longer says a refusal is immediate"; return 1; }
grep -q 'HANGS is not this allowlist' "$lib" \
|| { echo "the briefing no longer tells an agent what a hang means"; return 1; }
# And the property it describes is still true of the policy, so the advice
# cannot outlive the behaviour it rests on.
local chain
chain=$(sed -n '/^ chain refuse {/,/^ }/p' "$REPO_ROOT/docker/egress-policy")
[ -n "$chain" ] || { echo "no refuse chain in the policy"; return 1; }
grep -q 'reject with tcp reset' <<< "$chain" \
|| { echo "the policy no longer rejects TCP, so a block would hang"; return 1; }
grep -q 'reject with icmpx' <<< "$chain" \
|| { echo "the policy no longer rejects non-TCP"; return 1; }
grep -qE '(^|[^a-z])drop([^a-z]|$)' <<< "$chain" \
&& { echo "the refuse chain drops as well as rejects: $chain"; return 1; }
true
}
@test "gitea's CLIs moved out of bin/ and into its forge" {
[ ! -e "$REPO_ROOT/bin/gitea-pr" ]
[ ! -e "$REPO_ROOT/bin/gitea-issue" ]
@@ -222,6 +249,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 +1170,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.
+221 -11
View File
@@ -292,17 +292,18 @@ teardown() {
@test "forwarder bridges TCP to the proxy's unix socket" {
start_proxy
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT=18399 \
local fport; fport="$(free_port)"
SANDBOX_CREDENTIAL_PROXY_SOCKET="$SOCK" SANDBOX_CREDENTIAL_PROXY_PORT="$fport" \
SANDBOX_CREDENTIAL_TRANSPORT=loopback \
node "$REPO_ROOT/docker/credential-proxy-forwarder.js" &
FWD_PID=$!
ok=""
for _ in $(seq 1 50); do
if curl -s -o /dev/null http://127.0.0.1:18399/echo; then ok=1; break; fi
if curl -s -o /dev/null http://127.0.0.1:$fport/echo; then ok=1; break; fi
sleep 0.1
done
[ -n "$ok" ]
run curl -s -H "Authorization: Bearer $PLACEHOLDER" -d '{}' http://127.0.0.1:18399/v1/messages
run curl -s -H "Authorization: Bearer $PLACEHOLDER" -d '{}' http://127.0.0.1:$fport/v1/messages
kill "$FWD_PID" 2>/dev/null || true
[ "$(jq -r '.headers.authorization' <<<"$output")" = "Bearer real-access-token" ]
}
@@ -353,6 +354,20 @@ PYEOF
[[ "$output" != *"501"* ]]
}
# The forwarder is given a port rather than asking for one, so these tests
# have to name a number. Asking the kernel for a free one and handing that over
# leaves a window where something else could take it; naming a constant leaves
# the test failing on any machine already using it, which is the precondition
# these tests were reading from the environment instead of stating. The window
# is the smaller of the two, and the suites run on a developer's machine.
free_port() {
python3 -c 'import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()'
}
# The forwarder is the container-side half of the chain. In tls mode it
# terminates TLS for api.anthropic.com on loopback so Claude Code needs no
# ANTHROPIC_BASE_URL at all, then pipes raw bytes into the same unix socket
@@ -382,13 +397,14 @@ make_cert() {
@test "forwarder tls mode: https to api.anthropic.com reaches the proxy, which injects the real token" {
make_cert
start_proxy
start_forwarder tls 18443
local fport; fport="$(free_port)"
start_forwarder tls "$fport"
# --resolve is what stands in for the container's /etc/hosts entry: the
# name must really be api.anthropic.com for certificate verification to
# be a meaningful check, not a hostname the test picked to be easy.
run curl -s --cacert "$TD/cert.pem" \
--resolve "api.anthropic.com:18443:127.0.0.1" \
-X POST "https://api.anthropic.com:18443/v1/messages" -d '{}'
--resolve "api.anthropic.com:$fport:127.0.0.1" \
-X POST "https://api.anthropic.com:$fport/v1/messages" -d '{}'
kill "$FWD_PID" 2>/dev/null || true
[ "$status" -eq 0 ]
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
@@ -403,8 +419,9 @@ make_cert() {
# no HTTP awareness to catch it.
make_cert
start_proxy
start_forwarder tls 18444
run openssl s_client -connect 127.0.0.1:18444 -alpn h2,http/1.1 \
local fport; fport="$(free_port)"
start_forwarder tls "$fport"
run openssl s_client -connect "127.0.0.1:$fport" -alpn h2,http/1.1 \
-servername api.anthropic.com -CAfile "$TD/cert.pem" </dev/null
kill "$FWD_PID" 2>/dev/null || true
[[ "$output" == *"ALPN protocol: http/1.1"* ]]
@@ -412,8 +429,9 @@ make_cert() {
@test "forwarder loopback mode serves plaintext http on the port it is given" {
start_proxy
start_forwarder loopback 18445
run curl -s -X POST "http://127.0.0.1:18445/v1/messages" -d '{}'
local fport; fport="$(free_port)"
start_forwarder loopback "$fport"
run curl -s -X POST "http://127.0.0.1:$fport/v1/messages" -d '{}'
kill "$FWD_PID" 2>/dev/null || true
[ "$status" -eq 0 ]
[[ "$output" == *'"authorization": "Bearer real-access-token"'* ]]
@@ -437,7 +455,7 @@ make_cert() {
# today, so a fall-through lands on an unused port if that ever changes.
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 \
SANDBOX_CREDENTIAL_PROXY_PORT="$(free_port)" \
node "$REPO_ROOT/docker/credential-proxy-forwarder.js"
[ "$status" -ne 0 ]
[[ "$output" == *"SANDBOX_CREDENTIAL_TLS_CERT"* ]]
@@ -816,3 +834,195 @@ print(raw.decode().strip())
[[ "$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; }
}
# The exit audit in bin/dejima reads this log. Extracted and driven here rather
# than restated, and fed logs this proxy actually wrote, because the two live in
# different files and the classification only works if they agree about what a
# line looks like.
run_exit_audit() { # <proxy log>
C_WARN='' C_RESET='' SANDBOX_CREDENTIAL_PROXY_LOG="$1" bash -c '
C_WARN=""; C_RESET=""
SANDBOX_CREDENTIAL_PROXY_LOG="$1"
'"$(sed -n '/_audit_counts="\$(awk/,/unset _audit_counts _audit_last/p' \
"$REPO_ROOT/bin/dejima")"'
' _ "$1" 2>&1
}
@test "the exit audit does not report a credential failure as the API answering" {
# An overnight expiry logs no upstream status, because no request was made -
# the credential could not be loaded. Announcing "the model API answered
# with an error" there sends an operator to check the provider, and the
# retry caveat invites them to hope it succeeded anyway. Neither is true,
# and the line the proxy leaves last is an instruction rather than a status.
write_creds -60
start_proxy
curl -s "http://127.0.0.1:$PORT/oauth-reject" >/dev/null
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
run run_exit_audit "$TD/stderr"
[[ "$output" != *"model API answered with an error"* ]] \
|| { echo "reported as the API answering: $output"; return 1; }
[[ "$output" != *"may still have succeeded"* ]] \
|| { echo "invited a retry that cannot succeed: $output"; return 1; }
[[ "$output" == *"could not authenticate"* ]] \
|| { echo "did not say what happened: $output"; return 1; }
[[ "$output" == *"log in again"* ]] \
|| { echo "did not carry the remedy: $output"; return 1; }
}
@test "an upstream that answered with errors is still reported as one" {
# The control: the audit's original purpose has to survive the split.
start_proxy
pcurl -o /dev/null "http://localhost/status-429"
pcurl -o /dev/null "http://localhost/status-429"
run run_exit_audit "$TD/stderr"
[[ "$output" == *"model API answered with an error"* ]] \
|| { echo "an upstream error stopped being reported as one: $output"; return 1; }
[[ "$output" == *"2 x HTTP 429"* ]] \
|| { echo "the grouping by status is gone: $output"; return 1; }
[[ "$output" == *"may still have succeeded"* ]] \
|| { echo "the retry caveat is gone from the case it is about: $output"; return 1; }
}
@test "a request that never reached the upstream is reported as neither case" {
# `upstream request failed` carries no status - nothing answered - and no
# credential failed either. Classified by "no status lines anywhere", it
# read as an authentication failure, sending an operator to log in again
# over a connection problem.
start_proxy --upstream "http://127.0.0.1:1"
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
run run_exit_audit "$TD/stderr"
[[ "$output" != *"could not authenticate"* ]] \
|| { echo "a connection failure read as an authentication failure: $output"; return 1; }
[[ "$output" != *"model API answered with an error"* ]] \
|| { echo "reported as the API answering: $output"; return 1; }
[[ "$output" == *"credential proxy reported a problem"* ]] \
|| { echo "the failure went unreported: $output"; return 1; }
[[ "$output" == *"upstream request failed"* ]] \
|| { echo "the line itself is missing: $output"; return 1; }
}
@test "upstream text saying 'answered 503' cannot fabricate a status count" {
# The refresh failure's detail line carries up to 200 bytes of the token
# endpoint's response body - text the upstream chose. Counted wherever
# `answered N` appeared, that body minted "1 x HTTP 503" for a session
# that made no request, and brought the retry caveat with it. The count
# is anchored to the proxy's own `upstream answered N for` lines.
write_creds -60
start_proxy
curl -s "http://127.0.0.1:$PORT/oauth-reject?say=the+upstream+answered+503+today" >/dev/null
pcurl -o /dev/null -X POST --data '{}' http://x/v1/messages || true
run run_exit_audit "$TD/stderr"
[[ "$output" != *"model API answered with an error"* ]] \
|| { echo "an upstream string was counted as an API answer: $output"; return 1; }
[[ "$output" != *"HTTP 503"* ]] \
|| { echo "a status count was minted from body text: $output"; return 1; }
[[ "$output" == *"could not authenticate"* ]] \
|| { echo "the real failure went unreported: $output"; return 1; }
[[ "$output" == *"answered 503 today"* ]] \
|| { echo "the detail line is not shown: $output"; return 1; }
}
@test "a negative Content-Length is refused rather than read to EOF" {
# The cap exists because this runs on the host as the operator's own user.
# int() accepts a negative, a negative passes a `> MAX` test, and a buffered
# reader takes read(-1) as "to EOF" - so `Content-Length: -1` read an
# unbounded body into host RAM past the cap. A non-numeric value raised out
# past the types handle_error knows and put a traceback on the terminal.
start_proxy
run curl -s -o /dev/null -w '%{http_code}' --unix-socket "$SOCK" \
-H "Authorization: Bearer $PLACEHOLDER" -H 'Content-Length: -1' \
-X POST --data 'x' http://x/v1/messages
[ "$output" = 413 ] \
|| { echo "a negative Content-Length was not refused: $output"; return 1; }
# A value that is not a number is left alone: it still raises, because the
# test above for narrow error suppression uses exactly that fault to prove
# the suppression is specific to a client going away. This fix is about the
# value that passes the cap and then reads to EOF, not about input tidiness.
# The log says what arrived, which is what tells a hostile negative length
# apart from a genuinely oversized upload.
grep -q 'Content-Length of -1' "$TD/stderr" \
|| { echo "the refusal log does not name the offending length:"; cat "$TD/stderr"; return 1; }
# And an ordinary request still works, so the check did not close the door.
run pcurl -o /dev/null -w '%{http_code}' -X POST --data '{}' http://x/v1/messages
[ "$output" = 200 ] || { echo "a normal request was refused: $output"; return 1; }
}
@test "a negative chunk size is refused too, not only Content-Length" {
# The two paths share one helper today. A refactor that inlines the chunked
# branch - int(readline().split(b";")[0], 16) - puts the unbounded read back
# with nothing failing, so the chunked shape is asserted in its own right.
start_proxy
python3 - "$SOCK" <<'PYEOF'
import socket, sys
s = socket.socket(socket.AF_UNIX)
s.connect(sys.argv[1])
s.sendall(b"POST /v1/messages HTTP/1.1\r\nHost: proxy\r\n"
b"Authorization: Bearer placeholder\r\n"
b"Transfer-Encoding: chunked\r\n\r\n-1\r\n")
s.settimeout(5)
try:
got = s.recv(200)
except Exception:
got = b"<no answer>"
print(got.split(b"\r\n")[0].decode("latin-1"))
s.close()
PYEOF
run cat "$TD/stderr"
[[ "$output" == *"chunk size of -1"* ]] \
|| { echo "a negative chunk size was not refused as one: $output"; return 1; }
[[ "$output" != *"Traceback"* ]] \
|| { echo "it raised instead of being refused: $output"; return 1; }
}
+25 -7
View File
@@ -143,6 +143,22 @@ touches '^tools/' &&
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."
# The boundary itself had no pattern at all, so a release that rewrote the
# egress policy, the capability floor or the credential path was reported as
# touching nothing that needs a manual check - over exactly the things this
# project exists to hold.
touches '^(docker/egress-policy|docker/firewall-up|docker/fixed-domains|docker/dns-lookup-log|docker/cidr-collapse|bin/dejima-allow)' &&
manual "Reach an allowed destination and a refused one from inside a real
session, and check a refusal is immediate rather than a hang. The suites
drive the policy; only a session drives it with a real resolver."
touches '^(docker/caps-lib|docker/docker-compose)' &&
manual "Check the capability set of a running session against the floor the
compose file declares. A container that starts says nothing about what
it kept."
touches '^(bin/dejima-credential-proxy|docker/credential-proxy-forwarder|backends/|bin/dejima-credential-scan)' &&
manual "Run a session against a real model through the proxy, and let a
credential expire or be refused. The failure paths are what the suites
reach least."
[ "$printed" = 1 ] || echo " (none - this release touches nothing with a manual check)"
note "Required before this merges"
@@ -158,9 +174,11 @@ echo " launch on the other configured harness to confirm it starts."
# turns the obvious rollback into a second outage, so it is worth knowing
# before rather than after.
note "Config keys this release adds (a rollback refuses a config using them)"
# Both sides through git, because this reports on $base...$head and the working
# tree is neither of them once a head is named explicitly.
added="$(comm -13 \
<(git show "$base:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u) \
<(grep -oE "^[a-z-]+\\.[a-z_]+" bin/dejima-config-lib.sh | sort -u))"
<(git show "$head:bin/dejima-config-lib.sh" 2>/dev/null | grep -oE "^[a-z-]+\\.[a-z_]+" | sort -u))"
# Providers too, and this is the sharper half: a [harness.<name>] section for
# a harness the older dejima does not ship is an unknown SECTION, which fails
# validation exactly as an unknown key does. So adopting a new provider is
@@ -193,12 +211,12 @@ new_providers=""
for pair in $DEJIMA_RELEASE_AXES; do
axis="${pair%%:*}"
one="${pair#*:}"
[ -d "$axis" ] || continue
git cat-file -e "$head:$axis" 2>/dev/null || continue
while IFS= read -r name; do
[ -n "$name" ] || continue
git cat-file -e "$base:$axis/$name/$one.conf" 2>/dev/null ||
new_providers="$new_providers $one.$name"
done < <(find "$axis" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
done < <(git ls-tree -d --name-only "$head:$axis" 2>/dev/null | sort)
done
# And the keys a provider DECLARES, which are config keys an operator writes
@@ -210,18 +228,18 @@ new_keys=""
for pair in $DEJIMA_RELEASE_AXES; do
axis="${pair%%:*}"
one="${pair#*:}"
[ -d "$axis" ] || continue
git cat-file -e "$head:$axis" 2>/dev/null || 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)"
now="$(git show "$head:$axis/$name/$one.conf" 2>/dev/null \
| sed -n 's/^keys *= *//p' | 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 < <(git ls-tree -d --name-only "$head:$axis" 2>/dev/null | sort)
done
if [ -n "$added" ] || [ -n "$new_providers" ] || [ -n "$new_keys" ]; then
+49 -9
View File
@@ -163,7 +163,7 @@ done
exit 0
EOF
chmod +x "$LAUNCH_DIR/docker"
: > "$LAUNCH_DIR/sandbox-key"
printf "not-a-real-key\n" > "$LAUNCH_DIR/sandbox-key"
chmod 600 "$LAUNCH_DIR/sandbox-key"
PROJ="$(mktemp -d)"
}
@@ -444,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"
@@ -1993,15 +2003,16 @@ EOF
}
ttyd_log() { cat "$STUB_DIR/ttyd-argv" 2>/dev/null; }
# Reports no listener, whatever the host is actually running. Opt-in, and
# deliberately not part of stub_ttyd: the port-in-use refusal has its own test
# below, which binds a real socket and needs the real ss to see it.
# Reports no listener, whatever the host is actually running. Called beside
# stub_ttyd by every ttyd test but one, and deliberately not folded into it:
# the port-in-use refusal binds a real socket and needs the real ss to see it.
#
# For every OTHER ttyd test the port being free is a precondition, not the
# subject - and one that was being inherited from the machine rather than
# stated. The default-port case asserts against 7681, which is exactly the
# port a real dejima-ttyd front door holds, so it failed on any host running
# the feature: the machine a developer is most likely to run this suite on.
# The rest name 12345, which is free on most machines and on none reliably.
# Same shape as #56 - a test reading its environment instead of setting it.
stub_ss_no_listeners() {
STUB_DIR="${STUB_DIR:-$(mktemp -d)}"
@@ -2077,6 +2088,8 @@ unit_exec_start() {
@test "dejima-ttyd refuses to start when something else already holds its port" {
command -v ss >/dev/null 2>&1 || skip "ss is not available"
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
# The one ttyd test with no stub_ss_no_listeners beside stub_ttyd: ss
# seeing the socket bound below is the subject here, not a precondition.
stub_ttyd
# The distro's ttyd package ships an enabled service that binds every
@@ -2133,6 +2146,7 @@ PY
@test "dejima-ttyd refuses to start when remote access is unconfigured, before ttyd is reached" {
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ]
[[ "$output" == *"not configured"* ]]
@@ -2244,6 +2258,7 @@ PY
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
write_remote_config 'ttyd_port = 99999'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -ne 0 ]
# Named, so this cannot pass because ttyd was missing or the config was.
@@ -2260,6 +2275,7 @@ PY
# currently happens to agree with it.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345
@@ -2289,6 +2305,7 @@ PY
local bad
for bad in 0 7 73 999 abc 1e2 -20; do
stub_ttyd
stub_ss_no_listeners
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' "ttyd_font_size = $bad"
run "$REPO_ROOT/bin/dejima-ttyd"
@@ -2309,6 +2326,7 @@ PY
# moment either default moves.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
assert_ttyd_argv 12345
@@ -2337,6 +2355,7 @@ PY
# a host-wide "off" written here would be a different thing from silence.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
run grep -c 'buttonColors' "$STUB_DIR/ttyd-argv"
@@ -2368,6 +2387,7 @@ PY
# would be wrong for whichever half of the clients it did not match.
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
run grep -c 'focusOnTap' "$STUB_DIR/ttyd-argv"
@@ -2403,6 +2423,7 @@ PY
local bad
for bad in b M-b C-ab C- ctrl-a 2 'C-\' ; do
stub_ttyd
stub_ss_no_listeners
: > "$STUB_DIR/ttyd-argv"
write_remote_config 'ttyd_port = 12345' "ttyd_tmux_prefix = $bad"
run "$REPO_ROOT/bin/dejima-ttyd"
@@ -2432,6 +2453,7 @@ PY
"$REPO_ROOT/bin/dejima-provider-lib.sh" "$fake/bin/"
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$fake/bin/dejima-ttyd"
[ "$status" -ne 0 ]
[[ "$output" == *"$fake/web/index.html"* ]]
@@ -2449,7 +2471,7 @@ PY
local web="$REPO_ROOT/web"
[ -f "$web/index.html" ]
[ -f "$web/sources.sha256" ]
run bash -c "cd '$web' && sha256sum src/* package-lock.json | LC_ALL=C sort -k2"
run bash -c "cd '$web' && sha256sum build.mjs src/* package-lock.json | LC_ALL=C sort -k2"
[ "$status" -eq 0 ]
[ "$output" = "$(cat "$web/sources.sha256")" ] || {
echo "web/sources.sha256 is stale. Rebuild it:" >&2
@@ -2487,10 +2509,26 @@ PY
# restricts outbound traffic to an allowlist, so a CDN reference would not
# resolve even from a browser outside it if the operator is on the same
# restricted network.
#
# A `data:` URI is exempt and only that: it carries its bytes in the
# attribute, so it fetches nothing and neither reason above applies to it.
# The favicon is one. Matched per attribute rather than per line, so a data
# URI on a line cannot carry a real reference past this beside it.
local page="$REPO_ROOT/web/index.html"
[ -f "$page" ]
run grep -nEi '<(script|link|img|iframe)[^>]+(src|href)=' "$page"
run bash -c "grep -oEi '<(script|link|img|iframe)[^>]+(src|href)=\"[^\"]*\"' '$page' \
| grep -vEi '(src|href)=\"data:'"
[ "$status" -ne 0 ] || { echo "the page references an external asset: $output" >&2; false; }
# And the exemption is not a hole: anything that is not a data URI still
# fails, checked against a copy rather than trusted.
local probe="$BATS_TEST_TMPDIR/probe.html"
sed 's|<link rel="icon"|<link rel="stylesheet" href="https://cdn.example.com/x.css"><link rel="icon"|' \
"$page" > "$probe"
run bash -c "grep -oEi '<(script|link|img|iframe)[^>]+(src|href)=\"[^\"]*\"' '$probe' \
| grep -vEi '(src|href)=\"data:'"
[ "$status" -eq 0 ] \
|| { echo "the check no longer catches a real external reference" >&2; false; }
}
@test "the terminal page's own unit tests pass" {
@@ -2515,6 +2553,7 @@ PY
[ -x "$REPO_ROOT/bin/dejima-ttyd" ]
write_remote_config 'ttyd_port = 12345'
stub_ttyd
stub_ss_no_listeners
run "$REPO_ROOT/bin/dejima-ttyd"
[ "$status" -eq 0 ]
# Executed, not grepped: the flag has to reach ttyd's argv.
@@ -2538,7 +2577,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
@@ -2918,6 +2957,7 @@ nginx_blocks() {
printf '[remote-access]\nenabled = on\nbase_url = https://d.example\nttyd_port = 12345\n' \
> "$FAKE_HOME/.config/dejima/config"
stub_ttyd
stub_ss_no_listeners
local exec_start
exec_start="$(unit_exec_start dejima-ttyd.service "$FAKE_HOME")"
+75 -25
View File
@@ -37,43 +37,82 @@ PRIVILEGED=" runtime ci-runner "
# is a path nobody has decided about, and guessing "none" there is how a change
# ships having been checked by nothing.
suites_for() {
# Every suite that READS the path, not the one that owns it. static.bats
# asserts invariants over the whole tree and config.bats sources most of
# the libraries, so both appear far more often than a by-area reading
# suggests - and leaving them out is what made the --changed lane report an
# edit as green from a selection that never loaded the file.
#
# The map is checked against the suites mechanically; see the test named
# for it in static.bats. Over-selecting costs a suite run, under-selecting
# costs a missed regression, so a rule here errs wide.
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-audit) echo 'audit config proxy remote static' ;;
bin/dejima-update-check) echo 'config project remote static' ;;
bin/dejima-epitaph) echo 'config static' ;;
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 ;;
bin/dejima-color-lib.sh) echo 'remote project config static' ;;
bin/dejima-tui-lib.sh) echo 'remote project static' ;;
bin/dejima-conversation-ship) echo 'conversation static' ;;
bin/dejima-share) echo 'share static' ;;
# 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 ;;
bin/dejima-credential-proxy|docker/credential-proxy-forwarder.js) echo 'proxy static' ;;
bin/dejima-credential-scan*) echo 'config static' ;;
bin/dejima-project-lib.sh|bin/dejima-project|bin/dejima-dep) \
echo 'project config launch providers remote static' ;;
bin/dejima-allow) echo 'project static config' ;;
bin/dejima-attach) echo 'remote config project static' ;;
bin/dejima-ttyd|bin/dejima-notify-watch) echo 'remote config static' ;;
web/*|systemd/*) echo 'remote static' ;;
bin/dejima-provider-lib.sh) echo 'providers config remote share static' ;;
bin/dejima-harness-lib.sh) echo 'providers config remote static' ;;
bin/harness/*) echo 'providers remote static' ;;
# A manifest is read by more than the suite that owns the schema: the
# audit reads a workflow's briefing, conversation.bats sed-injects a
# harness.conf, and config.bats copies the whole set in.
forges/*|workflows/*|harnesses/*|tools/*) \
echo 'providers static audit config conversation' ;;
install.sh) echo 'config audit providers remote static' ;;
# 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 ;;
hooks/*) echo 'static config providers project remote' ;;
.gitea/*|.shellcheckrc) echo static ;;
.dejima/*) echo 'static config project providers' ;;
test/*.bats) local n="${1##*/}"; echo "${n%.bats}" ;;
# A suite file is read by static.bats as well as run by bats: it
# asserts invariants over the text of the others, so an edit to one
# that only selected itself was reported green by a lane that never
# loaded the assertions about it.
test/*.bats) local n="${1##*/}"; echo "${n%.bats} static" ;;
# Fixtures are executed by the suites that consume them, not merely
# read: fake-anthropic.py is the upstream in proxy.bats (and in
# runtime.bats, which no confined selection can carry), and
# answer-prompt.py drives config.bats' prompts.
test/fixtures/*) echo 'proxy static config' ;;
test/*) echo static ;;
# Last of the specific rules, not before them: a .md under test/ or
# .dejima/ belongs to whatever reads that directory, and this pattern
# would otherwise answer for it. The docs themselves are asserted
# against by more than static.bats - the README's axis lists, the
# provider tables, the nginx example and the remote-access page each
# have a suite checking the code agrees with them.
#
# So a doc-only edit runs more than static alone. That is the cost of
# the lane answering for what reads a file, and it is the safe
# direction: narrowing this back would report a doc change as green
# from suites that assert against it.
docs/*|README.md|*.md) echo 'static config providers remote' ;;
*) echo ALL ;;
esac
}
@@ -246,14 +285,21 @@ wait
# suite that fails to run at all - a syntax error, or duplicate test names,
# which bats refuses outright - exits nonzero having run nothing, and reporting
# that as "0 failures" is how a green board comes to mean nothing.
#
# Skips are counted and shown separately because bats emits one as `ok N ...
# # skip`, so a test that decided not to run is indistinguishable from one that
# passed. Several here skip on host state - an empty /opt/dejima, a missing
# tool - so the column is the only thing that says how much of the suite the
# number above actually covers.
failed=0
printf '%-16s %6s %8s\n' suite ran failed
printf '%-16s %6s %8s %8s\n' suite ran skipped failed
for s in "${selected[@]}"; do
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
emitted="$(grep -cE '^(ok|not ok)' "$out/$s")"
n_skipped="$(grep -cE '^ok [0-9]+ .* # skip' "$out/$s")"
bad="$(grep -cE '^not ok' "$out/$s")"
status="$(cat "$out/$s.status" 2>/dev/null || echo 1)"
printf '%-16s %6s %8s' "$s" "$ran" "$bad"
if [ "$ran" -eq 0 ]; then
printf '%-16s %6s %8s %8s' "$s" "$(( emitted - n_skipped ))" "$n_skipped" "$bad"
if [ "$emitted" -eq 0 ]; then
printf ' <- ran nothing\n'
failed=1
elif [ "$bad" -gt 0 ] || [ "$status" -ne 0 ]; then
@@ -264,14 +310,18 @@ for s in "${selected[@]}"; do
fi
done
for s in "${selected[@]}"; do
grep -hE '^ok [0-9]+ .* # skip' "$out/$s" | sed -E "s/^ok [0-9]+ / $s: /"
done | { grep . && echo " (skipped, so the counts above do not cover them)"; } || true
if [ "$failed" -ne 0 ]; then
echo
for s in "${selected[@]}"; do
ran="$(grep -cE '^(ok|not ok)' "$out/$s")"
emitted="$(grep -cE '^(ok|not ok)' "$out/$s")"
bad="$(grep -cE '^not ok' "$out/$s")"
{ [ "$bad" -gt 0 ] || [ "$ran" -eq 0 ]; } || continue
{ [ "$bad" -gt 0 ] || [ "$emitted" -eq 0 ]; } || continue
echo "--- $s"
if [ "$ran" -eq 0 ]; then
if [ "$emitted" -eq 0 ]; then
head -20 "$out/$s"
else
grep -E '^not ok' -A 20 "$out/$s"
+1129 -360
View File
File diff suppressed because it is too large Load Diff
+24 -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,24 @@ 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 pattern rather than by name, because a list is a thing to
# forget to add to - and this one was forgotten anyway: bin/*-lib.sh
# went unchecked, which is dejima-harness-lib.sh, the code deciding
# which host paths may be mounted where. 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|bin/*-lib.sh)
printf '%s\n' "$f"; continue ;;
esac
head -1 "$f" | grep -qE '^#!.*(bash|sh)' && printf '%s\n' "$f"
done
}
+3186 -450
View File
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -10,7 +10,7 @@
//
// Usage: npm ci && node build.mjs
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -41,8 +41,15 @@ const CLIENT_SOURCES = [
// needed, since the first alone passes on a commit that rebuilds the manifest
// and leaves the page behind.
const MANIFEST_SOURCES = [
// The builder itself. Without it, editing this file - the escaping, the fill
// order, which sources get inlined - produces a different page with a
// byte-identical manifest and stamp, so the staleness check passes on a page
// nobody rebuilt. web/README.md defends committing the artifact precisely so
// nobody has to run the build, which is what makes that check load-bearing.
'build.mjs',
'package-lock.json',
'src/config.js',
'src/favicon.png',
'src/gestures.js',
'src/keyboard.js',
'src/keys.js',
@@ -58,7 +65,18 @@ const MANIFEST_SOURCES = [
'src/viewport.js'
];
// The order above is hand-written because it is load-bearing, so the list
// cannot be a glob. A src/*.js omitted from it is still hashed into the
// manifest and still fails no test - it is simply never inlined, and the page
// is missing a module. Reading the directory catches that at build time.
const uninlined = readdirSync(join(here, 'src'))
.filter((f) => f.endsWith('.js') && !CLIENT_SOURCES.includes(`src/${f}`));
if (uninlined.length) {
throw new Error(`src/*.js not in CLIENT_SOURCES, so never inlined: ${uninlined.join(', ')}`);
}
const read = (relative) => readFileSync(join(here, relative), 'utf8');
const readBytes = (relative) => readFileSync(join(here, relative));
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
// A literal </script> inside an inlined script would end the element early.
@@ -82,8 +100,9 @@ html = fill(html, 'PAGE_CSS', read('src/page.css'));
html = fill(html, 'XTERM_JS', escapeForScript(read('node_modules/@xterm/xterm/lib/xterm.js')));
html = fill(html, 'ADDON_FIT_JS', escapeForScript(read('node_modules/@xterm/addon-fit/lib/addon-fit.js')));
html = fill(html, 'CLIENT_JS', escapeForScript(client));
html = fill(html, 'FAVICON_B64', readBytes('src/favicon.png').toString('base64'));
const manifest = MANIFEST_SOURCES.map((file) => `${sha256(read(file))} ${file}\n`).join('');
const manifest = MANIFEST_SOURCES.map((file) => `${sha256(readBytes(file))} ${file}\n`).join('');
html = html.replace(
'<!--BUILD_STAMP-->',
() =>
+5 -1
View File
@@ -10,7 +10,11 @@
<meta name="color-scheme" content="dark">
<meta name="mobile-web-app-capable" content="yes">
<title>dejima</title>
<!-- Generated by web/build.mjs - do not edit. Sources: f8a5205278ef77485e319cdefff6cbeeb027f201ab27266b02703a9eb570c608 -->
<!-- Inlined like everything else on this page: ttyd serves one file and answers
no path a /favicon.ico could be fetched from, so a link to one would 404 and
the tab would fall back to the generic icon. -->
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGUAAABlCAYAAABUfC3PAAAACXBIWXMAAAsSAAALEgHS3X78AAAK9UlEQVR4nO2db0wb5x3Hv6BAa2w0ggcjJlHsOHYkYgs8oIFIThMsUTlCJJsEFW/CgjJpaFsi7VWFtBfTVNZXUYJU5Q1K11ckIK0KQ0JDcrTWVU0FFCMcpJxLMQoYhOuEyf+K3XV74dxxZ5/xnX1nH+E+b+D85+6xPzzP7+653++hBAACxNRlyEgBd42xa7ckQEx9BuB6sVsjAwDYBaArhSxESlQBaCotditk0pGlSBBZigSRpUgQWYoEkaVIEFmKBJGlSBBZigSRpUgQWYoEkaVIkGPFbgBXvGt+hCMxuJdXAQBbO6+wvfOS9bUqpQIGnQYAcFangUqlgMWkL1hb80WSUrxrfriXV+Fd8+Pb7zbhXfPz3odz1pP2WF1tNQxnNDDoNGgy6yUrqiRATP2v2I0gJSx6Vlm/TDEx6DSwtpmkJOlK0aR41/yYdszBOfss4zBEp1KtRKW6AupTVShXlFHbbOzFEgi+2AUAbBEBxvZBqJQKWNtMsF44D4tZD5VSwe9DCUNhpXAVUV5RBo2xBuqTVcmfr0XkyxYRwPcbuwi++A+2iABCwciBr7fbWmG9cB7WNlPex+aB+FLCkRimHfMYn3QeKELbpMEJYw0loxCEglFsEQH4iQB8S5uIRxOsr1MpFbDbWtDbfQl1tcfFbpZ4UhY9q5h2zGPaMcf6fHlFGbSN9dA2aaAx1gjSE/JliwjAt+SHz+3P2IssZj3sHa2w21rEaobwUqYd85iY/CLjGZO2SQNtYz2M7aeFPKzgbBEBPHetZ+xBdbXVuGprQU+3VejYI5yUacc8Ho7NsA5RlWolTLazONeulUSP4AvhWgfh8sFPBNKeUykV6O22wm5rFWpoy1/KxKQTD8dmEI7E0p7TNmlg7jDghLEmn0NIhlAwioWplYy9x25rxe1b3fn2nNylZOoZZKxo7mrIeMp62InHElh2eEG41tNiD9lz8hjW+EtZ9Kxi+N5jVhnmDgPMNsOhHKJyhXCtY2FqhVXOQF8nerqtfHfJXcr2ziuMjD5Ju+I+qjJSySTHoNPgj7+9xme2gJuUTHHDbDOguavhSMtIZfmpFwtTK2kxp6fbioG+Ti5DWnYp0455DN9/xHhMY6zBu/2tb2zMyJd4LIGFqRUsO7yMx+22VgzdeT/b269knSVO7R2dgxehbdTwbeeRolxRhvaeRhjbT2PmgYsa0tjOUNnIepMrdd5H7h3cUZ+swl4sTm1bL3CbQ8sqpa72OEPMsuPbHJp3NCFc61RsIefPuMDpdrC9Y39nhMuHeIx94k6GCeHyUb/zmSvjJMXaZkJdbTW1/Zx2MBl2QsEoY1qmt/sS5/dyTpy4SjPtkYewrHie7p95Wcx6XvNinKXYba3U76FgBFssk3MySeKxBGM0sXe0ZnwtG5yl1NUeZ4h57lrndaCjhM/tzynAk/DK+6LvXA74mVmmDV0qpQLTjnle7+clxWLSywE/C8GNXUaSxvbOSwzffwR73585Z+rwzpDspc16ygE/nUzXceFIDCOjk5z2wVsKfQiTAz6TeCwB39Imtf3BL3/AjXN71Pb2zktOQxlvKcnAJQd8NugBvl75E24Y9/CB5Qdc1+1PtTwcm8m6n5wSvOWAzw49wF/X7X8nv6L9ziXxMCcpqQE/dYr6KJIa4Om947O1/ftNXBL7ci6FoAd8Qh7CGAHedjKBeuVPAIBQogSOzX0p9HnETOQsJTXg+5b4Z8a/KaQGePrQ5dgoQyheAiCZK8alp+RcCkEGfDIDknD5CnLzK7ixi3g0gVAwmjGLsbyiDD9/nfpaiPSm5y4f4/bvZqQUm5FSzO0cw8eet6jHr3K8ss+rPqWn20pJSaZ6RgW9CUbP9U0ds/lQqVZCfepnouUqp16vffTN2/jom7fTXkc/az2IvKQYdMkCHDJFlXD50NzVkM8uEdzYBeFaPzCfly+hYCQ5xLqT7aTnMefbu7lk7wPJP2CuM8V5V3L1dF+iEisI13pOUshZVcK1zrk3vFP7I1pr/8v63GYkGVzJsTzteNEECJcPhMuH8ooynGvXwtRhyKmX06/TLGY9rBdMVIVBMoac552tn7cUa9t5qEYVCEdiVMDn+teXLQ0USH5Qi0mPJrMet4ceUI//3rSH1tof2febcsZz+9Y1AMlEwsXlVUYCQzyazHZcdnh5p9nGYwnG3cWbfZ2wmPS5JOAxyFsKWf3EJ+BvEQEsP/VSw0kqyWoqU1I4LU/K2maiJvX+8GUF/vFemDr1JAklSjD0tYLqJSqlgvqSyJ/OWQ+cXz+Dc9bDEORzJ8sg1KeqYO4wZK0MoE/I1tVWC1aeJ0ghKteAv0UEsDC1wpq9TpYWHJS9frOvk5ISipfg1/9S4YYxjnde95jNSCk+9ryFzcj+mf5AX2fafqxtJljbTAjf6oZz9lla6UbwxS7+/ekcFqZW0NzVkFEOPcD35tk76AhWCjFw5y71wZq7Ghix5SAZfItw2JIDM8Ex+Q3AwUVOlWplmpwtIoB/3v18v11jfxWqTkXY+hTyyyqvKMNv7l7LKoMcg/ninPVg+P7jA5PbBvo6cZOll2Rje+cVHo7NHChH26TBV+NLVDzhI58DwkkJR2LouTVMfVHlFWUZazjstpa8x1+ylnLaMUf1UCFrE7d3XmF88gtMO+bT5Kd+tpHhQSHLvYUtrxu+/zhjjaPd1oqBvk7BCzkXPavUWZnFrMfIh4OC7j8ciWFi0onxSSdrz6yrrcbE6JCQh8yeS8wHesAnEUtGoVApFbj5us6ETY6QAZ5EUCkGnQY93Va4l1dx9kz9oZaRSqoc56wHdb+oFqVKWPC1WcgLtTcVUk4uJxFckZeWkiCyFAkiS5EgshQJIon1vrgQjsSodcHoq+J5v/MzTlEt5uRFHLk63lmdBoYz9YfpLLB4631xwbvmx5ezHjhnPTmtjkeHvLdhMekLvVQUX6QnJRyJsc7cCkmuN58KhHSkZJvOAJITgieMNahUV0Dz+kaUirZCXpy2At73G7sIB6MIvthlnRAlkeCMgzSkZFt0R9tYT8nIhXgskVxoze3PeJeTx+IDYlNcKd41P/5271HaMEUuLWJs1wpeIh6PJeBz+zOupzJ05/1ix5ziSZl2zGNk9AmjdxR6nRffkh+u8aU0OUXuNcWRwjbFX8x1XtjWUzHoNBgZHiyGmMJKCUdiuD30gDFclVeU4b3fXSz6Qm2hYBSffzrHOClQKRUYGR6kVgMvEIWTwiZEY6xB5+BFSa2C5JpYYlQRFEHMlYJMs7AJMbZr0fWndyUlBADaexpxuX8/vZSt7WJTECnD9x+nCbncL9oSsnljbD+dJmbow79zXoUoX0SX8snYDKMqVupCSFLFbO+8ZGRoiomoUhY9q4wav8MihCRVjHfNj5HRJ6IfVzQpZJcnUZ+qwsXeRrEOJxrG9tMwtmup7YlJJxY9q6IeUzQpI6OTjBywy/0tkgvqXLnc3wL1qf2aluF7j0U9nihSkimg+xeHzV0NBfunAmJBH3a3d17iEw6l17kiihR6gzXGGpg7DGIcpqCoT1Yx8qMPms3OF8FTjMgaEJJQMIopWiL0YWaPtl4AeatBjFQjwaVMTDoZ22Rp25vIuEhSBB++CjxPVFQMZ8T5rKLMfYl9yigVDDqNGLPIwiZ4k0jkv8AdWuS8LwkiS5EgshQJIkuRILIUCSJLkSCyFAkiS5EgshQJIkuRILIUCVIKwFfsRsgw2D0G4C8A+ovdEhkAgLvG2OX+Pzk3fgySn1OHAAAAAElFTkSuQmCC">
<!-- Generated by web/build.mjs - do not edit. Sources: 0cc964e9a08f82488c268b4e95b19542a42c5ada3b927e7fac62f7acc46bde94 -->
<style>
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
+3 -1
View File
@@ -1,5 +1,7 @@
63b33d784dd94f6df18f63043a5f3734a306e1ea01d7ddbc285d53c1b7435cf2 build.mjs
f6f6611d0b3eacc39d10add9c4bbc56d4b3a70a47f022351a1c6eab68cd66e71 package-lock.json
613d28096b1bed66f347590663f5f0136fb7f61a4578066fa2ed9e788ff9567a src/config.js
18a303a9b2bb95504e25e954c82e542da96702cf39a3499e9c9fd50e5160f39f src/favicon.png
f690fd9d35975bc977e3d323595a2a4aa1fca6ed50f213602f08adec3f5b2c92 src/gestures.js
e75dc9d5e311a3d9bf9dc373b651ad506afbd7a7cc7ad54aac6fdad94276b0a4 src/keyboard.js
2a070f63702b95b0c62963ec6b9ec4fa2b0b89b7553a8a889d68cdf3506f9ce3 src/keys.js
@@ -10,6 +12,6 @@ cb5e33c0fa117176d9073bd9019f78b65bfe5535833bf1765ee542c700d67f1f src/osc52.js
94229c981f6287162d6ef774f71ef41d941494595f76f16bb742811ca32a767f src/page.css
e02823ce9d5d57615a0a8c0c7b91611932202758048b7eadabe4f1054aaf1a9f src/protocol.js
aac82e88c6a65617818c6589091cbb8c3bab50e82e98aabf8b583cbb3dfda7e7 src/sgr.js
dab361cf748e6d530099f79ccd3f407fcc493f81dd4afeda227376a8c9e9b81f src/template.html
34e34a8d258b8a7d2afde03287681b99d839994936c398ba5c7165a3ed7aec93 src/template.html
3c8e5520b694693126097d9fe17f89ff23ba15adb8532521c647907edba20215 src/toolbar.js
864405fe11e5456bdb98ea8c841cb04346d5a38aabec897101defa9612d89443 src/viewport.js
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+4
View File
@@ -10,6 +10,10 @@
<meta name="color-scheme" content="dark">
<meta name="mobile-web-app-capable" content="yes">
<title>dejima</title>
<!-- Inlined like everything else on this page: ttyd serves one file and answers
no path a /favicon.ico could be fetched from, so a link to one would 404 and
the tab would fall back to the generic icon. -->
<link rel="icon" type="image/png" href="data:image/png;base64,/*FAVICON_B64*/">
<!--BUILD_STAMP-->
<style>
/*XTERM_CSS*/