That file keys per-project state by absolute path, and dejima copied the whole
map in. So a session was told the name of every project on the host, a sample
of each one's filenames (exampleFiles), what each had cost and when each last
ran - from inside a sandbox whose whole claim is that it cannot learn what you
do elsewhere (#380).
Narrowed for the reason #104 narrowed the projects MOUNT: the whole map is
every project's. The project's own path and anything under it are kept, because
a session's cwd is routinely a worktree inside the project and Claude Code keys
that separately. Compared as strings, which is meaningful here rather than a
guess about layout: both sides of this mount agree on the path by construction.
A `projects` key holding something other than an object is left alone rather
than narrowed. Narrowing it would error, and the staging treats a jq failure as
fatal - so an odd but parseable file would have become a launch that will not
start, over a key with nothing in it to narrow.
The other three directories #380 names are untouched: scoping backups/, tasks/,
plans/ and sessions/ is a behaviour change for the host side, and this one is
not.
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.
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.
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.
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.
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.
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.
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.
`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.
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.
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.
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.
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.
"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.
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.
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.
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.
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.
--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.
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.
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.
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.
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.
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.
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.
"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.
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`.
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.
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.
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.
"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.
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.
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.
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.
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.
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.
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.
`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.
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.
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.
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.
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.
`/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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
df7a744 fixed the comparison in build_entries; the exemption decision one
function up still compared INTERNAL_OK to the entry verbatim, on case and on
a trailing dot. A pair spelled differently passed the launcher's folded check
and build_entries' folded check, was recorded strict here, and the boot then
refused the name's internal answer - telling the operator to configure the
exemption they had already configured.
The existing test now spells the two sides apart in case and dot both, and
asserts the exemption that was recorded rather than only that the boot
proceeded.
The 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.
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.
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.
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.
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.
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.
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.
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.
resolve_one returns non-zero for a name that answered with no address, which
is an ordinary outcome. The sweep assigned from it bare, and under
`set -Eeuo pipefail` that aborts - so a permitted name whose host is
unreachable took the container down, which is the opposite of what the note
two lines below it exists to do. The previous form tested the substitution
inside `[ -n ... ]`, where a failure is part of a condition and exempt.
The harness could not see it. bats runs a test with errexit off, and `set -e`
inside a subshell of that shell does not restore it - measured rather than
assumed, because the first attempt at this fix was exactly that subshell and
it reported the regression as passing. The extracted functions now run in a
real child bash with the options egress-policy sets, which reproduces the
failure and then the fix.
It also wanted a table name the harness had never supplied, which the
stricter shell surfaced immediately as an unbound variable. Read out of the
script, like the exemption hint beside it.
Covered by the case that reaches it: a lookup that succeeds and carries no A
record. The existing empty-answer test stubs a dig that fails, and resolve_one
returns zero on that path, so it never reached the abort.
A 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
/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.
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
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
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.
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.
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.
retry_positive ended with a literal `return 1`, so check 5 reported "curl exit
1" whatever had happened - a code curl never returns here.
That is the fact the commit adding the retry turned on. It told a timeout from
a refusal: exit 28 meant the network, where exit 7 would have meant the policy
doing its job. The retry kept the container alive through the first and threw
away the evidence for telling them apart.
The status now survives the loop and is what the caller reports. `"$@" || rc=$?`
rather than `if "$@"`, because a bare `if` discards it.
The redundant `>/dev/null` on the check 5 call goes too: curl already has
--output /dev/null, and say writes to stderr.
The 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.
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.
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.
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.
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.
It only queries A now, so the v6 half of the address pattern can never match.
Left in, it reads as though a v6 answer is something this function still
expects to see, which is the opposite of what the policy around it says.
It 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.
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.
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.
`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.
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.
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.
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.
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.
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.
The loop that read it called dejima_permit_open, which appends to the open
class first and marks the entry internal second - so a name listed only there,
approved nowhere, became a permitted destination reachable on any port. The
configuration table described the key as exempting something already permitted,
which is what it should do and not what it did.
They are separate decisions and only one of them is a grant. Whether a
destination may be reached is an approval, reviewed with the project and
visible in an allowlist; whether an answer for it may point inside the
operator's network is this. A key that did both would make naming a host here
enough to reach it, which is a grant nobody reading an allowlist would think to
look for.
Naming something nothing permits now stops the launch and says which entry and
what to do about it. The alternative was to ignore it, which leaves an
exemption attached to nothing and a destination the operator believes they
configured silently unreachable - the two halves of a configuration disagreeing
with each other, which is the thing this branch already decided to fail on
rather than survive.
The two halves disagreed. bin/dejima-allow passed --internal-ok for any domain
recorded as an approval; firewall-up.sh deliberately withheld it, and said so.
So `dejima-allow --global foo.internal` succeeded and the session worked, then
the next launch read the same name with no exemption, saw the resolver refuse
the private answer, and stopped the container. The operator's first hint that
the two commands had different ideas was a failed launch, some time later, on a
name that had just worked.
The strict side is the right one. An approval says a destination may be
reached; it does not say an answer for it may point inside the operator's
network, and a project's declared domains are reviewed as public names -
exempting one on the strength of an approval is the pivot the check exists to
stop.
So neither half decides it: both read [core] internal_domains, and a name is
exempt if and only if it is listed there. That keeps the strict default and
keeps the live add working for a name the operator has actually declared
internal, which is better than either half was doing on its own.
The message that failure prints named EGRESS_INTERNAL_OK, which is the
policy's interface with whatever launched it and not something an operator can
set - it sends them looking for a variable that is not theirs. The launcher now
supplies the name of the key they do set, and the policy uses it, so the
component stays launcher-agnostic and the message is still actionable.
Two things the tests caught while being written. config_get prints its value
with no trailing newline, so a single-entry list is one line `read` never
returns and the loop never ran - the repo's own config parser guards that with
`|| [ -n "$var" ]` and this now does too. And config_get reports a missing
config file on stderr, which is right for a launch and noise here, where a host
with no configuration is ordinary and the answer is simply "not listed"; a
config that exists and is malformed still reports itself.
The README was updated with the policy; this file was not, and it is the one
that states the security argument, so its paragraphs were wrong rather than
merely stale.
IPv6. It described blocking the family outright with ip6tables and stripping
AAAA answers, and the fallback that asked the kernel whether IPv6 was live
before deciding. None of that exists: one inet table carries both families, so
there is no state in which one is filtered and the other is not, and AAAA
answers resolve and land in the v6 sets like anything else. Blocking v6 was the
honest answer to a v4-only allowlist rather than the better one, and the check
it needed is gone with it - there is nothing left to infer, because the table
either loads with both families and a closed default or the policy refuses to
start. This one mattered most: the README's GitHub-read-only argument now rests
on the allowlist covering both families, and this file still said the opposite.
Tool names. `iptables` is no longer installed, so "that user gets a permission
error running iptables" now describes a command-not-found. `nft` is the one to
name. The claim beside it was also wrong in a more interesting way: it said a
compromised session has "no copy of the allowlist within reach", which stopped
being true when /usr/local/sbin went on the session user's PATH. Reach is not
the question - the script refuses a non-root caller, and the capability it
would need belongs to the container beside it. Both directions are tested, so
the text now says what the tests check.
The request log. It named a static ulogd.conf that this branch deletes and
NFLOG groups that have changed; the configuration is generated per launch
beside the ruleset, which is what keeps the group numbers from drifting from
the rules that write to them. The DNS half was understated: lookups are now
structured records carrying a reason, and the reason that matters carries a
severity.
Mid-session domains. The "one gap remains" paragraph described a limitation
that is fixed - an internal hostname can be added to a running session now,
because the resolver's configuration is regenerated rather than signalled - and
the mechanism it credited for GitHub's port restriction is no longer the one
that decides it. Longest match decides which set an answer is written into;
rule order decides which ports that set is reachable on.
The privileged job got 60 of 62 tests through. Both failures were mine, in the
tests rather than in what they test.
`ps -eo user` pads the column to eight characters and truncates anything
longer, so the account this arrangement rests on came back as "egress-+" and an
assertion naming it could never match. The account is named for what it is
rather than for what fits in a column, so the test compares uids instead, which
cannot be truncated. The policy's own check was never affected: `ps -o user=`
with an empty header sizes the column to its content, which is why it passed
in the same run.
The other asserted a lookup of an unpermitted name comes back REFUSED. That
was true of the resolver this replaced, which had no upstream for such a name
and said so; this one answers NXDOMAIN, because it states the refusal rather
than leaving it as an absence. Which of the two a resolver says is a property
of how the refusal is expressed, not of whether the name is permitted, so the
assertion now accepts either and fails on an address.
One sibling assertion had already been updated when it moved; this one had not,
because it lives in the credential-proxy test and reads as being about the
/etc/hosts pin.
The diagnostic answered it. dnsmasq forwarded:
dnsmasq[166]: query[A] api.github.com from 127.0.0.1
dnsmasq[166]: forwarded api.github.com to 127.0.0.11
so the configuration was right, the forwarder's own rule matched, and the
query left. Nothing came back. The block was one hop further along: the
container runtime's embedded resolver forwards external names to the host's
real nameservers, and on some versions does that from inside THIS network
namespace, as root, on port 53 to a destination the policy has never heard of.
This chain refused it, so the resolver could go no further and every lookup
timed out having been permitted.
The firewall this replaced documented that exact hop and permitted it, keyed to
uid 0, noting that two hosts with byte-identical resolver configuration behaved
differently and it cannot be assumed either way. The replacement kept the
reasoning and dropped the rule.
What the rule costs is now stated where it is, rather than left to be
discovered: a process already running as root in this namespace can address a
resolver directly. The session user cannot - uid 1000, no sudo, no setuid
binary, no capability to change uid - so the query-name channel stays closed
for the thing this exists to contain. Root in the session container would be a
way around it, which is the posture the old firewall had too; it permitted uid
0 to port 53 anywhere AND accepted all loopback besides, so this is narrower.
Narrower is not closed, and closing it needs a discriminator finer than uid,
which the packet filter cannot express between two containers sharing one
namespace.
Three claims that were true of the version without this rule are corrected
rather than left standing, in docs/security.md, the entrypoint's comment and
the test that pins the property: what holds is that the session USER cannot
address a resolver, not that nothing but the forwarder can.
The permit and refuse chains now carry counters. The diagnostic added last
round tried to read them and found none, because there were none to read - a
refusal was visible in the log, which needs a consumer, and nowhere a startup
self-check could look. A runtime test also now asserts the session user cannot
reach the runtime's embedded resolver directly, which is the half of this that
matters and the half no unit test can reach.
The privileged job got past the ruleset and died at the next check: the
permitted name api.github.com did not resolve through the local resolver.
One likely cause, and the one this fixes. dnsmasq daemonizes, so its parent
returns almost immediately and `pgrep` succeeds while the child is still
binding. A query sent into that window is refused by the kernel rather than
answered, and every check after it reads that as the policy refusing a name it
permits - a container that fails to start, intermittently, over nothing. The
firewall this replaced had the same guard for the same reason; the replacement
waited for the process instead of the service.
The probe is a name that must NOT resolve, so any response at all - including
the NXDOMAIN it is supposed to get - proves the resolver is up and deciding.
Waiting on a name that should resolve would conflate "the resolver is ready"
with "the forward worked", which is the next check's question and a different
failure.
That may not be the whole of it, so the check that failed now reports what it
found rather than only that it found nothing: the resolver's own log lines for
the name, which say whether the query was forwarded or answered locally; the
configuration the name matched; the upstream resolvers discovered at start; and
the owning uid of every UDP socket in the namespace, from /proc/net/udp.
The last of those is the question this cannot otherwise answer. Permission to
reach a resolver outside the namespace is `meta skuid`, which matches the
SOCKET's owner - not the uid the process ended up running as. A socket opened
before dnsmasq dropped privilege keeps uid 0, and the rule would not match it.
The firewall this replaced never exercised that path: it accepted all loopback
traffic first, so the forward was permitted before any uid was considered.
Which is also why the rule is worth keeping rather than relaxing. That blanket
loopback accept is what let a session run `dig @127.0.0.11 anything.example`
and have the runtime forward it off-box - the exact channel the name allowlist
exists to close, open the whole time underneath it.
The privileged CI job died at the policy's own startup check:
[egress-policy] FAILED: the egress chain did not come up with a default-drop policy
The chain had come up with a default-drop policy. The check was
nft list chain inet egress_policy egress | grep -q 'policy drop' || die ...
and `grep -q` leaves the moment it matches, which closes the pipe. nft then
takes SIGPIPE and exits 141, `set -o pipefail` makes that the pipeline's
status, and the `||` fires. The check failed BECAUSE it succeeded.
It is invisible while the producer finishes writing before grep leaves, which
is why it passed every local exercise. It appeared here because the chain
listing carries a forge's published ranges, which is thousands of lines - the
first output big enough to lose the race. nft printed nothing, so there was
nothing in the log to suggest the command rather than the condition.
Replacing the producer with `printf` does not help; the SIGPIPE just moves. So
matches are `[[ ... ]]` where the pattern is a fixed string, and a herestring
where grep is genuinely needed - bash writes the whole string out before grep
runs, so there is no pipe for grep to leave early. `head -1` and `grep -m1`
are the same shape and go the same way: awk stops itself, a glob replaces an
`ls | head`, and the one place that wanted the first matching line reads the
lines in bash.
A static test now refuses the shape across the container's startup path, and
found two more instances while being written - in the ranges fetch and in the
resolver lookup, both of which would have failed the same way once their input
grew.
The same bug was in caps-lib.sh's CAP_SETPCAP check, which decides whether
either container starts at all: capsh's output is one short line and would
almost always win the race, which is the worst place for a race to be.
Also: the resolver configuration check now reports dnsmasq's own words rather
than only a status. The reason that matters is a build without nftset support
- the arrangement rests on the resolver writing answers into the packet
filter's sets, and a dnsmasq compiled without it rejects that line rather than
ignoring it, which is correct and completely opaque from an exit code.
The 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.
Most of these tests already asserted the right thing about the wrong
implementation, and moved across. The ones worth naming:
The #59 tests - a startup script must never die silently, and a fetch must be
bounded, retried and reported - now cover egress-policy, which gained the -E
and the ERR trap they check for. Porting them found two real defects. jq exits
0 on an empty body having decided nothing, so an empty response was being
accepted as a good fetch and failing later somewhere that could not retry; the
check now reads the filter's output rather than its status. And the body check
had to move inside the retry loop, because a rate limit answers with a message
and no ranges, sometimes over a 200, and it clears on its own - outside the
loop it would have failed a launch over something a second attempt would have
got right.
The resolution tests became tests of the start-time sweep over permitted names,
which has to keep two outcomes distinct: a name that returns nothing is
reported and the session continues, because the world is allowed to be down,
while a name the policy itself refuses stops the container, because that is two
halves of the operator's own configuration disagreeing and it does not fix
itself. Their fixtures write the refusal as the lookup happens rather than
beforehand, which is the real ordering - written beforehand, the test passes
without exercising anything, since the sweep reads only what it produced.
The IPv6 tests are gone rather than translated. They asserted that a family was
blocked, and that a branch which could not tell whether IPv6 was up would
refuse to start rather than log a conclusion it had not checked. Neither
question exists now: one table covers both families, so there is no state in
which one is filtered and the other is not. What replaced them asserts that,
and that a v6 destination is refused rather than merely unrouted - skipping,
not passing, where the namespace has no v6 at all, because a vacuous pass is
the failure those tests were written about.
The env-passing guard derives its exclusions instead of listing them. A name
these scripts originate is theirs; a name they read is the launcher's and has
to be passed. `X="${X:-}"` counts as a read, which is what keeps LOKI_URL in
the required set.
One test was deleted for being vacuous: it checked every `ipset add` in a file
that no longer exists, so it passed by matching nothing.
The 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
DEVCONTAINER=true was set for orientation. It misdescribes what this is: a
sandbox with an egress firewall, no sudo and no path back to root is not the
thing tools mean when they check that flag, and several of them change
behaviour on it - relaxing a prompt, enabling a feature, assuming a workspace
layout - on an assumption that does not hold here.
DEJIMA=1 stays, which is the same orientation stated accurately, and is what
the SessionStart hook and the harness already read.
The 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.
/var/opt is where an add-on package's variable data belongs; a directory at the
filesystem root is not. The volume keeps its name, so the mount lands on the
same data and an operator's existing history carries over rather than being
orphaned under a path nothing mounts any more.
It is also chowned to the sandbox user's group and given 0700 rather than left
at the default. Docker seeds a fresh named volume from whatever is at the mount
path in the image, permissions included, so this is what the volume starts life
with.
The 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.
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.
`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.
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.
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.
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.
`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.
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.
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.
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.
`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.
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.
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.
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.
`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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The static tests assert where the assignments are. This asserts what that
buys: with remote access off and DEJIMA_STATE_DIR and DEJIMA_RUN_ID both set
in the environment, a launch must not remove the files that join names.
Two canaries rather than one, because the removal needs both halves to come
from the environment - either name being dejima's own is enough to miss the
path, and a single file would pass on a fix that only covered one of them.
Two tests, and neither is the one already there. That one asks whether a name
cleanup() reads is BOUND, which is about set -u aborting the trap, and it
accepts `${NAME:-}` as the answer. Guarding against unbound does nothing about
a name arriving from the caller's environment already carrying a value, which
the trap then removes - and that is the invariant bin/dejima's own comment
states, so three names had drifted out of it with nothing to say so.
The first test asserts it for every argument to an rm in cleanup().
The second exists because the first has an exception. SANDBOX_GITCONFIG_DIR is
assigned below the trap and is safe there, but on a claim about its window
rather than about the name: no exit sits between the two lines. An edit that
puts one there turns the exception into the bug it is exempted from, and
nothing about that edit would look like it touched this.
Step 1 of #305's filter container, and a defect the step turned up.
The run id is generated for every launch now, not only a remote-enabled one.
It is the launch's identity and it was derived from whether one optional
feature is configured; the filter container needs it to label a network and a
container that exist either way.
That matters on its own, because cleanup() joins it to DEJIMA_STATE_DIR and
removes the result. With remote access off nothing assigned either name, so
both came from the caller's environment if it had them and the EXIT trap
removed a path of the environment's choosing.
bin/dejima already has a block for exactly this hazard, above `trap cleanup
EXIT`, and states the rule: every name cleanup() removes but does not itself
create belongs in it. Three more had drifted out of it - SANDBOX_BRIEFING_FILE,
SANDBOX_CREDENTIAL_PROXY_DIR and SANDBOX_FAKE_CREDENTIALS_FILE are first
assigned below the trap with ten, twelve and twelve ordinary `exit` paths in
between. The second is an `rm -rf` argument. Bound above the trap as well now,
rather than moved: their later sites read as what each feature starts from and
are correct as that.
SANDBOX_GITCONFIG_DIR stays the one exception and its comment stops claiming
uniqueness it had lost. What makes it safe is its window holding no exit, which
is a property worth asserting rather than re-deriving.
"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.
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.
`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.
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.
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.
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.
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.
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.
.dejima.md warned not to read "CI covers it" as "it passed", which is true
of a queued job and not of a green one. The forge does not show a check
green until every job has finished and passed, and runtime.bats is one of
those jobs - so there is no state where the checks are green and the
privileged half was skipped.
Read as written, the warning taught every agent working here to hedge a
result that is not in doubt, on every pull request. That is the error that
actually happens: the operator sees the hedge, knows the run was green, and
disregards the sentence - which costs the warning its force for the case it
was written for.
So it says what each colour means and what to report for it. Green is
verified end to end and should be reported that way. Yellow or grey is a run
that has not happened, and reporting one as passing is what the paragraph is
for. Both halves, because leaving either out is how this goes wrong in one
direction or the other.
docs/release.md and test/release-pass.sh carried the same framing and now
carry the same rule. The test that pinned the script's warning asserts both
halves, against the script flattened to one line so that rewrapping a
message is not a failure about what it says.
test/release-pass.sh took `main` - the local branch - as its default base.
Everything it prints is derived from base..head: which manual checks apply
comes from the paths the diff touches, and the rollback list comes from the
config keys it adds. A base behind the real one therefore reports on a
different release, and reports it confidently.
Not hypothetical. Two passes ran here against a local main 260 commits
behind origin/main, and both named config keys that had shipped releases
earlier and a trust-anchor domain this release does not add. One of those
reports was written into a handoff and read by the next agent as fact.
The default is the tracking ref now, and a base named explicitly is refused
when it is behind its own upstream - `main` is the ordinary thing to type
and is exactly how both stale runs happened, so catching only the default
would catch neither.
It exits rather than warning. A gate that says something is wrong and then
prints its report anyway is one whose output still ends up in a release PR.
Found by the release pass's security review, reading the two push guards
against what they claim.
Both resolved the remote with `git remote get-url`, which answers with
remote.<name>.url. git pushes to remote.<name>.pushurl when one is set, so
a pushurl naming any other host was checked against the fetch URL and
allowed. No evasion needed: .git/config is in the read-write project mount
- deliberately, since `git push -u` writes there - so a session can set one
and every later push is checked against the wrong host. `get-url --push` is
the one question that answers where the push goes.
A one-command `-c remote.origin.pushurl=...` slipped a second way: both
guards skipped `-c` and its value while finding the subcommand, then
resolved the URL without it, answering for a different configuration than
the push would run under. Both now carry those overrides into their own
lookup.
And the hook had two problems of its own, neither shared with the shadow:
It recognised `git push` and `git -C <dir> push` by pattern and nothing
else, so `git -c k=v push`, `git --git-dir=... push` and `git --no-pager
push` were not pushes as far as it was concerned and it exited 0 before any
check ran. It walks tokens now, splitting compound commands on the shell
separators first so that only a segment beginning with git is read as a git
invocation - `echo push` is not one.
It also failed OPEN on a remote it could not resolve where the shadow fails
closed, and the shadow's comment says why closed is right: an unresolvable
remote is what a disagreement between a parser and git's own looks like,
and that has to be a refusal rather than a push nobody checked. Reaching
that state was easy, because splitting the command on the first occurrence
of "push" split inside `pushurl` and produced a nonsense remote.
The shadow catches every one of these regardless - it is the layer that
holds whichever harness is selected - so what was wrong was the earlier
warning being quieter than it claimed, not the boundary. The test that the
two guards agree now has cases where they did not.
The passage fixing the credential blocker narrated the change instead of
the state: "used to fall through to it", "that is now a refusal". Someone
reading it to find out what happens today has to work out which half is
still true, and .dejima.md says exactly this - cut what the code replaced,
git holds it, and a reader debugging the line has no use for it.
Two more of the same voice, both mine from the prose pass: the credential
proxy "is no longer on that list", and a session-start hook being "no
longer the only route". Neither past state is load-bearing; both sentences
say more without it.
Two small things from a real install and a real launch.
install.sh emitted a blank line before the defaults rebuild whether or not
the rebuild had anything to say, so a run that added, dropped and reported
nothing left a double gap between two paragraphs that were already
separated. The blank line belongs to each message instead, which makes
silence silent.
And `claude-opus-5[1m]` was refused as a model id. The modelid type was
added a week ago because `identifier` had no slash and no OpenRouter id
could be configured; the set it grew was built from the ids in that report,
and Anthropic brackets a context variant. So the same bug, one release
later, for the same reason: a charset derived from the failure at hand
rather than from what the catalogues actually spell.
Brackets carry no meaning anywhere this value goes - it is written with jq
--arg - and the reason the type stays narrow is unchanged: a newline or a
tab would split a record every reader of this config expects to be one
line. Those are still refused, along with a space, a semicolon and a
backtick.
The test grew the bracketed id beside the others rather than replacing
them, and says why: real ids from every catalogue dejima can reach, not the
ones that prompted a fix.
CI failed static.bats 144 with
FAIL ctrl-c-still-reaches-the-session b'ped\n'
The last four bytes the container received were the tail of `typed\n`,
the line sent immediately before: ^C had not arrived yet when the check
looked. Nothing was wrong with the code under test.
Two sites sent a byte, pumped the pty for 0.8 seconds and then asserted -
timing the machine rather than waiting for the thing. A byte written to
the master has to reach the client, cross into the container stand-in and
be appended to a file before a check can see it, and on a loaded host that
is not reliably under 0.8s.
This is the third instance of the same bug in this file. The reclaim wait
and await_raw are the first two, and await_raw's own comment names the
shape - "Sampling the flags right after fg was timing the machine, and on
a loaded one it read the shell's own canonical mode". So it gets the same
answer they got, factored: await_received polls until what the container
received satisfies a predicate, and returns the predicate's answer at the
deadline either way, so a real failure still fails rather than hanging
until the suite's own timeout.
Verified under load rather than by argument: three consecutive runs green
with twenty busy loops on ten cores, which is heavier than the CI host
that failed it.
Release blocker, found by a release pass rather than by a test.
c306c32 made an unproxied credential a launch refusal: a Claude Code
signed in with a console API token has no claudeAiOauth block, so
backends/anthropic cannot serve it, and dejima used to skip the proxy and
mount the real file. docs/security.md's arrangements table went on listing
that as the fourth row - the one where the real credential is mounted and
a compromised session can take it.
A document promising the weakest posture for a launch that no longer
happens is worse than one saying nothing, because it is the posture an
operator plans around. Only proxy = off lands there now, and only where
they wrote it. The passage says what happens instead, and that the refusal
is scoped to a credential that exists: an absent one still launches,
because that is a host nobody has signed in on yet.
A static test pins the two together, since the drift was silent for a
release and nothing would have caught the next one. It reads the table's
own row rather than the prose around it, and asserts the code still
refuses - so removing the refusal fails the test about the document, which
is the direction that matters.
Also from the same pass: test/release-pass.sh enumerated four provider
axes under a comment claiming every one, and tools is the fifth. A new
tool is the same rollback hazard - [core] tools = rtk is refused by a
dejima that does not ship rtk - so it now reports one, names it through
the value that refuses rather than as a config section it does not have,
and a check against the tree fails when an axis is added and not listed.
A change under tools/ gets a manual check too; nothing here runs one.
And a flourish trimmed in docs/development.md: "confirmed rather than
argued" said nothing the sentence after it did not. What is left is the
fact, now stronger - Cline has run real completions through the terminator
against two backends of different api_style.
responsible-use.md is where dejima makes a judgement about what an operator
does - don't spam maintainers, own what you merge, security is not
retrofittable. Working around a vendor's client gating is not that, and
dejima has no view on whether someone should try.
What dejima has is a limit: it does not do it. That is a fact about the
credential proxy, so it lives beside the fact that prompted it, in
providers.md, where someone choosing a backend is already reading.
The reasons are the engineering ones. The proxy substitutes the credential
and changes nothing else, so getting past the gate would mean sending a
system prompt the harness never wrote - a second answer to "which client is
this" inside the component whose only job is to pass the first one through.
And it would be brittle where the cost lands on the operator's account
rather than on dejima.
Gone with the section: the sentence about a sandbox not getting to pick
which boundaries it honours. True of the sandbox, and an argument nobody
reading this needed.
will not pretend otherwise
The compatibility table said `anthropic` x `cline` and stopped there, which
is true of the dialects and not of the answer a session gets. A Claude
subscription's OAuth credential is served to Claude Code and refused to
other clients per model: claude-haiku-4-5 is served, claude-opus-5 and
claude-sonnet-5 are not, while an otherwise identical request from Claude
Code succeeds.
The refusal is a 429 rate_limit_error whose body message is the word
Error, so from inside it is indistinguishable from a quota problem - an
account nowhere near any limit still has every request refused. That is
worth documenting on its own: it cost an afternoon of reading usage graphs
before an experiment settled it.
The table itself is unchanged and stays derived - it answers whether dejima
will pair the two, which is a question about api_style. Whether the
endpoint then serves the request is the endpoint's to answer, and now has
a section of its own beside the table rather than a hedge inside it. The
matrix test keeps checking the cells against the manifests, which is what
it is for.
R5's "confirmed end to end" is qualified rather than withdrawn: the
arrangement is confirmed - placeholder in, real credential injected
host-side, a real completion out - and which models the credential will
serve a client that is not Claude Code is a separate question it never
asked.
And the non-goal, in responsible-use.md where this project's positions
live: dejima will not make one harness present itself as another to get
past that gate. It would forge a product's identity against the operator's
own account, on a control the provider put there deliberately. It would be
brittle in the worst way - working until the day it does not, and that day
being a question about someone's account rather than a bug report. And it
would contradict what the rest of the project spends its effort on: the
credential proxy forwards the harness's own request so that what the API
sees is true, down to declining to send a header the client did not.
The supported answers are both real ones - a credential that is yours to
spend for another harness, or the harness the subscription was issued for.
The decoded 429 reads, in full:
{"type":"error","error":{"type":"rate_limit_error","message":"Error"}}
The word Error and nothing else. So on the response this whole thread of
changes was chasing, the body is not where the information is - the
headers are, and a reader should not have to know to go looking for them.
request-id is what Anthropic support asks for and is the only thing on
that response that identifies it from the other side; retry-after is the
only thing that says how long. Both are logged when present.
Deliberately not the account and workspace ids the same response carries.
They name the operator's organisation, and this line is written to be
pasted into a bug report - request-id is enough to find the request
without carrying who made it. A test asserts they stay out, and fails when
one is added back.
The fixture gained all four headers, so the assertion that two are kept
and two are dropped has something to be true about.
gzip was the guess; the real answer came back br. So the encoding this
could not read was the common case rather than an edge - brotli is what a
Node client negotiates when offered, and cline offers it.
Decoded through brotli or brotlicffi when either is importable, using the
incremental decoder in both, since what this reads is a bounded prefix
rather than a whole stream. Optional on purpose: the proxy runs on the
host as the operator's user, and a diagnostic is not worth a hard
dependency on a module Debian packages separately. Absent, the line names
the encoding, the size, and the two commands that would fix it - a note an
operator can act on rather than one that only explains why they cannot.
Not solved by asking the API for an encoding this can read. The request it
sees is deliberately the harness's own header block, byte for byte, and
Accept-Encoding is part of what makes a session look like a stock install
- there is a comment about not sending CPython's own `identity` tell for
exactly that reason. Rewriting it so a log line is easier to read would
trade a property the sandbox exists for against a convenience.
The test asserts whichever half the host can demonstrate, and requires the
absent branch to name the fix rather than only the problem. Both branches
still produce no unprintable bytes, which is the invariant that does not
depend on any of this.
The logging reached a real 429 and put this on the operator's terminal:
dejima: line 1333: warning: command substitution: ignored null byte in input
...f0303U=....$..&.....o.IB..b..W.H,%..S..an,....;.G..2...f..m2.t.L/jz5..
The client's own Accept-Encoding is forwarded verbatim - deliberately, so
the request the API sees is the harness's own - and every Node client asks
for gzip. So the error body came back compressed and the prefix read for
the log was compressed bytes, written out raw. Which is also why the
message that would have explained the 429 was unreadable: the thing added
to answer the question was the thing hiding the answer.
Two changes, and the second is the one that matters. gzip and deflate are
decompressed with decompressobj, which works on the bounded prefix that is
all this reads; br and zstd have no stdlib decoder and are named rather
than printed. Then, whatever the encoding, only printable characters reach
the file - unconditionally, not as a consequence of recognising the
encoding.
That second half is an invariant this had no business breaking in the
first place. dejima-audit hex-encodes control characters in a finding
precisely because they scroll, blank and reorder a terminal, and an
upstream error body is no more trustworthy than a session-authored path.
An unknown encoding now degrades to a note about its size rather than to
bytes.
Control characters become spaces rather than being dropped, so a message
cannot be silently re-joined into a different one.
The fixture grew the shape this actually met: gzip, chunked, retry-after,
against a client that asked for gzip. The previous one answered plain, so
it could not have caught this - the same way the one before it answered
with a Content-Length and could not catch the chunked case.
The logging added last change met its first real rejection and recorded
half of it:
12:07:25 dejima-credential-proxy: upstream answered 429 for /v1/messages
12:07:28 dejima-credential-proxy: upstream answered 429 for /v1/messages
12:07:32 dejima-credential-proxy: upstream answered 429 for /v1/messages
Three status codes and nothing to act on. The body was read only where
upstream declared a small Content-Length, and a rate limiter answers
chunked - so the one thing that says which limit was hit, and the
retry-after that says for how long, were both dropped.
It reads a bounded prefix whatever the framing now, with read1, which
returns what has arrived rather than waiting for a buffer. That costs one
chunk, is written out before the stream picks up where it left off, and
retry-after is named on its own rather than left among the headers for a
reader to spot.
The summary called every one of those a refusal. A 429 is a client being
asked to wait, and cline retried through these - the session may well have
got its answer. It groups by status now, prints the most recent line
whole, and says plainly that a retried status is not a verdict on the
session. Grouping is the point rather than tidiness: one condition a
client retries becomes several identical lines, and a list of them says no
more than a count does.
The fixture grew a rate limiter's actual shape - chunked, no
Content-Length, retry-after - because the previous one answered with a
length and so could not have caught this.
One incidental: the grouping awk matches on the whole line rather than
looping over fields. A loop needs an index, and an awk index inside single
quotes is indistinguishable to the test that every name cleanup() uses is
bound before the EXIT trap fires. That test is right to be strict; it also
caught the index in the comment explaining this, which is the correct
amount of strict.
Reported from a real launch: cline against the anthropic backend started,
listed models, ran a prompt, spun, and errored. No logs anywhere to act on.
The proxy is the only thing that sees both the request the session made and
the answer the API gave, and it forwarded a non-2xx verbatim and recorded
nothing. What the harness then shows is the harness's business, and for
that session it was nothing an operator could use. Every other part of this
repo says what happened; this path said nothing for exactly the case
someone is trying to debug.
It records the status, the path and the API's own message now. Only for a
rejection: logging a successful request would put the operator's traffic in
a file, and reading the body to do it would buffer a streaming response.
The body is read whole only where upstream declared a small Content-Length,
and forwarded byte for byte afterwards, so what the session sees is
unchanged - a streaming or oversized rejection is recorded by status alone
rather than buffered here.
To a file, not stderr. The proxy inherits bin/dejima's stderr, which is the
operator's terminal, and the session owns it while it runs: a line written
mid-request lands in the middle of whatever the harness is drawing.
bin/dejima reads it back during cleanup, before the temp directory goes and
before the exit audit, which stays the last thing on the terminal. With no
--log it still goes to stderr, so a proxy run by hand is not the case with
no diagnostics.
An agent that has just run the pass holds everything the release PR is made of, so the instructions now say where the pass stops: the report is the deliverable, the release PR is never opened unasked, and the sanctioned next move is to prompt the user to create it.
Documents the two files and the property they buy, in the reference and in
the README's first-run section: your config is yours and dejima never
writes it, defaults.conf beside it is dejima's and holds what its defaults
were when this machine first saw each key.
`required` gets a second, mechanical reading - it is exactly the set with
no line in defaults.conf, because a key with no default has nothing to
pin. The document and the file agree without anyone keeping them in step.
config_origin says which of the three layers answered, and --print-config
does NOT print it. Appending it as a second column broke eleven callers in
this repo, all using `grep -qx key=value`, which is the obvious way to read
that output and evidence that more exist outside it. A format with callers
is one to extend deliberately rather than in passing, and the question it
answers is already readable from the files: in config it is yours, in
defaults.conf it is pinned, in neither it is what this release ships.
install.sh rewrites defaults.conf: keys this release added appended, keys
it retired dropped, every value already decided left exactly where it is.
Both halves fall out of rendering with "keep" rather than being two rules
someone has to remember.
Pruning is not the tidier half. An unknown key there is one this dejima
cannot read, so a file that only ever grew would collect them until the
launch notice about it became permanent.
A shipped default that no longer matches the pinned one is named here and
nowhere else, because here is when it moves - you pulled and re-ran this.
Both values are printed and neither is taken: taking one would be dejima
writing a change to a session's behaviour into an upgrade, which is what
this exists to stop. The line to adopt it goes in the operator's own
config, never in the file the next rebuild rewrites.
The starter config stops writing every key commented out at its default.
That was a second copy of the defaults, in comments nothing regenerates,
drifting against the schema as keys were added; defaults.conf beside it is
that reference now, complete and rewritten every install. What is left is
what was actually seeded - the selection and any legacy variables still
exported - with each section's header written by the first key that lands
under it, so a section nothing seeded leaves no empty stanza.
Nothing wrote defaults.conf, so the layer read in the last commit was
always empty and the schema always answered. A launch writes it when it is
absent.
dejima creating its own file is not the write this arrangement removes -
it is the opposite of one. Doing it here rather than only in install.sh is
what makes the property hold for an operator who never ran the installer,
and dejima runs perfectly well from a checkout without it.
A launch that finds the file does nothing further: no rebuild, no prune,
no drift check. Those belong to install.sh, which is when the shipped
defaults actually change, and doing them per launch would put a standing
condition in front of every one - the failure the orphaned-share list
already demonstrates.
Every installed provider's keys, not only the selected one. Pinning just
the harness in use leaves the next one an operator selects handing them
ITS current defaults, which is the same failure one axis over and a year
later. Keys with no default are absent, because there is nothing to pin:
those are exactly the ones docs/configuration.md marks required, so the
file and the document agree by construction.
After --print-config's exit, deliberately. That flag is what an operator
runs to inspect their configuration, and a query that creates a file as a
side effect is not one. It also kept a test honest, which is how the
ordering got noticed: --print-config emits one line per schema key, and
the announcement was an extra one.
A retired key in the file is named once, with ./install.sh, and the launch
continues on the schema's answer.
[core] harness has no default because a default that could change later is
a config file that is not the source of truth. That argument is not
special to harness: it holds for every defaulted key, and there is a
recorded instance of the failure - the [core] forge and workflow defaults
changed from gitea+worktree-pr to none+local-only, silently stopping
existing sandboxes pushing. install.sh still carries the note.
So a key resolves through three layers: the operator's config, then
defaults.conf, then the schema. The schema is reachable only for a key
defaults.conf has no line for - a fresh machine, or a key added since the
last ./install.sh - which is the one case where dejima's current answer is
the right one. Nothing writes defaults.conf yet; that is the next commit.
It lives beside the operator's file rather than under ~/.local/share,
because it explains the file next to it and the answer to "why is this
value what it is" belongs where someone would look for it.
The pinned layer gets its own two arrays rather than being seeded into the
operator's, and that is the whole load-bearing decision here.
config_is_set has ten callers and every one means "did the operator write
this" - the [core] ssh_key / [identity.default] ssh_key refusal fires only
when both are present, an identity's forge keys take precedence only when
it names one, and the ttyd knobs read unset as "the page decides from the
pointer type". A file naming every key would answer yes to all of them.
Its parse is lenient where the operator's is strict, line by line rather
than wholesale. An unknown key there is one dejima RETIRED, on a machine
whose defaults.conf predates the release that dropped it: the operator did
not write it and is told not to edit the file, so refusing would leave
them a launch that will not start and nothing to do about it. The schema
answers for whatever is skipped and the count is kept for the launch to
name once.
Both parsers stopped clearing their own arrays; config_validate clears all
four before either runs. That is not tidying: with the old shape, seeding
the pinned layer into the operator's arrays was a bug the following parse
happened to wipe, so the test for it passed against the mutation. It
fails now.
The section said stock tmux, which scrolls five lines. The configuration
that prompted this bound halfpage-up instead - half the pane, so twenty
lines in a forty-line one, which is what "more than half the screen" was.
Both are worth naming, and neither is worth guessing at from here: the
first line of the section is now the list-keys that answers it. The
example scrolls four rather than one, which is what the reported fix
settled on and reads better on a desktop sharing the same server.
Reverts "Make a scroll notch cost more finger travel where a notch is
worth more" and its doc commit.
The diagnosis was half right. A notch is worth several rows to whoever
receives it, so one notch per row did outrun the finger - but sending
fewer notches trades a fast scroll for a stiff one and leaves each jump
exactly as big. On a phone that jump is what matters: five lines is more
than half the screen, and reported as still needing several tries to place
a line after the change.
Confirmed by asking where the notch lands: the pane enters copy mode with
a position counter, so tmux is receiving it and the five is tmux's own
binding.
Nothing in dejima can make that smaller. The page chooses how many notches
to send and nothing else, and `dejima-attach` joins the tmux session the
operator launched from, whose configuration is theirs. So the answer is
four lines in their ~/.tmux.conf, and docs/remote-access.md now carries
them - both key tables, since which is active follows mode-keys - along
with why this is not something dejima sets on their behalf.
web/test/gestures.test.js stays, rewritten for the behaviour that is back:
one notch per row of travel on both paths, nothing for travel shorter than
a row, and the direction. The module had no test file at all before, and
the reverted change is not why it wanted one.
remote-access.md's "Mounting the box's files" is how you get at a project's
share from another machine, and it already links back here for the
clear-it-out advice. The other direction was missing, so an operator
reading about ~/shared had no way to reach the warning that a desktop
indexes, thumbnails and previews everything a sandboxed session wrote
there, with nothing of dejima's in the path.
The knob is meaningless without the reason it exists, which is that who
receives a notch decides what it costs: one row when the page scrolls its
own scrollback, five when stock tmux is in copy mode.
SANDBOX_ANTHROPIC_PROXY_SOCKET, _PROXY_PID, _PROXY_DIR, _PROXY_PORT,
_TLS_CERT, _TLS_KEY and _TRANSPORT describe a unix socket, a forwarder
process, a self-signed cert and a choice between TLS and loopback. None of
that is Anthropic's; the terminator answers for whichever backend's
proxied_domain was selected, and openrouter goes through the same code.
They are SANDBOX_CREDENTIAL_* now, and /etc/dejima-anthropic-tls inside the
container is /etc/dejima-credential-tls for the same reason.
Three keep the old name, and that is the interesting half. BASE_URL,
AUTH_TOKEN and API_KEY each carry a value into the ANTHROPIC_ variable
beside it in docker-compose.yml, which is what Claude Code reads. Renaming
those would name them after the thing they are not. Said at the
declaration so nobody finishes the rename by mistake.
SANDBOX_ANTHROPIC_TRANSPORT keeps its old name in the two places it is the
operator's rather than dejima's: install.sh seeds [credentials] transport
from it on a first install, and _warn_legacy_env names it as retired. Both
read what is in someone's shell profile, and that string did not change.
The rename removes the collision those had with dejima's own variable of
the same name.
A stub in config.bats filtered docker's environment to ^SANDBOX_ANTHROPIC,
so the renamed socket variable stopped appearing in what two credential
tests assert on. It takes both prefixes now.
The terminal page sent one wheel notch per row of finger travel. That
tracks the finger exactly while the page is scrolling its own scrollback,
because a notch moves one row there. It does not when an application is
reading the mouse: stock tmux binds copy-mode's WheelUpPane to
`send -X -N 5 scroll-up`, and a full-screen application picks its own
figure. So a small movement of a thumb threw the screen several rows, and
putting a particular line at the bottom took several tries.
[remote-access] ttyd_scroll_step is how many rows the finger travels per
notch, default 5, and it multiplies only on the path where a notch is
worth more than a row. Applying it to both would make the scrollback
sluggish to fix something that is not wrong there.
The odd one out among the -t knobs: unset does not mean "send nothing".
The page has to pick some travel per notch either way, so the schema
carries a real default and the page is told it rather than keeping a
second copy in step. ?scroll=N overrides per client, which matters here
more than for the others because the right number depends on what is
running in the session rather than on the host.
A step of zero or less is refused twice - by the schema's 1-40 bound and
again in the page - because scrollBy consumes accumulated travel in a
while loop keyed on it, so zero spins forever on the first drag.
gestures.js had no test file. It has one now, driving the real touch
handlers through a stub element: the scrollback path unchanged at any
step, an application path at 5, the old behaviour still reachable at 1,
and travel shorter than one step sending nothing rather than rounding up.
Removing the multiplier fails three of them.
Also checked in chromium against the built page, with a stub WebSocket
speaking ttyd's frames: a 20-row drag with SGR reporting on sends 4
notches by default and 20 at scrollStep=1, a server value arrives through
SET_PREFERENCES, and ?scroll=2 beats a server value of 10.
The list in configuration.md named ~/.claude and stopped, which was the
same list bin/dejima had. It also left out the epitaph store and the share
root, both of which have been guarded for a while.
[mounts] read_write refused a writable mount overlapping $HOME/.claude, by
name. Cline keeps its configuration in ~/.agents and ~/.cline and its state
under ~/.local/share/dejima/cline, none of which were in that list - so
read_write = ~/.agents was accepted, handing a session write access to the
hooks and skills the operator's own machine runs next time. That is the
persistence this refusal exists to stop, and the docs already claimed it
was stopped.
The list is derived now. Every host path the selected harness's plugin
names, in all three declaration tiers, plus every `path`-typed key in its
manifest - so the state directories are covered without this knowing what
a given harness calls them, and a harness landing later is guarded on the
day it lands rather than when someone remembers.
Read-only tiers count too. A read-only mount protects the host's file from
the session that has it; it says nothing about a writable mount an
operator points at the same file, and what matters here is only that the
harness reads it on the host.
A staged or seeded entry contributes its host source AND $HOME/<rel>, the
path it is installed at. The two differ when the operator keeps the file
elsewhere, and the rel is named whether or not the file exists, which the
source is not - emit_seed_from drops an absent source. That is what still
covers ~/.claude/plugins, which is reached only through a seed
destination and which the literal used to cover.
The three loads move up to where the guard is, because a guard that runs
after the mount it would refuse is not one. They were already idempotent;
their consumers further down now read the arrays rather than reloading.
Tested by launch rather than by reading the list: one case per way a path
reaches it, under both shipped harnesses, plus a mount outside the
harness's config that must still be accepted - refusing everything would
pass the first test on its own. Each of the three arms was removed in
turn and each failed on a different path.
Seventy rows in one table, in the order keys were added. [harness.cline]
was split across two places with the backends interleaved between them,
[ui] sat inside [core], [logging] was split around [epitaph], and nothing
distinguished a key you must set from one with a default.
It is a table per section now, each under a heading naming that section so
a row can say `harness` rather than `[core] harness`. Sections are ordered
by what a launch depends on: the three [core] selections first, then the
harness they select, then its backend, then the credential proxy, then who
the session acts as, then the forge, then the surfaces that are off until
switched on.
`required` in the Default column marks what an operator supplies. Exactly
one key is required unconditionally, and the intro says so - every other
one is conditional on a selection, and the section it sits under is what
names the condition.
[identity.<name>] gains rows at all. Its keys are computed - three of
dejima's own plus each forge's identity_keys - so they were in the schema,
settable, and documented only in a prose section further down.
The test that pins the reference against the schema follows the structure
rather than dictating it: a bare key under a heading naming its section
counts, as does a fully qualified one in the tables that cover several
sections at once. It now requires a table ROW in both cases. Matching the
key anywhere in the file let it pass with the key documented only in a
paragraph three sections away - which is the discoverability problem it
exists to catch, not an acceptable answer to it. Dropping a row for
[harness.cline] context_window passed before that and fails now.
[harness.cline] model was typed `identifier`, which is [A-Za-z0-9._-].
Every hosted catalogue namespaces by vendor with a slash and several
qualify a variant with a colon, so `z-ai/glm-5.3` was refused and not one
OpenRouter id could be configured at all - the backend this harness exists
to reach was unusable, and the launch said only "invalid value" without
saying which character it objected to.
A `modelid` type instead: a leading alphanumeric, then the identifier set
plus `/` and `:`. Still narrow, and for the reason `path` is rather than
for shell safety - the value is written with jq --arg, so JSON escaping is
not the concern. What it protects is that a newline or a tab would split a
record every reader of this config expects to be one line.
Every existing test said `model = m`, which is a bare identifier and
passes under either type. That is why nothing caught it. The new ones
configure real catalogue ids and check what lands in providers.json, not
just that the validator was happy - reverting the type fails them with the
exact error reported, and widening the type to accept anything fails the
half that keeps a space out.
Moving the hook registrations into dejima's own settings overlay left
harness_guards_registered reading the operator's ~/.claude/settings.json
and naming everything it did not find there. Nothing writes there any
more, so on a host that never ran the old installer every launch printed
not registered for claude-code, so not in force this session:
the session briefing
push-guard
merge-guard
bash-deny
Run ./install.sh to register them.
with all four in force, in the overlay, at their baked paths. Following
the instruction makes it permanent: install.sh's upgrade cleanup strips
exactly the registrations the check is looking for.
The check is deleted rather than repointed. What the overlay carries is
composed fresh each launch from the manifest, so reading it back would be
asking dejima whether it did what it just did; which guards a harness
cannot host at all is the manifest's, and harness_report_guards already
says it. The hook stays in the plugin interface - what a harness needs
wired on the host is a real question a future one may answer differently.
Three tests went with it, and their being green is why this shipped. All
three asserted the notice against the operator's settings.json - one that
it fires when nothing is registered there, one that it names a single
hand-dropped guard, one that it stays quiet when all four are present.
Every one of them still passed after the move, because the notice did
still fire; what changed was that firing had become wrong. Tests that
pinned where the guards used to live outlived the guards.
What replaces them asks the question the launch actually answers: with
settings.json present and dejima nowhere in it - the state a correct
install now leaves - a launch must not claim the guards are off, and the
overlay must carry all four. The second half is not optional: without it
the first passes on a launch that registered nothing anywhere.
It was the longest section in the file and buried its answer. Most readers
bounce before the end of a section, and this one's audience is someone
wondering whether the container choice was considered - not someone planning a
port. So the whole argument is now in the first two hundred words, and the
detail that only serves a port is gone: what virtiofs would cost per mount,
what a vsock forwarder would be for, which of Docker's verbs the tooling uses,
and gVisor's odds against the browser in the image.
What survives is what the question is actually about. A microVM is the stronger
boundary and this does not dispute it. Escape is the least likely of the six
and the only one needing an exploit. There is already a VM here, hardened from
outside the guest, which is where the effort went. And the three things that
would reopen the decision, so it reads as a judgement rather than a door held
shut.
#104 and #305 are links now rather than bare numbers: a reader who wants the
detail this section stopped carrying should be one click from it.
The README callout goes in the stack section rather than the docs table,
because that is where a reader is standing when the question occurs to them,
and an answer nobody finds gets asked as a question instead.
The most frequently asked question about this project, and the docs answered
only half of it. "Which deployment this describes" concedes the field's
tiering and says escape moves a layer; it does not say why the effort went
below the container rather than into a stronger one, so the answer lived in
conversations rather than anywhere a reader or a sweep could reach it.
Written as an argument that can lose. It concedes the ordering outright rather
than defending a container as the better primitive, names what gets harder
under a VM as well as the one thing that gets easier, and ends with four things
that would change the answer - Kata as the cheap path, gVisor as a spike,
dropping the trusted-project assumption, and a live escape clearing this
configuration's floor.
The point the field's framing usually misses is that there is already a VM
here. The choice is not one boundary against two: it is an outer VM enforced
from the hypervisor, against that plus an inner one managed by software running
inside the blast radius of the row it is meant to bound.
static.bats pins the concession rather than the prose, because the failure mode
is a later revision quietly upgrading it into a claim that a container is as
strong - which would be false, and would read as reasonable.
The README and docs still stated general properties in Claude Code's
vocabulary, from before there was a second harness or a backend axis. Some
of it was only narrow; the rest had become wrong.
Wrong, and fixed here:
The exit-time audit does not watch ~/.claude/projects. That surface was
retired when the cross-project finding was, because since #104 a session
gets its own slugs and nothing else, so the only writes visible there are
other sessions doing their own work. The README still listed it, and
omitted .vscode/ and the host-execution surfaces the harness declares.
configuration.md described Cline's model configuration in terms of a
[harness.cline] base_url that no longer exists - where the model is moved
to the backend - and said a hosted API cannot be proxied, which openrouter
is. It also told an operator to resume a Cline session on the host with
CLINE_DATA_DIR, three paragraphs above the section saying that is not a
supported path (#301), and opened Cline's parity section with "almost
nothing carries over" while the table of what carries over sat below it.
development.md said the credential proxy was Claude Code's shape
throughout. It is arguments now, which is what lets Cline reach a proxied
backend without a branch of its own.
security.md described the push and merge guards as regex over a command
string. They shadow git and the forge CLI on PATH and read argv; only
deny-dangerous-bash.sh is regex.
The README claimed hooks refuse destructive commands, flat. That needs a
hook the harness can host, and Cline hosts none - the launch already says
so, and now the README does too.
Narrow rather than wrong, and widened:
"One project's session cannot reach another's" documented one harness's
mechanism for a property both have, so it now says what the property is
and then how each harness reaches it. The Cline half is a data directory
per workspace rather than a slice of yours, which is why it clears R4 more
cleanly rather than less.
The credential is a model credential, not an Anthropic OAuth one; the
allowlisted endpoint is the selected backend's, not api.anthropic.com; the
briefing reaches a session by one of three routes rather than through a
SessionStart hook. The ~/.claude mount inventory stays Claude Code's and
now says so, with a pointer to Cline's.
Notifications are gated on a harness declaring the notify guard, which
remote-access.md did not mention - a remote-enabled Cline session gets
attach and the web terminal and no push.
The commit that took deny-dangerous-bash.sh off the host left three places
saying otherwise: the README's install step and development.md both named
it as the one hook install.sh still writes, and install.sh's own messages
reported that no guard hooks were registered when it can no longer
register any. The block those messages sit in still needs the harness
name, but for the upgrade cleanup rather than for registration.
security.md described the tamper it guards against as unlinking one of
install.sh's hook symlinks. The tamper is unchanged and the directory is
still mounted read-only for it, but the symlinks in it are now the
operator's own.
harness_prepare_config created an empty settings.json and CLAUDE.md in
~/.claude when the host had none, so that staging had something to copy.
A file the operator does not have is not one dejima should make on their
machine for its own convenience, and the file is needed in the container
rather than there.
emit_staged_config takes an `ensure` flag now, and the entrypoint creates
what it names - root-owned, read-only, refusing a path that could climb
out of $HOME, and leaving an existing file alone so a second launch does
not blank what was staged from the operator's own.
Two kinds, because empty is not always a valid file. settings.json asks
for `{}`: an empty one is not valid JSON, and Claude Code discards a
settings.json it cannot parse wholesale, taking every permission, hook
and plugin with it - the exact failure the validation above it exists to
catch. statusline.sh asks for neither, because a placeholder there would
be installed executable and run as the status line.
That distinction was not something I reasoned out. The test asserting the
old behaviour said in its own comment that an empty settings.json is one
Claude Code would refuse to parse, which is the only reason the first
version of this - writing an empty file for both - did not ship.
Also adds a Model backend maturity section to docs/providers.md. The
harnesses and forges each have one saying how much use they have actually
had; four backends in a table without one invites the reading that they
are equally exercised. anthropic is the one with the miles on it,
anthropic-api-key and openrouter are confirmed working rather than worn
in, and self-hosted runs no proxy at all so there is least of dejima
between the session and the endpoint. A test derives the list from what
ships, so a backend added later fails it until someone says.
The pty fixture ended its read loop when the child stopped WRITING and
then required it to have already exited. Those are not the same moment:
dejima runs its exit audit and stops its shipper after the last thing it
prints, so a child that was merely busy got killed and reported as a
hang - and took SIGPIPE on a pty nobody was reading any more, which is
where the "printf: write error: Broken pipe" in the failures came from.
It waits up to a minute for the child to finish now, and only kills after
that. An unanswered prompt still ends in a kill rather than a hung suite,
which is what the kill was for.
This is why --no-pause failed intermittently on the gate and once in six
isolated runs. It was never load: it was a race between a child
finishing and a fixture deciding it never would.
Recorded because the first attempt made it worse in a useful way. Adding
the grace period without importing `time` turned an intermittent failure
into a consistent traceback, which is how the real cause became visible
at all - an intermittent test that fails for two different reasons hides
the second one behind the first.
deny-dangerous-bash.sh was the one exception: baked nowhere, registered
in the operator's ~/.claude/settings.json, and deliberately ungated so it
applied to their own sessions too. That was dejima deciding something
about a machine outside its sandbox, which is not dejima's to decide.
It is baked and overlay-registered with the others now, so it still
blocks what it blocked inside a container and stops existing anywhere
else. An operator who wants it on their host sessions can register it
themselves - it is a hook in this repo and always was.
install.sh consequently writes nothing to ~/.claude at all: no symlink,
no registration, and no settings.json brought into existence to hold
neither. harness_register_guards registers nothing and says so; the hook
is kept because "what a harness needs on the host" is a real question a
future one may answer differently, and for Claude Code the answer is now
nothing. The upgrade cleanup takes back this hook as well.
One bug found by a test rather than by reading. Removing install.sh's
`mkdir -p ~/.claude/hooks` left nothing creating ~/.claude, and
harness_prepare_config writes a settings.json and a CLAUDE.md there - so
on a host where Claude Code had never run, a launch failed reporting a
settings.json it had just failed to create. It creates the directory it
writes into now.
Fixes#303.
install.sh symlinked dejima's hooks into ~/.claude/hooks and registered
them in ~/.claude/settings.json. Five of six no-op outside a sandbox, so
nothing misbehaved - but dejima was editing a config that is not its own
to make its own sandbox work, and "harmless because six gates say so" is
a worse claim than not doing it.
Claude Code merges the --settings overlay with the operator's settings
rather than replacing it, which is what makes the alternative possible.
The codebase already relied on that: harness_register_notify has been
writing a Stop hook into the overlay all along.
So sandbox-context.sh, git-push-guard.sh, merge-guard.sh and
dejima-notify.sh are baked into the image at /usr/local/lib/dejima/hooks
and registered per launch into the overlay by absolute path. Baked rather
than staged under $HOME: a session owns its home directory, and a guard
whose script it can unlink is not one. That is the property the read-only
~/.claude/hooks mount used to provide.
deny-dangerous-bash.sh stays where it was. It is the one hook meant to
apply to the operator's own sessions, and it already yields to an
existing install.
An upgrade takes back what an older install.sh wrote - the symlinks and
the registrations - matched by name and only when the symlink points into
this repo, so an operator's own file at one of those names is left alone.
Without that, the claim would hold only for a fresh install.
The operator's own ~/.claude/hooks is still mounted read-only, so their
hooks reach a session exactly as before.
Session persistence per project already works: this project's Cline state
lives on the host in a directory of its own and survives the container, so
launching dejima again picks up where the last session left off. Importing
or exporting sessions across that boundary was assessed and is not being
built (#301), so the behaviour is documented instead.
The briefing was telling agents the opposite. Core's closing carried "They
can continue this exact session outside the container by running on the
host:", which is Claude Code's fact stated as everyone's - and Cline's
fragment answered it with a CLINE_DATA_DIR incantation for driving a
host-side Cline against a directory that is dejima's rather than Cline's
own. A Cline agent reading that would send its operator somewhere not
supported.
The sentence moves into the harness fragments, where the fact belongs and
where the comment above the assembly already said it belonged. Claude Code
keeps the offer and the command. Cline says it cannot be continued outside
dejima, tells the agent not to offer it, and says what continuing does
look like - launching dejima again - because an agent told nothing will
invent something.
--harness's briefing test keyed on CLINE_DATA_DIR to tell the two apart.
It keys on what each says about resuming now, in both directions, so a
briefing carrying both would fail either way rather than passing one.
harness_state_dir is now read by no shipped fragment. Left in place: it is
documented briefing vocabulary rather than a dead manifest key, and
removing it would take cline.sh's harness_briefing_state_dir with it.
Worth a decision rather than a silent deletion.
cline loads global rules from Documents/Cline/Rules and nowhere else.
~/.agents/rules reads like the obvious companion to ~/.agents/skills and
is not loaded - planting a file in each and asking `cline config` which
it had returned only the Documents one, while Skills(1) in the same run
confirmed ~/.agents/skills IS read.
So ~/.agents/rules is supported by staging its files INTO the directory
that is read, rather than by mounting it where nothing would look at it.
Host Documents/Cline/Rules is staged the same way. Dotfiles stay the
place an operator keeps rules; the Documents path is where they have to
arrive.
File by file rather than as a directory mount, because dejima writes its
own briefing into that directory and a read-only mount over it would
leave nowhere to put the briefing.
Two collisions have fixed answers. A name in both host locations is taken
from Documents/Cline/Rules: that is cline's own location for rules and
where it writes when they are edited through cline, and staging both
would be two bind mounts at one container path. A host file named
00-dejima.md is skipped - the briefing's name is read from the manifest
rather than repeated here, and entrypoint.sh already writes the briefing
after the staged files, so staging one it overwrites is only noise.
Only .md and .txt, which is what cline reads out of a rules directory.
Cline declared no config directories at all, so none of the operator's own
configuration reached a session: no skills, no hooks, no AGENTS.md. The
mechanisms this needs already existed for claude-code, so this is three
hook implementations rather than new machinery.
Read-only mounts for ~/.agents/skills, ~/.agents/hooks and
~/.cline/hooks. Read-only is the point rather than caution: everything
there is either an instruction the agent follows or a program cline runs,
and a session that could rewrite them would be choosing what happens on
the HOST the next time cline starts outside a container - persistence for
a prompt injection, in files nobody re-reads before the next run. Scanned
for symlink targets, because on a Linux host these are routinely links
into a dotfiles checkout.
~/.agents/AGENTS.md is staged: a root-owned read-only copy. It is
instructions the agent follows, so writing the host's would choose what
the next session is told - and staging also survives it being a symlink,
which a file mount does not.
~/.cline/settings.json and cline_mcp_settings.json are seeded writable
and discarded with the container. cline rewrites both as it runs, so a
read-only copy would fail the write rather than the persistence - and the
persistence is the part that matters, because cline_mcp_settings.json
names MCP servers, which are commands cline executes.
~/.agents/rules is deliberately absent. It reads like the obvious
companion to ~/.agents/skills and cline does not load it: planting a file
there and one in ~/Documents/Cline/Rules and asking `cline config` which
it had returned only the second. Mounting a directory the harness never
reads would be a promise dejima cannot keep, so the test asserts its
absence.
Also raises the pty fixture's idle bound. Sixty seconds was enough on an
idle box and not enough under the full gate, where it killed a child that
was merely slow and reported it as a hang - the same shape as #295.
Starting several sessions from a tmuxinator layout gives every one of
them a terminal, so the pause fires on every one, and the operator wants
them all at a prompt rather than eight keys to press. --no-pause skips
the wait and still prints the notice: skipping the wait is not skipping
the telling.
A flag rather than a config key, deliberately. It describes the terminal
in front of one launch, not a standing preference - someone who wants
eight sessions to start unattended today has said nothing about the
launch they run by hand tomorrow.
Making room for it in --help found a real bug. --base-url was listed
there and had not been parsed since the provider axis moved the model's
address onto the backend and deleted the flag. Passing it answered
"unknown option", which is the worst kind of documentation: it reads as a
bug in dejima rather than in the text. Removing it also frees the line
the help page's own budget needed, which is how the constraint earned its
keep.
So a test now cross-checks the two: every flag --help offers must be one
bin/dejima parses. It has to match a flag anywhere in a case pattern,
because --ro is the second alternative of `--extra-readonly|--ro)`.
docs/configuration.md gains a short section on what dejima says before it
starts - the pause, when it fires, and that the shared-directory list is
sampled - since none of that was written down anywhere.
Adding a prompt before the build changed what every pty-driven launch
test is typing into. Two of them were driving a different prompt and had
their first keystroke eaten by this one.
test/fixtures/answer-prompt.py answers the pause automatically. It is
ambient - every launch fixture sets [core] ssh_key, which is a
deprecation, so it fires on all of them - and it is not the prompt any of
those tests is about. static.bats's tty helper does the same with a
leading newline, in the helper rather than in six callers, so each
caller's keys still mean what they say.
And the fixture no longer hangs. It waited on the child unconditionally,
so an unanswered prompt - the likeliest way for it to end badly - blocked
waitpid forever and took the whole suite with it, silently. It kills the
child and says which it was. That is what actually happened here: the
gate ran for twenty minutes on one stuck test before I looked.
Reported from a real launch, where the notices were sandwiched between a
ninety-entry file list and the first thousand lines of a Docker build.
The orphaned-share list is a sample now. It is a standing condition an
operator clears once, and printing all of it on every launch pushed
everything about THIS run off the top of the screen. Eight entries and a
count of the rest.
And a notice earns a pause. Every line after it is the image build, so a
notice that lands there scrolls away before it can be read - the same
reason the exit audit prints its findings last. It fires only when
something was said, and only with a terminal attached: an unattended
launch must not block on a keystroke nobody will press. The share list
deliberately does not raise it, because pausing every launch until a
standing condition is cleared teaches the operator to press Enter without
looking, which is the failure the pause exists to avoid.
Separately, and pre-existing: an invalid config reported its reason once
per PARSING CONTEXT. The early validation runs suppressed and, on failure,
dropped the cache so the next caller would parse and report - but the next
caller is usually a subshell, whose cache dies with it. One duplicated
line was merely odd; with a hint under it the same message printed twenty
lines. Re-parsed in this shell instead, so the reason is printed once and
the failed status is cached where subshells inherit it. Deliberately not
an exit: flags are not parsed at that point, and someone with a broken
config is exactly who needs --help.
The pause has a positive test as well as the unattended one. Without it
the guard test passes just as well when the feature does not exist, which
is not hypothetical: an accidental `git checkout` wiped both this and the
share sampling mid-session, and only the sampling test noticed.
give the startup notices room
Three things reported from a real launch on a fresh container.
A key dejima itself moved reported only "unknown key". base_url left the
harness for the backend in the provider axis, and an operator who had it
got a message that is true and useless: it does not distinguish a typo
from a move, and the answer is in a document they have no reason to think
they need. Unknown keys dejima once accepted now say where they went, and
show the two sections to write. Kept deliberately to keys dejima moved -
a key nobody ever accepted is a typo, and guessing at what it meant is
worse than saying nothing.
The assumed_backend migration appended its own [harness.<name>] section
even when the operator already had one, leaving the same header twice. It
parses, and it reads as a mistake dejima made in someone else's file. It
writes into the existing section now, and appends only when there is
none. Inserting right after the header is safe here specifically because
the key is UNSET: there is no later assignment for a line near the top to
lose to, which is what makes editing a config file dangerous in general.
The file keeps its own permissions rather than a fresh temp file's.
And the startup notices ran together as one block: an environment
deprecation, an identity deprecation, dejima editing your config, and the
guards that are not registered, with nothing between them. Each notice
owns the blank line above it now - never below, so two firing on the same
launch cannot double up - and the migration says what it wrote with the
line picked out, because that is the one notice an operator may want to
go and look at.
The migration test pinned the old wording; it asserts the announcement
and the written line instead, and now also that exactly one section
exists, which is the reported bug.
Found by the release pass's documentation review, which asks whether any
document describes something that no longer exists.
proxied_api_domain was a HARNESS manifest key. The provider axis moved it
to the backend as proxied_domain, because a harness reaches whichever
endpoint it is configured for - leaving the name on the harness would
have the terminator answering for one API while the session talked to
another. The key is gone from the schema and from both harness manifests,
and docs/providers.md still had a row describing it, and
docs/development.md named it twice as something to find in
harnesses/<name>/harness.conf.
A reader following either would go looking for a key that cannot be set,
and conclude the feature was missing rather than moved.
Two smaller leftovers from the same rename: a refusal's comment cited
anthropic-console, which the two-backend consolidation removed, and a
static test was named for the provider's declared domain when the axis it
asserts on is the backend's.
2026-09-01 03:07:16 -07:00
83 changed files with 15046 additions and 3401 deletions
printf'\nPinned this release'"'"'s defaults in %s.\n'"$path"
return0
fi
mv "$tmp""$path"||{ rm -f "$tmp";return 1;}
# Each line carries the blank one before it, so a rebuild with nothing to
# say prints nothing at all rather than a gap where a message would go.
["$added" -eq 0]||printf'\nPinned %s new default(s) in %s.\n'"$added""$path"
["$dropped" -eq 0]||printf'\nDropped %s retired line(s) from %s.\n'"$dropped""$path"
# Drift, last, because it is the part that asks something of the operator.
whileIFS=read -r key;do
[ -n "${_CFG_BASE_SEEN[$key]+x}"]||continue
pinned="${_CFG_BASE_VAL[$key]}"
shipped="$(config_default "$key")"||continue
["$pinned"="$shipped"]&&continue
["$drifted" -eq 0]&&{
printf'\ndejima ships a different default now for %s:\n'"$path"
}
drifted=$((drifted +1))
section="${key%.*}";leaf="${key##*.}"
printf' [%s] %s: yours stays %s, dejima now ships %s\n'\
"$section""$leaf""$pinned""$shipped"
done < <(_config_defaults_keys "$path")
["$drifted" -eq 0]||{
printf'\nYours are kept, so nothing about your sandbox changed. To take one,\n'
printf'write it in %s under its section - never in the file above.\n'"$(config_path)"
}
return0
}
# "section.key" for each line of a rendered defaults file, in file order.
_config_defaults_keys(){
awk '
/^\[/ { s = $0; gsub(/^\[|\]$/, "", s); next }
/^[a-z_]+ = / && s != "" { split($0, p, " = "); print s "." p[1] }
'"$1"
}
# Write it if it is not there. dejima's own file, so dejima creating it is not
# the kind of write this whole arrangement removes - it is the opposite of
# one. Without it the pin would exist only for operators who ran ./install.sh,
# and dejima runs perfectly well from a checkout without it.
#
# Present is left alone: no rebuild, no prune, no drift check. Those belong to
# ./install.sh, which is when the shipped defaults actually change. Doing them
# per launch would put a standing condition in front of every one.
#
# Prints one line, and only when it creates the file.
config_defaults_ensure(){
local path tmp
path="$(config_defaults_path)"
[ -e "$path"]&&return0
mkdir -p "$(dirname "$path")"||return0
tmp="$(mktemp "$path.XXXXXX")"||return0
if config_defaults_render > "$tmp" 2>/dev/null;then
chmod 600"$tmp"
mv "$tmp""$path"||{ rm -f "$tmp";return 0;}
printf'Pinned dejima'"'"'s current defaults in %s so a later release cannot move them.\n'\
"$path" >&2
else
rm -f "$tmp"
fi
return0
}
# Looks up one [forge.<name>]/[harness.<name>] key's TYPE<TAB>DEFAULT in that
# provider's `keys` manifest entry instead of a row hardcoded in core.
_config_extra_spec(){
@@ -1107,7 +1275,7 @@ identity_validate() {
# "once" that a session could not reach, and a deprecation nobody sees
# until removal is not a deprecation.
if config_is_set core ssh_key;then
printf' %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n'\
printf'\n %s[core] ssh_key is deprecated%s - it is [identity.default] ssh_key\n'\
"${C_WARN:-}""${C_RESET:-}" >&2
printf' under its old name.\n' >&2
if[ -n "$name"];then
@@ -1123,8 +1291,10 @@ identity_validate() {
# force it answers with THAT identity's key - and printing it as the
# thing to move would name a key the operator is not being asked to
# touch.
printf' [identity.default]\n ssh_key = %s\n\n'\
printf' [identity.default]\n ssh_key = %s\n'\
"$(_config_get_from_file core ssh_key)" >&2
# Worth a pause before the build scrolls it away - see bin/dejima.
DEJIMA_STARTUP_NOTICE=1
fi
[ -n "$name"]||return0
@@ -1526,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
@@ -1670,8 +1842,6 @@ If a task genuinely needs something else the sandbox can't provide -
broader network access than a domain or two, host-level or system changes,
GUI/hardware access - stop and explain clearly to the user what you need
and why, instead of trying to work around the firewall or sandbox yourself.
They can continue this exact session outside the container by running on
the host:
BRIEFING_EOF
}
@@ -1834,6 +2004,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
@@ -2025,9 +2205,11 @@ briefing_compose() {
# The harness fragment closes the briefing: it supplies both the resume
# command closing_a has just promised and the sentence about where the
# session history it resumes from actually lives. Both are the harness's
# own facts - Claude Code resumes from a bind-mounted ~/.claude, Cline
# from a per-project state directory named in the command itself - and
# core stating either one meant stating Claude Code's.
# own facts, INCLUDING whether there is anything to say. Claude Code
# resumes from a bind-mounted ~/.claude and says so; Cline cannot be
# resumed outside dejima at all, and a core sentence promising it could
# was Claude Code's fact stated as everyone's - which left a Cline session
# able to send its operator somewhere that does not work.
#
# $requirements and $project_doc (the missing-dependency report and
# .dejima.md) stay hook-computed and hook-appended after this - they read
# 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
exit1
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
exit1
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.
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
exit1
}
# Case-insensitively: forges treat an owner and a repository name as
# case-preserving but not case-sensitive, and a refusal over capitalisation
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
# 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."
return1
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
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'."
return1
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."
return1
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
whileIFS=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."
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."
return1
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."
return1
;;
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"
return1
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."
return1
fi
done
return0
}
_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
@@ -9,80 +9,219 @@ scratch space, and what the sandbox knows about itself.
## Configuration reference
Every key dejima reads, its default, and what it is for. All of it lives in
one file, `~/.config/dejima/config`. `./install.sh` writes a starter copy with
every key present and commented out at its default, so nothing here has to be
set to get a working sandbox: uncomment what you need. `dejima --print-config`
prints the resolved schema with real values, which is the fastest way to see
what a given launch will actually use.
Every key dejima reads, its default, and what it is for.
### Two files, and only one of them is yours
`~/.config/dejima/config` is **yours**. dejima reads it and never writes it.
`./install.sh` creates it if you have none, carrying your selection and
anything it could migrate from an older setup, and nothing else.
`~/.config/dejima/defaults.conf` beside it is **dejima's**, and holds every
defaulted key at the value dejima shipped *when this machine first saw that
key*. Your file overrides it; it overrides what the current release would have
chosen.
That second file is why a release cannot move a setting you never chose.
`[core] harness` has no default at all for this reason - a default that could
change later is a config file that is not the source of truth - and this
applies the same argument to every other key. It is not hypothetical: the
`forge` and `workflow` defaults once changed from `gitea`+`worktree-pr` to
`none`+`local-only`, which silently stopped existing sandboxes pushing.
The first launch on a machine writes it and says so once. `./install.sh`
rebuilds it: keys a release added are appended, keys it retired are dropped,
and **every value already in it stays**. When a shipped default no longer
matches your pinned one, `./install.sh` names both and the line that would
take the new one. It does not take it - that is the whole point - and the line
goes in *your* file, never in `defaults.conf`, which the next rebuild rewrites.
Do not edit `defaults.conf`. Anything in it that dejima no longer understands
is skipped rather than refused, since you are not the one who put it there, and
a launch tells you to re-run `./install.sh`.
`dejima --print-config` prints every key as it resolves. Which of the three
answered is readable from the files themselves: in `config` it is yours, in
`defaults.conf` it is pinned, in neither it is what this release ships.
**`required` in the Default column is what you have to set yourself**, and it
is exactly the set with no line in `defaults.conf`: a key with no default has
nothing to pin. Exactly one is required unconditionally - `[core] harness`.
Every other `required` is conditional on a selection you made, and each table
below says which; a section belonging to something you did not select is never
read, so its required keys are not yours to fill in.
Every tool takes `--help`, which describes its own arguments and what they do.
That is the fastest answer to "what does this flag mean"; this page is for the
config file behind them.
### `[core]` - what this sandbox runs
The three selections come first because everything below depends on them: the
harness decides which `[harness.<name>]` section is read, and the forge decides
which `[forge.<name>]`.
| Key | Default | Purpose |
| --- | --- | --- |
| `[core] harness` | *(none - must be set)* | agent harness the container runs; refused if unset, so a later default cannot change your selection. Also settable per launch with `--harness`, which is how a host keeps one harness as its default and reaches another when it wants to |
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`/`github-pr`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
| `[core] workflow` | `local-only` | workflow `sandbox-context.sh` tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
| `[core] command` | *(the selected harness's own `command`)* | container's main process (e.g. `nvim`), also settable per launch with `--cmd`. Overriding it changes what dejima launches, not which harness is wrapped |
| `[core] identity` | unset | which `[identity.<name>]` section supplies the credentials and git name this session acts under; also settable per launch with `--identity`, which is how one host runs a session per account. Unset selects `[identity.default]` where the config has one. A name with no section is refused rather than falling back |
| `[core] tools` | unset | optional tools to put in the image, colon-separated; also `.dejima/tools` in a project (approved first) and `--include` per launch. None by default. See [docs/tools.md](tools.md) |
| `[core] update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord. The answer is cached in `~/.local/share/dejima/update-check`, which is safe to delete |
| `[ui] banner` | `on` | the three-line summary at the top of a launch - project and branch, the selection, and the switches whose state changes what a session can do. `off` prints nothing. Never drawn when output is redirected |
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
| `[core] share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir. One directory per project underneath it, and only that project's is mounted at `~/shared`. `dejima-share` is the host side of it |
| `[core] ssh_key` | unset | **deprecated** - the pre-identity spelling of `[identity.default] ssh_key`. Still read when the config has no `[identity.default]`; refused when it has one, since the two say the same thing |
| `[core] known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
| `[harness.claude-code] backend` | *(none - must be set)* | which model API this harness talks to, and so which credential the proxy injects. A directory under `backends/`, each shipping its own `[backend.<name>]` keys. Per harness rather than global, because running Claude Code against Anthropic and Cline against something else is the ordinary case. No default, for the reason `[core] harness` has none: one that could change later would move a session to another endpoint without its config changing. Unset, dejima writes `anthropic` into your config once and says so - a migration rather than a default, so nothing it decides later can move you. Also settable per launch with `--backend`. See [providers.md](providers.md#model-backends) |
| `[harness.claude-code] projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
| `[harness.claude-code] file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
| `[harness.cline] backend` | *(none - must be set)* | as above, and the risk is concrete here: Cline supports many backends and has one of its own. Nothing is written for you either, because what this harness talked to before the key existed depends on `base_url`. Also settable per launch with `--backend` |
| `[backend.anthropic] credential_file` | `~/.claude/.credentials.json` | the host file this backend's credential is read from, per request. A path, never a secret, and never mounted into the container |
| `[backend.self-hosted] base_url` | *(none - must be set)* | where you serve the model. A loopback or RFC 1918 address, or a host you have allowlisted - it has to be an endpoint needing no credential, since there is none to withhold. On the backend rather than the harness, so a local endpoint stays configured while a hosted API is selected |
| `[backend.anthropic-api-key] credential_file` | `~/.claude.json` | where your Anthropic API key is. Defaulted to where Claude Code's console login puts it, so the common case needs nothing set; point it at a file of your own if you hold the key yourself. Read host-side per request and never mounted, and the key is stripped from the copy of `~/.claude.json` dejima stages |
| `[backend.anthropic-api-key] credential_field` | `primaryApiKey` | which value inside that file is the key, as dot-separated object names. Clear it (`credential_field =`) when the file is nothing but the key |
| `[backend.<name>] proxy` | *(inherits `[credentials] proxy`)* | whether the credential proxy runs for this backend. Per backend so that one backend the proxy cannot serve is not a reason to stop withholding credentials from the ones it can. Off means this backend's real credential is given to the session, and dejima says so at launch |
| `[backend.openrouter] credential_file` | *(none - must be set)* | your OpenRouter API key, in a file. Read host-side per request and never mounted; the container only ever holds a placeholder. No default, because a key's location is yours and guessing at one would point dejima at a file it has no reason to think exists |
| `[harness.cline] model` | *(none - must be set)* | model id the endpoint serves; no default, because any shipped one goes stale with the next Cline release. Also settable per launch with `--model` |
| `[harness.cline] context_window` | unset | tokens Cline may use before compacting. Needed for a model it has no catalogue entry for, which is every self-served one. Also settable per launch with `--context-window` |
| `[harness.cline] state_dir` | `~/.local/share/dejima/cline` | per-project Cline state; one directory per workspace |
| `[forge.gitea] ssh_host` | unset | Gitea/Forgejo SSH hostname: skip if you only push to GitHub |
| `[forge.gitea] api` | unset | `https://<gitea host>/api/v1`: skip if you only push to GitHub |
| `[forge.gitea] token_file` | `~/.config/gitea/token` | path to the Gitea/Forgejo API token (the file, not the token itself) |
| `[forge.github] push_token_file` | unset | path to a **write-capable** PAT, used only when `[core] forge = github`: deliberately separate from `[github] token_file` below; see "GitHub push" in docs/security.md |
| `[github] token_file` | `~/.config/gh/sandbox-token` | path to a read-only PAT for private GitHub repos / `gh` |
| `[mounts] readonly` | unset | extra host paths (colon-separated) a personal config needs, mounted read-only at their own path |
| `[mounts] read_write` | unset | host paths (colon-separated) mounted **read-write** at their own path: for directories meant to outlive the session; refused if they overlap anything dejima needs out of a session's reach |
| `[mounts] nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
| `[credentials] proxy` | `on` | host-side credential proxy; `off` mounts the real OAuth credential into the container directly |
| `[credentials] transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit `ANTHROPIC_BASE_URL`, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](security.md)) |
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint; `--no-log` clears it for one launch, which silences everything downstream of it |
| `[epitaph] enabled` | `on` | keep the summary a harness writes when a session compacts; `--no-epitaph` skips it for one launch |
| `[epitaph] dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
| `[epitaph] loki` | `off` | also push each epitaph to `[logging] loki_url`, host-side |
| `[logging] conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in docs/security.md. `--no-conversation` turns it off for one launch, and `--no-log` implies it |
| `[audit] dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
| `[audit] baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
| `[audit] keep` | `10` | how many past findings/baselines to retain |
| `[audit] diffs` | `on` | keep the diff of what changed alongside each finding; `off` records that something changed without retaining the content |
| `[audit] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings; also settable per launch with `--strict-audit` |
| `[remote-access] enabled` | `off` | the switch: nothing in "Remote access" below runs until this is `on` |
| `[remote-access] ttyd_tmux_prefix` | unset | the tmux prefix the page's buttons send; the page assumes stock `C-b` without it |
| `[remote-access] ttyd_focus_on_tap` | unset | `on` lets a tap on the terminal raise the phone keyboard; unset means only the KBD button does |
| `[remote-access] ttyd_button_colors` | unset | `off` returns the toolbar to monochrome |
| `[remote-access] debounce` | `60` | seconds between repeat notifications for the same session |
| `harness` | **required** | agent harness the container runs; refused if unset, so a later default cannot change your selection. Also settable per launch with `--harness`, which is how a host keeps one harness as its default and reaches another when it wants to |
| `forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`/`github-pr`, opens `ssh.github.com:22` for push), or `none` (no forge at all) |
| `workflow` | `local-only` | workflow the session briefing tells the agent to follow: `worktree-pr` (push + open a PR), `branch-push` (push, stop at the branch), or `local-only` (commit locally, push nothing) |
| `identity` | unset | which `[identity.<name>]` section supplies the credentials and git name this session acts under; also settable per launch with `--identity`, which is how one host runs a session per account. Unset selects `[identity.default]` where the config has one. A name with no section is refused rather than falling back |
| `command` | *(the selected harness's own)* | container's main process (e.g. `nvim`), also settable per launch with `--cmd`. Overriding it changes what dejima launches, not which harness is wrapped |
| `tools` | unset | optional tools to put in the image, colon-separated; also `.dejima/tools` in a project (approved first) and `--include` per launch. None by default. See [docs/tools.md](tools.md) |
| `firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
| `internal_domains` | unset | approved domains that really do resolve inside your own network, colon-separated. The policy refuses an answer for a permitted name that points at a private address, because an answer decides what the allowlist contains and comes from outside; this exempts the ones you know are internal. It exempts only - a name here is not reachable unless it is also approved, and naming one that is not stops the launch saying so. The configured forge, the Loki host and a self-served backend's `base_url` host are permitted and exempt already, so this is for destinations dejima does not configure itself. `dejima-allow` reads this same key when adding a domain to a running session |
| `share_dir` | `~/.local/share/dejima/shared` | host-visible read/write scratch dir. One directory per project underneath it, and only that project's is mounted at `~/shared`. `dejima-share` is the host side of it |
| `known_hosts` | `~/.ssh/known_hosts` | known-hosts file used for git-over-ssh |
| `update_check` | `on` | at startup, say when this checkout is behind the branch it tracks. One `ls-remote` to your own remote, at most hourly, never fetching; `off` makes dejima contact nothing of its own accord. The answer is cached in `~/.local/share/dejima/update-check`, which is safe to delete |
| `ssh_key` | unset | **deprecated** - the pre-identity spelling of `[identity.default] ssh_key`. Still read when the config has no `[identity.default]`; refused when it has one, since the two say the same thing |
**No forge is the default**: `[core] forge = none` with
`[core] workflow = local-only`, so the sandbox works locally and pushes
nowhere. Every other workflow needs forge credentials, which is what you are
opting into when you pick one.
### `[harness.<name>]` - the harness you selected
One section per harness, and only the one `[core] harness` names is read. Each
ships its own keys; see [providers.md](providers.md) for the manifest contract
behind them.
#### `[harness.claude-code]`
| Key | Default | Purpose |
| --- | --- | --- |
| `backend` | **required** | which model API this harness talks to, and so which credential the proxy injects. A directory under `backends/`, each shipping its own `[backend.<name>]` keys. Per harness rather than global, because running Claude Code against Anthropic and Cline against something else is the ordinary case. No default, for the reason `[core] harness` has none: one that could change later would move a session to another endpoint without its config changing. Unset, dejima writes `anthropic` into your config once and says so - a migration rather than a default, so nothing it decides later can move you. Also settable per launch with `--backend`. See [providers.md](providers.md#model-backends) |
| `projects_dir` | `~/.claude/projects` | where Claude Code session transcripts live |
| `file_history_dir` | `~/.claude/file-history` | Claude Code's edit-undo record, mounted per session rather than whole |
| `backend` | **required** | as above, and the risk is concrete here: Cline supports many backends and has one of its own. Nothing is written into your config for you either, unlike Claude Code: there is no one endpoint every existing Cline session was already using, so any value dejima recorded would be a guess. Also settable per launch with `--backend` |
| `model` | **required** | model id the endpoint serves, exactly as that endpoint spells it - a hosted catalogue namespaces by vendor (`z-ai/glm-5.3`) and may qualify a variant (`deepseek/deepseek-r1:free`). No default, because any shipped one goes stale with the next Cline release. Also settable per launch with `--model` |
| `context_window` | unset | tokens Cline may use before compacting. Needed for a model it has no catalogue entry for, which is every self-served one. Also settable per launch with `--context-window` |
| `state_dir` | `~/.local/share/dejima/cline` | per-project Cline state; one directory per workspace |
### `[backend.<name>]` - the model API you selected
One section per backend, and only the one your harness's `backend` names is
read. `proxy` is available in every one of them; the rest are that backend's
own. See [providers.md](providers.md#model-backends) for how much use each has
actually had.
| Key | Default | Purpose |
| --- | --- | --- |
| `[backend.<name>] proxy` | *(inherits `[credentials] proxy`)* | whether the credential proxy runs for this backend. Per backend so that one backend the proxy cannot serve is not a reason to stop withholding credentials from the ones it can. Off means this backend's real credential is given to the session, and dejima says so at launch |
| `[backend.anthropic] credential_file` | `~/.claude/.credentials.json` | the host file this backend's credential is read from, per request. A path, never a secret, and never mounted into the container |
| `[backend.anthropic-api-key] credential_file` | `~/.claude.json` | where your Anthropic API key is. Defaulted to where Claude Code's console login puts it, so the common case needs nothing set; point it at a file of your own if you hold the key yourself. Read host-side per request and never mounted, and the key is stripped from the copy of `~/.claude.json` dejima stages |
| `[backend.anthropic-api-key] credential_field` | `primaryApiKey` | which value inside that file is the key, as dot-separated object names. Clear it (`credential_field =`) when the file is nothing but the key |
| `[backend.openrouter] credential_file` | **required** | your OpenRouter API key, in a file. Read host-side per request and never mounted; the container only ever holds a placeholder. No default, because a key's location is yours and guessing at one would point dejima at a file it has no reason to think exists |
| `[backend.self-hosted] base_url` | **required** | where you serve the model. A loopback or RFC 1918 address, or a host you have allowlisted - it has to be an endpoint needing no credential, since there is none to withhold. On the backend rather than the harness, so a local endpoint stays configured while a hosted API is selected |
### `[credentials]` - how the credential is withheld
| Key | Default | Purpose |
| --- | --- | --- |
| `proxy` | `on` | host-side credential proxy; `off` mounts the real credential into the container directly. Overridable per backend with `[backend.<name>] proxy` |
| `transport` | `tls` | set to `loopback` to fall back to a plaintext forwarder with an explicit base URL, keeping the credential out of the container but reinstating the base-URL fingerprint: for when certificate pinning breaks the default TLS path (see [security notes](security.md)) |
### `[identity.<name>]` - who the session acts as
Groups the SSH key, the git name and the forge credential belonging to one
account, so a host can run a session per account. Selected by `[core]
identity`, or `[identity.default]` when that is unset. Nothing here is
required: an identity supplies what it names and no more.
| Key | Default | Purpose |
| --- | --- | --- |
| `ssh_key` | unset | the SSH key this identity pushes with |
| `user_name` | unset | `user.name` for commits made in the session |
| `user_email` | unset | `user.email` for commits made in the session |
| `token_file` | *(the forge's own default)* | this identity's Gitea/Forgejo API token, overriding `[forge.gitea] token_file`. Read only when `[core] forge = gitea` |
| `push_token_file` | unset | this identity's write-capable GitHub PAT, overriding `[forge.github] push_token_file`. Read only when `[core] forge = github` |
See [Identities](#identities) below for the worked examples.
### `[forge.<name>]` and `[github]` - forge credentials
Only the section `[core] forge` names is read. `[github]` is separate from
`[forge.github]` on purpose: the first is read access to GitHub whichever forge
you selected, the second is push access when GitHub *is* your forge.
| `[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`, 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 and be shared by every project; refused if they overlap anything dejima needs out of a session's reach |
| `read_write_per_project` | unset | the same, mounted at the path named but backed by `<path>/projects/<slug>` - one directory per project rather than one for the host. For anything a session writes to be read later as background, where another project reading it would be a channel rather than a convenience |
| `nvim_config` | unset | nvim config dir, used when `[core] command = nvim` |
### `[audit]` - the exit-time audit
| Key | Default | Purpose |
| --- | --- | --- |
| `dir` | `~/.local/share/dejima/audit` | where exit-time audit findings are stored |
| `baselines_dir` | `~/.local/share/dejima/baselines` | where audit baselines are stored |
| `keep` | `10` | how many past findings/baselines to retain |
| `diffs` | `on` | keep the diff of what changed alongside each finding; `off` records that something changed without retaining the content |
| `strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings; also settable per launch with `--strict-audit` |
### `[epitaph]` - what a compacted session leaves behind
| Key | Default | Purpose |
| --- | --- | --- |
| `enabled` | `on` | keep the summary a harness writes when a session compacts; `--no-epitaph` skips it for one launch |
| `dir` | `~/.local/share/dejima/epitaphs` | where they are kept; never mounted into a container |
| `loki` | `off` | also push each epitaph to `[logging] loki_url`, host-side |
### `[logging]` - shipping logs off the host
Off entirely until `loki_url` is set. Nothing leaves the machine otherwise,
and there is no endpoint of dejima's own anywhere in this.
| Key | Default | Purpose |
| --- | --- | --- |
| `loki_url` | unset | ship DNS/firewall logs to a Loki endpoint; `--no-log` clears it for one launch, which silences everything downstream of it |
| `conversation` | `off` | opt-in: also ship session transcripts to Loki (needs `loki_url`); see "Conversation logging (opt-in)" in [docs/security.md](security.md). `--no-conversation` turns it off for one launch, and `--no-log` implies it |
### `[remote-access]` - the web front door
Off entirely until `enabled` is `on`. The `ttyd_*` keys configure the terminal
page and are read once at process start, so editing them needs
`systemctl --user restart dejima-ttyd.service`. See
[docs/remote-access.md](remote-access.md).
| Key | Default | Purpose |
| --- | --- | --- |
| `enabled` | `off` | the switch: nothing else here runs until this is `on` |
| `ntfy_url` | **required** for notifications | ntfy topic URL for session notifications. `dejima-notify-watch` refuses to start without it; nothing else reads it |
| `base_url` | unset | public URL of the web front door, for the notification's deep link. Absent, pushes still go out - just with no link on them |
| `debounce` | `60` | seconds between repeat notifications for the same session |
| `ttyd_port` | `7681` | loopback port `ttyd` listens on; the reverse proxy has to `proxy_pass` to the same one |
| `ttyd_font_size` | unset | terminal font size in px, 8-72. Applies to every client, phone and desktop alike |
| `ttyd_tmux_prefix` | unset | the tmux prefix the page's buttons send; the page assumes stock `C-b` without it |
| `ttyd_focus_on_tap` | unset | `on` lets a tap on the terminal raise the phone keyboard; unset means only the KBD button does |
| `ttyd_button_colors` | unset | `off` returns the toolbar to monochrome |
### `[ui]` - what a launch prints
| Key | Default | Purpose |
| --- | --- | --- |
| `banner` | `on` | the three-line summary at the top of a launch - project and branch, the selection, and the switches whose state changes what a session can do. `off` prints nothing. Never drawn when output is redirected |
## Global and per-project domains and dependencies
Allowed domains and dependencies each stack through their own layers. The
@@ -104,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
@@ -340,7 +496,9 @@ you don't have one.
### Cline
Almost nothing carries over, and that is the design rather than a gap.
Your own configuration carries over the same way Claude Code's does - the
table under [What comes in from your host](#what-comes-in-from-your-host) is
the list. Your *sessions* do not, and that is the design rather than a gap.
Cline keeps one `sessions.db` indexing every session it has ever run, with
the project a session belonged to as a field inside the record rather than a
@@ -351,21 +509,33 @@ under `[harness.cline] state_dir` (default `~/.local/share/dejima/cline`),
mounted at `~/.cline/data` in the container. Sessions persist across runs of
the same project and are invisible to every other project.
Because that directory is dejima's rather than yours, resuming on the host
needs `CLINE_DATA_DIR` set to it. The exact command is at the end of every
session's briefing.
Because that directory is dejima's rather than yours, resuming on the host is
not a supported path - see
[Sessions, and where they live](#sessions-and-where-they-live) below. Relaunch
dejima on the same project instead, which is what the briefing tells the
session to offer.
The model configuration is written fresh by dejima at each launch and is
not yours either. Two shapes are supported:
The model configuration is written fresh by dejima at each launch and is not
yours either. Where the model is comes from `[harness.cline] backend` and
nowhere else, so there is no second setting for it to disagree with, and two
shapes are supported:
- **`base_url` unset** - Anthropic, reached through dejima's own credential
proxy exactly as Claude Code is. What is written into the container is the
placeholder `sandbox-credential-proxy-placeholder`; the real credential
stays on the host.
- **`base_url` set to a model you serve yourself** - either a loopback or
RFC 1918 address, or a host you have allowlisted with `dejima-allow`.
- **A backend the proxy serves** (`anthropic`, `anthropic-api-key`,
`openrouter`) - reached through dejima's own credential proxy exactly as
Claude Code is. What is written into the container is the placeholder
`sandbox-credential-proxy-placeholder` and the endpoint's real hostname; the
credential stays on the host. Which of Cline's own provider types dejima
writes comes from the backend's `api_style`: its native Anthropic client for
one, its OpenAI-compatible client for the other.
- **A backend with no credential** (`self-hosted`, carrying its own
`[backend.self-hosted] base_url`) - a model you serve, at either a loopback
or RFC 1918 address or a host you have allowlisted with `dejima-allow`.
Nothing needs a credential, so there is nothing to withhold.
Keeping the address on the backend rather than on the harness is what lets you
have a local model and a hosted API both configured and switch between them by
changing one word.
A name qualifies through the allowlist rather than through how it is spelled,
because that is the same store deciding whether the container can reach the
endpoint at all: one that fails the check is one the session could not have
@@ -373,13 +543,13 @@ connected to anyway. A model server on your own network normally has a name,
so requiring a literal address would have made this path unusable for the
ordinary case.
Anything else is refused at launch - a hosted backend's API key would have to
go inside the container, and dejima has no proxy that could hold one back on
its behalf. Note what the check is *not* doing: dejima writes a placeholder
into the container on this path whatever the endpoint is, so no real
credential enters it either way. What the check decides is whether the
configuration can work at all, which is worth saying at launch rather than as
an auth error three layers down. See
Anything else is refused at launch - a hosted API pointed at through a
no-credential backend would want its key inside the container, and dejima has
no proxy that could hold one back on its behalf. Note what the check is *not*
doing: dejima writes a placeholder into the container on the proxied path
whatever the endpoint is, so no real credential enters it either way. What the
check decides is whether the configuration can work at all, which is worth
saying at launch rather than as an auth error three layers down. See
| `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/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/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 |
@@ -64,8 +67,10 @@ top-level entries like `statusline.sh`) to resolve, and runs
`docker compose run`. `bin/dejima-allow`
adds a domain to a running session's firewall from the host - see
[Adding a domain to a running session](security.md#adding-a-domain-to-a-running-session).
`install.sh`installs the hooks into
`~/.claude/hooks` and registers them in `~/.claude/settings.json`.
`install.sh`writes nothing to `~/.claude`. Every hook dejima uses is baked
into the image at `/usr/local/lib/dejima/hooks` and registered per launch into
the `--settings` overlay, which Claude Code merges with your
`~/.claude/settings.json` rather than replacing it.
## Tests
@@ -74,18 +79,21 @@ the security invariants the README states, not a substitute for reading it,
but a way to actually catch a future change silently breaking one of them
instead of finding out the hard way.
`static.bats` greps the repo's own files - no sudo in the `Dockerfile`, every
`ipset add` is duplicate-safe, no `docker.sock`/host-credential mounts, the
`~/.claude` mount inventory is exactly what it should be, `dejima-allow`
rejects a malicious domain argument. `runtime.bats` builds the image, boots
a real container running the actual `entrypoint.sh` (a `SANDBOX_TEST_MODE`
env var makes it stop just short of execing the session's command, so the suite can attach
to a fully-initialized container instead of reimplementing its own copy of
the setup sequence), and asserts against it: IPv6 blocked, DNS/IP allowlist
enforcement in both directions, GitHub read-only, no working `sudo`/
`iptables`/`init-firewall.sh` as `node`, capabilities actually stripped
(not just absent from a `docker exec` that doesn't have them to begin
with), `ulogd2` unprivileged, and the dynamic-ipset CDN-rotation fix.
`static.bats` greps the repo's own files - no sudo in the `Dockerfile`, no
`docker.sock` or host-credential mounts, the`~/.claude` mount inventory is
exactly what it should be, `dejima-allow` rejects a malicious domain argument.
`runtime.bats` builds the image, boots a real container running the actual
`entrypoint.sh` (a `SANDBOX_TEST_MODE` env var makes it stop just short of
execing the session's command, so the suite can attach to a fully-initialized
container instead of reimplementing its own copy of the setup sequence), and
asserts against it: IPv6 refused outright with no permit path in the chain and
no AAAA from the resolver, name and address enforcement in both directions,
inbound and
forwarded traffic dropped, GitHub reachable on 443 and not on 22, no working
`sudo` and no way to change the packet filter as `node`, capabilities actually
stripped (not just absent from a `docker exec` that doesn't have them to begin
with), both log producers unprivileged, and the address-set CDN-rotation fix.
`project.bats`, `remote.bats` and `audit.bats` need no Docker either.
`project.bats` covers the
@@ -98,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
@@ -170,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.
`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
@@ -191,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
@@ -201,10 +214,12 @@ it has to argue about.
### What a harness must be able to do
Support is per harness and deliberate. A supported harness gets the same
depth of integration Claude Code has - the same mounts, the same guards, the
same audit surfaces - so a harness is not a manifest someone drops in. That
is only possible if the harness can host what those protections need.
Support is per harness and deliberate. A supported harness gets everything
dejima has to give: its state scoped per project, its config carried in
read-only, its audit surfaces watched, and its guards installed wherever it
can host one. So a harness is not a manifest someone drops in - and where it
cannot host a protection, the launch says so rather than leaving the gap
unnamed.
These are the requirements. A harness that fails one of the first four cannot
be supported, and that is a decision about the harness rather than a gap in
@@ -235,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
@@ -315,7 +332,7 @@ or better is the requirement.
| **R3** | **Yes, by running it, and the documentation is wrong about how.** A rules file at `~/Documents/Cline/Rules/00-dejima.md` reached the model: asked for a codeword only that file contained, the session answered with it. `~/.agents/AGENTS.md` works too. `~/Cline/Rules`, which the documentation names, is **not** read on Linux, and `CLINE_RULES` pointing at a file did nothing. So the briefing is a file rather than a hook, which is what `briefing_path` in the manifest is for. |
| **Network reach** | **In the container, unlike Claude Code's.** Not a requirement, but the thing [security.md](security.md#the-firewall-bounds-the-container-not-the-harness) says has to be established per harness rather than assumed. Asked to fetch a page the allowlist does not carry, Cline's web-fetch tool ran and returned "Unable to connect" - the same answer `curl` gives. So the egress allowlist bounds what reaches a Cline session, which is not true of Claude Code's `WebSearch`. |
| **R4** | **Yes, by running it, and this is where Cline is strongest.**`--data-dir` relocates the whole store - `db/`, `settings/`, `logs/`, `cache/`. A full run against a chosen directory put everything there and left nothing in the default location. There are per-path environment variables too: `CLINE_DATA_DIR`, `CLINE_DB_DATA_DIR`, `CLINE_SESSION_DATA_DIR`, `CLINE_TEAM_DATA_DIR`, `CLINE_PROVIDER_SETTINGS_PATH`, `CLINE_HOOKS_LOG_PATH`. Per-project scoping is a flag rather than an inference. |
| **R5** | **Yes, and confirmed end to end.** Credentials are a plain file, `settings/providers.json`, whose path `CLINE_PROVIDER_SETTINGS_PATH` relocates - so dejima can decline to mount the operator's. The substitute is the one that already exists: Cline configured for `anthropic` with the placeholder key `sandbox-credential-proxy-placeholder`, pointed at the real hostname, ran a real completion through dejima's in-container terminator and host-side proxy. No base-URL override and no new proxy work - the same arrangement as Claude Code's faked `.credentials.json`, and the session is indistinguishable from a stock install. |
| **R5** | **Yes, and confirmed end to end.** Credentials are a plain file, `settings/providers.json`, whose path `CLINE_PROVIDER_SETTINGS_PATH` relocates - so dejima can decline to mount the operator's. The substitute is the one that already exists: Cline configured for `anthropic` with the placeholder key `sandbox-credential-proxy-placeholder`, pointed at the real hostname, ran a real completion through dejima's in-container terminator and host-side proxy. No base-URL override and no new proxy work - the same arrangement as Claude Code's faked `.credentials.json`, and the session is indistinguishable from a stock install.**What that completion did not establish is which models the subscription credential will serve a client that is not Claude Code**: Anthropic gates that per model and answers a refused one with a 429, so the arrangement is confirmed and the entitlement is not dejima's to give. See [providers.md](providers.md#a-subscription-credential-is-claude-codes-and-anthropic-enforces-that). |
Two notes for whoever writes the integration. Cline's plugin manifest schema
still carries a field described as "not read by Claude Code", so its plugin
@@ -354,20 +371,26 @@ that command lives in the harness's briefing fragment rather than in
`resume_command`: briefing substitution is one pass, so a placeholder inside
a substituted value is never resolved.
*Provider-agnostic is not the same as endpoint-agnostic.* Cline can be pointed
anywhere; dejima cannot follow it everywhere. With `base_url` unset it runs
against Anthropic through dejima's own credential proxy and the key never
enters the container. Pointed at someone else's hosted API there is no proxy
that could hold a key back, so the key would have to go in - which is R5
failing, not a network restriction. The plugin therefore accepts `base_url`
only for a model the operator serves themselves - a loopback or RFC 1918
address, or a host they have allowlisted - where an open-weight model needs no
credential at all and R5 has nothing to protect. The allowlist is what
qualifies a name, because it is the same store deciding what the container can
reach: an endpoint failing the check is one the session could not have
connected to anyway, and a model server on someone's own network normally has
a name rather than a literal address. A provider-generic proxy would widen
this to hosted providers; nobody has written one (#171).
*Backend-agnostic is not the same as endpoint-agnostic.* Cline can be pointed
anywhere; dejima cannot follow it everywhere. A backend the proxy serves is
fine whatever its dialect - the proxy takes the credential's kind and header
names as arguments, so `openrouter` works the way `anthropic` does and the key
never enters the container either way. Whether the ENDPOINT then serves the
request is a separate question dejima does not answer: a Claude subscription
credential is served to Claude Code and refused to other clients for the
larger models, which is Anthropic's call and not one dejima works around
(see [providers.md](providers.md#a-subscription-credential-is-claude-codes-and-anthropic-enforces-that)). What dejima cannot followis a hosted
API reached *without* a backend: there is no proxy holding a key back on its
behalf, so the key would have to go in, which is R5 failing rather than a
network restriction.
So the `credential = none` path is only accepted for a model the operator
serves themselves - a loopback or RFC 1918 address, or a host they have
allowlisted - where the endpoint needs no credential at all and R5 has nothing
to protect. The allowlist is what qualifies a name, because it is the same
store deciding what the container can reach: an endpoint failing the check is
one the session could not have connected to anyway, and a model server on
someone's own network normally has a name rather than a literal address.
*Conversation logging works; epitaphs do not, and only one of those is
Cline's fault.* Cline's transcripts are
@@ -499,9 +522,9 @@ harness-specific path resolving to nothing is the honest answer.
| Seeded files | that a harness reading a credential from a fixed path needs something at it, since an absent file and an empty one are not reliably the same to it. The manifest's `image_files`, created empty and 0600 in that harness's layer, where a mounted credential shadows it - rather than by `entrypoint.sh`, which runs in every image including the base one alone | `harnesses/<name>/harness.conf` |
| Image environment | anything the harness wants set for every session of its own - for Claude Code, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`. The manifest's `image_env`, in the same layer | `harnesses/<name>/harness.conf` |
| Command | there is no default anywhere shared. `SANDBOX_CMD` comes from the manifest's `command` via `bin/dejima`, and `entrypoint.sh` refuses an empty one rather than naming a harness a given image may not contain | `harnesses/<name>/harness.conf`, `docker/entrypoint.sh` |
| Proxied endpoint | which name the in-container TLS terminator answers for, and what `/etc/hosts` pins to loopback. The manifest's `proxied_api_domain`; a credential proxy with no domain declared is refused rather than started answering for nothing | `harnesses/<name>/harness.conf`, `docker/entrypoint.sh` |
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it. `api_domains`and`proxied_api_domain`in the manifest | `harnesses/<name>/harness.conf` |
| Credentials | the whole proxy exists because Claude Code authenticates from `~/.claude/.credentials.json` rather than an env var; the entrypoint seeds a well-formed fake so it has something to read | `bin/dejima-credential-proxy`, `docker/credential-proxy-forwarder.js`, `docker/entrypoint.sh` |
| Proxied endpoint | which name the in-container TLS terminator answers for, and what `/etc/hosts` pins to loopback. The BACKEND's `proxied_domain`, not the harness's: a harness reaches whichever endpoint it is configured for, so the name belongs to the endpoint. A proxied backend with no domain declared is refused rather than started answering for nothing | `backends/<name>/backend.conf`, `docker/entrypoint.sh` |
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it. `api_domains`on the harness,`proxied_domain`on the backend | `harnesses/<name>/harness.conf`, `backends/<name>/backend.conf` |
| Credentials | which substitute the harness accepts, and where it reads it from: a well-formed fake `~/.claude/.credentials.json` the entrypoint seeds for Claude Code, a placeholder key in `settings/providers.json` for Cline, a placeholder in an environment variable on the API-key path. The proxy itself is neither harness's - what the credential is, which header carries it and whether it refreshes are the backend's, passed as arguments | `bin/dejima-credential-proxy`, `docker/credential-proxy-forwarder.js`, `docker/entrypoint.sh` |
| Hook protocol | the event payload arrives on stdin; `SessionStart` returns `hookSpecificOutput.hookEventName`/`additionalContext` as JSON, while a `PreToolUse` hook blocks by exiting 2 with its reason on stderr | `hooks/*.sh` |
| Hook registration | four hooks written into `~/.claude/settings.json` in Claude Code's schema, one `SessionStart` with a `startup\|resume\|clear\|compact` matcher and three `PreToolUse` matching `Bash`. In the plugin, gated per declared guard | `bin/harness/claude-code.sh` |
| Briefing delivery | **by which of three routes**, not whether. Claude Code takes the briefing as a system prompt, through the flag its manifest names in `briefing_args`, because a `SessionStart` payload past roughly nine kilobytes is persisted and replaced with a preview - so the hook route silently under-delivered a briefing carrying a project's `.dejima.md`. Cline takes no such flag and registers no hook, so it declares a `briefing_path` and reads a file `entrypoint.sh` writes root-owned and read-only. A harness doing neither still gets it through the hook. What the briefing *says* is none of theirs: composing it host-side is core's, and the container-side part - this container's id, the declared-versus-installed gap, the project's `.dejima.md` - is one script every route calls | `hooks/dejima-briefing-render.sh`, `hooks/sandbox-context.sh`, `docker/entrypoint.sh` |
@@ -551,13 +574,20 @@ because absent is visible to the operator and broken is not.
`claude-code` is no exception to that: it is declared by its own manifest and
built into its own layer like any other. The base image carries nothing of any
harness's - not its package, not the directories it keeps state in, not its
environment, and not its name as a default command. What is left on the
harness axis is the credential proxy, which is Claude Code's shape throughout
and is the R5 row above.
environment, and not its name as a default command.
Nor is the credential proxy harness-shaped. Which header carries the
credential, what shape it takes, what must not travel beside it and whether it
can be refreshed are all arguments the selected backend supplies, so a second
harness reaches a proxied backend without a branch of its own - the R5 row
above. Cline has run real completions through the same terminator
and the same host-side proxy, against `anthropic` and against `openrouter`,
which is a different api_style through the same code.
`providers.bats` covers the manifest contract itself and is where a new
`harness.conf` gets its parsing and validation tests. The suites that assert
on Claude Code paths by name - `config.bats`, `audit.bats`, `static.bats`,
`runtime.bats` - are the ones that will need a second set, and a green run
against the existing harness proves nothing about a new one. Nothing in
`test/` currently runs a second harness end to end.
`harness.conf` gets its parsing and validation tests. `config.bats`,
`static.bats`, `project.bats` and `conversation.bats` each carry a second
harness's cases beside Claude Code's, which is the shape a third one should
follow. What is still missing is the end of the ladder: `runtime.bats` boots
only `claude-code`, so nothing in `test/` runs a second harness end to end in
a real container, and a green confined run says nothing about that.
@@ -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,14 +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. |
| `proxied_api_domain` | `?hostname` | (empty) | The model API endpoint the credential proxy stands in front of. Allowlisted **only when the proxy is off**: with it on, the session reaches the API through the forwarder, so leaving the name unresolvable defeats a bypass that looks it up by DNS. Which name it is comes from here; when to allowlist it is dejima's rule. |
| `api_domains` | `?list:hostname` | (empty) | Hostnames this harness's own machinery must reach - its update host, its telemetry endpoint. They join the built-in part of the permitted set rather than the operator's, and are not exempted from the check that refuses an answer pointing inside a private network - a harness's endpoints are public names, and exempting a public name is what turns one influenced answer into egress to an internal host. |
| `api_styles` | `?list:identifier` | (empty) | Which backend `api_style`s this harness has a client for. `claude-code` speaks `anthropic`; `cline` speaks both, which is what makes it the backend-agnostic one. Empty means unchecked. |
| `guards` | `?list:identifier` | (empty) | Which of dejima's guard rails this harness can host: `bash-deny`, `push-guard`, `merge-guard`, `notify`. A name outside that set is refused rather than treated as an unknown guard, since a typo would otherwise turn a guard off silently. The ones a harness does not declare are named at launch - see "What a harness must be able to do" in [development.md](development.md). |
| `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 registers a hook instead, which is how Claude Code gets it. |
| `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. |
| `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` | `?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. |
@@ -248,7 +248,7 @@ Worked example, `harnesses/claude-code/harness.conf`:
@@ -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
@@ -291,29 +290,31 @@ can make; it is one the audit reports. See
- Running the harness with its permission prompts off removes your chance to
review tool calls before they run. Only point this at trusted repositories.
- The mounted project itself has **no write protection**. `deny-dangerous-
bash.sh`, `git-push-guard.sh` and `merge-guard.sh` are regex
pattern-matching on the literal Bash command string - a fast-fail guard
against an overeager agent doing something obviously destructive, not a
boundary against one that's actively trying to get around them (a
different scripting language, an encoded command, editing `.git/config`
directly all sail right through).
Nothing here stops a compromised or badly-instructed session from
modifying or deleting thereal working copy it's mounted against.
- The mounted project itself has **no write protection**. The guards are
fast-fail checks against an overeager agent doing something obviously
destructive, not a boundary against one actively trying to get around them.
`deny-dangerous-bash.sh` is regex pattern-matching on the literal Bash
command string, so a different scripting language, an encoded command or
editing `.git/config` directly all sail right through. The push and merge
guards shadow `git` and the forge CLI on `PATH` and read argv, so quoting
and pipelines stop mattering - but a session that means to get round one can
call `/usr/bin/git` directly. Nothing here stops a compromised or
badly-instructed session from modifying or deleting the real working copy
it's mounted against.
- That same read-write access to the mounted project is also a
host-code-execution vector, not just a data-destruction one: a
compromised session could plant `.git/hooks/post-commit` (or
`pre-push`), set `core.hooksPath`, or write a
`.claude/settings.local.json` with a malicious hook command, into the
project. Any of those run unsandboxed on the host the next time you run
`git` or open Claude Code in that repo - no network egress required, so
`git` or open your harness in that repo - no network egress required, so
the firewall doesn't see it either. Same underlying persistence pattern
as the `~/.claude/hooks` and `~/.claude.json` risks above, just through
as the harness config risks above, just through
the project mount instead of the config mount. "Only point this at
trusted repositories" (above) covers input trust; this is about the
sandbox writing a persistence payload into an otherwise-trusted repo.
The workspace's own `.git/hooks` directory is mounted read-only
(same pattern as the `~/.claude` code directories below), so planting a hook
(same pattern as the harness's own code directories below), so planting a hook
file directly is closed - accepted breakage: installing hooks
*in-session* (`husky`, `pre-commit install`) fails, which is the
point. `core.hooksPath` and `.claude/settings.local.json` aren't
@@ -498,6 +499,16 @@ approximation of its name.
## One project's session cannot reach another's
The property is the same for every harness; the mechanism is not, because it
depends on how the harness keys its own state. Claude Code stores a directory
per project and dejima mounts a slice of it. Cline stores one flat database
indexing every session it has ever run, with the project as a field inside the
record, so there is no slice to take - dejima gives each workspace a data
directory of its own instead. Both reach the same place: a session sees its
own project's history and no other's.
### Claude Code: a slice of your own tree
- `~/.claude/projects` (session transcripts and per-project memory) is
**not** mounted as a tree. A session gets the workspace's own slug
directory plus one for each live git worktree nested under it,
@@ -551,6 +562,31 @@ approximation of its name.
That costs undo history for that session if it is later resumed on the
host, and it is the reason this cannot be scoped as completely as
`projects/`, where the workspace's own slug is known at launch.
### Cline: a directory dejima owns, one per workspace
- `~/.cline/data` is **not** mounted. Cline indexes every session it has ever
run in one `sessions.db`, with the project a session belonged to as a field
inside the record rather than a directory above it, so there is no
per-project subtree to slice: mounting your data directory would put every
project's transcripts in every session, which is this section's whole
subject.
A launch instead creates `<[harness.cline] state_dir>/<workspace slug>` on
the host and mounts *that* at `~/.cline/data`. Every session under it belongs
to this project by construction rather than by a filter that could be wrong,
which is why Cline clears this requirement more cleanly than Claude Code
rather than less.
The cost is that the directory is dejima's rather than yours: a session
started inside the sandbox is not offered back to a host-side Cline, and one
you started on the host is not visible inside. There is no import or export
([#301](https://gitbox.branderson.io/dejima/dejima/issues/301)), and the
briefing tells the session so, so it will not offer you a resume command that
cannot work.
### Whichever harness is selected
- Epitaphs - the summary a session leaves when its context is compacted away -
are kept host-side in `[epitaph] dir`, and that directory is never mounted. A session can neither read what earlier
sessions concluded nor edit what this one is about to record, which is the
@@ -589,7 +625,7 @@ it.
| **Proxied, refreshing** (`credential = anthropic-oauth`) | A fake credential file whose secrets are placeholders. The real one is read host-side per request. | The placeholder. | Use the API as you, through the proxy, while the session runs. |
| **Proxied, static key** (`credential = static`) | A placeholder key in the harness's own model configuration, and the endpoint's real hostname. | The placeholder. | The same. |
| **A backend with no credential** (`credential = none`, a model you serve) | Nothing but the endpoint's address. It needs no credential, so there is none to withhold. | Nothing. | Reach that endpoint, which is why it has to be one you allowlisted. |
| **No proxy** (`[credentials] proxy = off`, or a Claude Code login the selected backend cannot serve) | The real credential file, mounted. | **The credential itself**, durable and usable offline. | Everything the credential allows, for as long as it is valid. |
| **No proxy** (`[credentials] proxy = off`, or `[backend.<name>] proxy = off`) | The real credential file, mounted. | **The credential itself**, durable and usable offline. | Everything the credential allows, for as long as it is valid. |
**The first three keep the credential out; the fourth does not.** That is the
whole of the difference, and it is why `proxy = off` is described here as a
@@ -604,8 +640,16 @@ the host and injected per request. On the API-key path the container gets
`ANTHROPIC_API_KEY=sandbox-credential-proxy-placeholder` and no credential file
at all.
What lands in the fourth row is `proxy = off`, and a login whose shape no
selected backend serves.
**Only `proxy = off` lands in the fourth row, and only where you wrote it.**
A credential the selected backend cannot serve - a Claude Code signed in with
a console API token, whose file holds no `claudeAiOauth` block, against
`backends/anthropic` - is a refusal, naming the backend, the file, the shape
it expected and the two ways forward. A kill switch that can engage itself is
not one, which is why this row is a kill switch rather than an option.
The refusal is scoped to a credential that *exists* and cannot be served. An
absent one mounts nothing and downgrades nothing - that is a host where the
harness has not been signed in yet, and it still launches.
**The proxy is switched per backend, not globally.** `[backend.<name>] proxy`
falls back to `[credentials] proxy` when unset. One backend whose credential
@@ -737,7 +781,30 @@ 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
The `backends/anthropic` arrangement, at the level of what the proxy actually
does. The machinery is not Anthropic's - the credential's kind, which header
carries it, what must not travel beside it are all arguments a backend
supplies - but it is worth reading through one backend rather than in the
abstract, and this is the one with the most miles on it.
- The Anthropic OAuth credential (`~/.claude/.credentials.json`) never
enters the container. It isn't mounted: `~/.claude` is not shared as a
@@ -795,7 +862,7 @@ session, where the credential deliberately never goes.
after an upgrade, as `UNABLE_TO_VERIFY_LEAF_SIGNATURE`,
`ERR_TLS_CERT_ALTNAME_INVALID`, or a pin-specific error. What
distinguishes pinning from a merely broken certificate is that
@@ -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 |
@@ -263,6 +263,80 @@ repository's behalf.
**What this repository can do about that is say so**, which is the whole of
this section. Nothing in dejima can bound its own host.
## Why a container, and not a microVM
The most frequent question about this project.
The short answer is that a microVM is the stronger boundary and nothing here
disputes it. Container escape is the least likely of the six threats above and
the only one that needs an exploit at all, and the layer it lands on is already
bounded by a control enforced from outside the guest. The effort went there
rather than into a harder container.
**There is already a VM here**, which is what the usual framing misses. It is
put as container versus microVM, as though the alternative were a container
with nothing underneath. On the deployment this document describes, dejima runs
inside a devbox VM whose rule set is enforced from the hypervisor. So the choice
is not one boundary against two. It is an outer VM enforced from outside the
guest, against that plus an inner one managed by software running on the box it
is protecting - inside the blast radius of the row it is meant to bound. A
boundary enforced from outside is worth more than one enforced by the thing
inside it, and that is most of the answer.
**The field's ordering is not in dispute.** gVisor's syscall interposition and
a per-workload VM kernel are both stronger than a namespaced container.
Firecracker is what AWS runs Lambda on, for the reason that applies here too:
untrusted code, multi-tenant, on shared hardware. The disagreement is about
where marginal effort goes, not about which primitive is stronger.
### What a stronger runtime would and would not change
It would not touch the five threats ranked above escape. Two of them would get
*harder*, because both are enforced by how the container is configured rather
than by what isolates it:
- **One project's session cannot reach another's**, because each path crossing
the boundary is named individually rather than mounting a tree
([#104](https://gitbox.branderson.io/dejima/dejima/issues/104)). Under a VM
each becomes a filesystem daemon.
- **The container sees an agent socket, never your key.** That is a live host
socket, and a VM has no equivalent to hand it one.
One would get easier: egress rules on a tap device sit where the guest holds no
capability to touch them. That is available without changing the runtime, which
is what [#305](https://gitbox.branderson.io/dejima/dejima/issues/305) does.
**And "swap the runtime" understates the work**, because several controls are
implemented *in* Docker rather than merely hosted by it. The dependency
approval is a derived image build; the tooling reaches live sessions through
it; the capability floor is an argument about Docker's model. Porting them is a
rewrite of the parts that *are* the security argument, in a project that says
at the top that none of it has been reviewed by anyone else.
### What would change the answer
The decision is a judgement about effort, not a door held shut, and these are
the things that would reopen it:
- **Kata Containers is the cheap path if this reasoning is wrong.** It is an
OCI runtime, so the compose file, the mounts and the tooling survive, and it
can use Firecracker underneath. A per-launch switch rather than a fork.
- **Pointing dejima at projects chosen by someone else.** That contradicts an
assumption stated at the top of this document, and reorders the ranked list.
- **A container escape in the wild that clears this configuration's floor**:
non-root session user, `no-new-privileges`, `cap_drop: ALL` and a short list
added back. The class recurs, and whether a live one clears that floor is
asked on a schedule in
[research-review.md](research-review.md#the-standing-questions) rather than
assumed either way.
What is done instead is depth below the container, which is
[the stack](#the-stack-and-what-each-piece-buys): two of the three layers under
dejima are enforced where the guest cannot reach them, and the third bounds a
compromise in time. A sandbox that reaches for a microVM and stops there has
nothing underneath it, so an escape from the VM ends its argument. Here it is a
row with controls of its own.
## What this repository does not claim
- **That the egress allowlist bounds what reaches the model.** It bounds what
# Can't resolve a remote from here - let git produce the real error
# rather than block or guess.
exit0
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
exit2
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.
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.