P2.8 and the P5 items from the codebase audit.
docs/development.md's decoupling inventory listed "Settings layering" and
"Staged config" as resolved while bin/dejima still pre-created
~/.claude/settings.json and ~/.claude/CLAUDE.md by name, parsed settings.json
with Claude-Code-specific reasoning, and generated the override layer as
{sandbox: {enabled: false}} - Claude Code's settings schema, not a neutral
one. The pre-creation was a duplication as well as a coupling, since
harness_staged_config already declares exactly those two paths.
All three are one plugin hook now, harness_prepare_config, which core calls
with the path to write the override to. The plugin decides which config files
must exist, what makes one unusable, and what the override says; core keeps
what was always its own - that the override is a throwaway the session cannot
reach, that it is mounted at a container-local path and never over the
harness's own settings file (#47), and that a plugin refusing there stops the
launch. A settings schema is a parser's grammar rather than a value, which is
why this is a plugin hook and not a manifest key.
statusline.sh is deliberately not among the files created if absent: it is
staged executable and named by settings.json's statusLine.command, so an
empty one would be a status line that runs and does nothing in place of the
harness having none. The inventory now describes what is there, with a second
row for the config preconditions.
The credential proxy read a request body of any size into host RAM, from a
client that is the sandboxed session. Every other session-controlled size in
the repo is bounded, several with explicit reasoning about $TMPDIR being
tmpfs. Capped at 32MiB and refused with a 413 rather than truncated, since
half a request forwarded upstream is worse than a rejected one. The refusal
raises its own exception type: _read_body also raises ValueError for a
genuinely malformed request, and a test pins that such a fault still surfaces
as a traceback - catching ValueError here would have swallowed it, which is
the trade the suppression elsewhere in that file exists to avoid.
dejima-epitaph was silent when no harness is selected, exiting 0 having saved
nothing - indistinguishable from a session that never compacted. It says so
now, in the same voice dejima-audit uses for the identical condition.
dejima-attach's picker exited 1 with no message when there was no controlling
terminal (ssh -T, or an SSH app that requests no pty), which reads as a
broken front door. `read -r choice || exit 0`, matching what attach() already
reasons about explicitly.
Six new tests: the override disables the harness's own sandboxing, carries
none of the operator's other settings, refuses an unparseable settings.json,
creates both config files but not statusline.sh, leaves an existing file
alone, and core names no harness config file of its own. Two more in
proxy.bats for the body cap in both directions. A launch's 57 docker
arguments are identical before and after.
All nine host-side suites pass, and shellcheck is clean.
Three findings from the codebase audit, all in forges/gitea/bin.
gitea-actions could report "all N jobs succeeded" for a run in which a job
failed. fetch_jobs read one page of 100 commit statuses and filtered by run
number afterwards; a job writes at least two statuses and a re-run stacks
another set on the same commit, so on a re-run of a workflow with a dozen
jobs the requested run's own statuses fall off page one. The zero-jobs case
already errored loudly. Partial visibility did not, and the output goes to an
agent that acts on it.
The same shape in gitea-pr: create's duplicate check read one page of open
PRs, so past the server's page size it missed the existing PR for the branch
and filed a second one, and comments truncated issue comments and reviews at
the same boundary - dropping the feedback whoever is addressing the review is
waiting on, in a loop the docs recommend by name. All four list reads now
follow pages until one comes back short, bounded so a server that ignores
`page` cannot spin. Duplicated between the two CLIs rather than shared, for
the reason gitea-issue's header gives: they are self-contained files copied
into the image, and a library would be a third thing to keep in step.
A run with a skipped job read as in_progress forever, because the rollup
treated anything-but-success as unfinished. `skipped` is a finished job and
now counts as one; every other unrecognised status still reads in_progress,
which is the safe direction for one this does not know.
gitea-pr's `comments` and `merge` took the PR number as given while `edit`
required a number. curl normalises dot-segments, so a value like
`../../../other/repo/pulls/7` reaches a different repo's endpoint with this
token on it. No privilege boundary is crossed - the agent holds the token and
could curl directly - but the defence was already there and applied
inconsistently. Both go through require_number now, with an empty argument
still getting the usage line rather than a complaint about the empty string.
Three tests. Every PR-number subcommand refuses a path, an absent argument
gets usage, and comments reads all 105 comments a paginating stub serves
across three pages - mutation-tested by restoring the unpaginated read, which
fails it on the second page. The existing curl stub matched whole URLs, so it
stopped matching once the reads carried a query string; it matches on the
path now, which is what those tests were always about.
P4.17, P4.15, P4.16 and P3.14 from the codebase audit.
shellcheck now runs over all 29 shell scripts in the repo as its own CI job,
at -S warning. test/shellcheck.sh holds the file list, discovered by shebang
rather than enumerated, so a script added under bin/, hooks/, docker/ or a
forge's bin/ is covered the day it lands; the job and the static test that
pins its coverage call the same script.
It found a real bug on the first run. _provider_keys_cached did
local axis="$1" name="$2" cache_key="$axis:$name"
and bash does not apply the earlier assignments before evaluating the later
one in a single `local`, so cache_key was built from whatever axis and name
meant in the CALLER's scope. The cache was keyed on ":" for every provider,
and the only reason nothing broke is that both call sites wrap it in $( ) -
so the cache was populated in a throwaway subshell, read back never, and its
comment described a win that could not happen. It is removed rather than
fixed: a per-command-substitution cache with one lookup per subshell is dead
code, and dead code with a latent collision in it is worse than none.
Six `export X="$(cmd)"` in bin/dejima are split into an assignment and an
export. export always succeeds, so the combined form hides a failing
substitution from set -e; split, a config_get that fails stops the launch.
The rest of what shellcheck flagged is intentional, and says so at the site:
one-row tables that read as loops, literal tildes in prose, a trap body baked
in on purpose, and mkdir -p -m whose leaf-only mode is the reason for the
verify-and-chmod block beneath it. Two classes are disabled repo-wide in
.shellcheckrc, both about variables crossing a boundary shellcheck cannot
see: a library defining what its consumers read, and status=$? inside a
single-quoted trap. Verified clean under 0.11.0 and under the 0.9.0 CI will
install.
test/conversation.bats replaces grep-based assertions about
bin/dejima-conversation-ship with eight behavioural ones: the shipper runs
against a real transcript and a real HTTP endpoint standing in for Loki, and
what arrives is what is asserted on. Mutation-tested both ways - removing the
TERM trap fails the two loss-path tests, and a final_flush that flushes the
batch without rescanning fails only the tail test, which is the distinction a
grep cannot make. The superseded grep test is removed.
The web/index.html staleness test compared the manifest against the tree and
nothing against the page, so a commit staging web/src and web/sources.sha256
while leaving index.html behind passed and shipped the stale page - the exact
failure its own comment claimed to prevent. build.mjs already stamps the page
with sha256sum of the manifest it wrote; the test now checks that too.
Verified by reproducing the stale-page commit, which fails, and the correct
one, which passes. build.mjs pointed at test/static.bats for this; the test
is in test/remote.bats.
One static test hardcoded "exactly 2 checkouts pin the head sha", which the
new job broke. It asserts the property now - every checkout is pinned except
the two runner-env jobs - since a count breaks when a job is added, which is
when nobody is thinking about which ref it reads.
All eight host-side suites pass, and shellcheck is clean.
Two P1 findings from the codebase audit.
P1.3 - the web terminal honours OSC 52 unconditionally, so a session can
rewrite the operator's clipboard. The chain the audit describes ends at
PASTE: switch to a plain shell one tmux prefix key away, tap PASTE,
bracketedPaste() is false there, and toolbar.js sends the raw text including
its trailing newline, which runs it.
The execution step is where this is cut. With bracketed paste on, the text
arrives as data and nothing runs, unchanged. Without it, trailing newlines
are stripped: the paste still lands, and the operator presses Enter
themselves. Line breaks in the middle still arrive as Enter, which is true of
pasting into any terminal without bracketed paste and cannot be fixed without
refusing legitimate pastes, so PASTE says so rather than leaving it assumed.
The transformation is a pure function so it can be tested without a DOM.
Two bounds on the write itself: every OSC 52 write raises a toast naming how
much was written, because a clipboard that changes silently is one you paste
from believing you know what is on it, and a payload over 100KB is refused
before it is decoded, since a yank is a selection rather than a stream.
Copying out of a session (docker/sandbox-clipboard.lua, tmux set-clipboard)
is untouched.
The docs claimed "a native terminal over the SSH front door behaves
identically". It does not: xterm gates OSC 52 writes behind allowWindowOps,
off by default, kitty prompts, Alacritty needs configuring. That comparison
now says which way the difference runs, and what bounds it.
P1.4 - dejima-attach's prune could delete a live session's marker directory.
STARTUP_GRACE=900 was a guess about build duration; a cold first run can
exceed it, and then opening the front door mid-build unregistered the session
and rm -rf'd the bind source of a mount the imminent compose run declares.
Docker recreates it root-owned, dejima-notify.sh fails on every Notification
and Stop for the rest of the run, and nothing rewrites the registry entry, so
the session is invisible to both front doors permanently.
The marker directory's lifetime belongs to bin/dejima's EXIT trap, so the
prune no longer touches it at all: a second owner can only race the first,
and losing that race breaks a session rather than tidying a host. The guess
is gone too. bin/dejima records its own pid in the registry entry, and the
prune asks whether that process is still running, which is observable where a
build's duration is not. A recycled pid reads as alive and leaves an entry
listed too long, which is the harmless direction. An entry with no pid can
only be a session that outlived an upgrade, and keeps the old window; the
constant is renamed to say that is all it is for.
pid reaches kill, which takes a job spec and a negative pgid as well as a
process, so it is validated as digits in read_entry beside started= and for
the same reason.
Seven new tests: four on the paste transformation, three on the OSC 52 cap,
and four in remote.bats covering a pruned entry leaving the marker directory
alone, a launch still building however long it has taken, and a pid that is
not a plain number being refused rather than signalled. write_entry still
writes the pre-pid shape by default, so the existing suite keeps covering the
upgrade fallback. web/index.html is rebuilt from npm ci.
All seven host-side suites and the 48 web tests pass.
An external voice audit swept the repo against "Cut what the code replaced"
and found 24 files carrying history that stopped being checked. Two of them
had rotted into statements that are now false, which is the rule's whole
argument:
bin/dejima said ALLOW_GITHUB_PUSH is read by nothing. docker/init-firewall.sh
reads it at three sites - the port-22 grant and both halves of the startup
self-check - and docker-compose.yml passes it. The comment meant that the
push guard no longer reads it, and said "nothing".
bin/dejima-project-lib.sh's remote-access header said the switch is a
non-empty base_url. It is [remote-access] enabled, an explicit boolean, which
the comment block directly beneath it states and remote_enabled() implements.
The header was a fossil of an intermediate design.
The rest is the same defect in a milder form: comments that narrate what
moved rather than state what is there. The #50 masking story was retold at
three code sites, none of them the canonical record; docs/security.md is,
and each site now carries the current fact and a pointer to it. Four
"no longer" phrasings in bin/dejima, two "moved here from" notes in
dejima-config-lib.sh, two justifications resting on a remote.conf validator
that is gone, and a dozen more, all restated as properties of the code that
is there.
Docs the same: security.md's cross-project paragraph, statusline.sh's :ro
mount (kept as a present-tense counterfactual, with the confirmed-against-a-
live-container fact intact), remote-access.md's five "exactly as it did
before this feature existed" guarantees, and the "in this phase" parenthetical
in providers.md, which refers to a delivery plan no reader has.
Kept deliberately, per the rule's own exception: every migration and
compat path, the regression-pin comments in tests, and security.md's
"found the hard way three times" (#47/#48/#50) - the canonical incident
record every code pointer now resolves to.
Two tests were coupled to the old wording and are updated in the same
change: static.bats grepped security.md for "no longer a CROSS-PROJECT
finding", and two test names carried archaeology of their own. Two
"Finding 1" labels cited a review document not in the repo, and named
different findings in each file; the failure-mode prose around both stays,
the labels are gone. All seven host-side suites pass.
docs/providers.md called `install` "the only manifest key naming something
that actually gets copied and executed" and said the list "is what the
Dockerfile copies into the image". Neither was true: nothing under bin/ or
install.sh read the key, and the Dockerfile ran a find over the directory
layout, so `install =` empty in forges/github/forge.conf described a gate
that did not exist.
That drift fails open, and it got worse when #133 put the provider
directories in the audit's TCB. An operator reading a forge manifest is
entitled to conclude that a forge declaring nothing puts nothing executable
in the image; under the find, any file added under any forges/*/bin/ shipped
into /usr/local/bin of every image with no declaration to review and no
unknown-key error to notice.
docker/install-forge-bins.sh installs exactly the declared files and fails
the build in both directions: a declared file that is missing, and a file
under a forge's bin/ that no manifest declares. It parses rather than sources
the manifest, for the reason dejima-provider-lib.sh does - forges/ can be the
mounted workspace when dejima sandboxes its own checkout - and re-checks each
element against the relpath shape, since the schema validator does not run
where this does.
Three static tests, so the gate is checked in the fast tier rather than only
where Docker runs: the installed set equals the set present under a bin/, an
undeclared file fails, and a declared-but-absent file fails. Both doc claims
now describe the enforcement that is there.
A launch spent 17 seconds in shell before docker was invoked, and forked
5,397 processes to do it. dejima-config-lib.sh parses the config file once
per process and answers every read from that parse; the manifest layer never
got the same treatment, so manifest_get ran a full manifest_validate and then
re-walked the file on each of a launch's ~36 calls, forking three times per
line for the trimming alone. The schema table was read through a process
substitution over a heredoc on every key lookup, and provider_dir resolved
its two readlinks per read.
Manifests are now parsed once per process into _MF_VAL/_MF_SEEN, the schema
table is loaded once per axis into _MFT_TYPE/_MFT_DEF, provider_root and
provider_dir cache their resolved paths, and the parse loop uses the existing
fork-free _config_trim_into. A launch is 0.40s and 127 forks; test/config.bats
falls from 639s to 314s.
The property the caching has to preserve is the one the config header states:
every read sees a fully validated file, and no read can succeed against a
file another read would reject. Values are staged and committed only once the
whole manifest has passed, so a manifest refused halfway leaves no half of
itself readable, and the cache is keyed on the resolved directory rather than
the provider name, so repointing DEJIMA_PROVIDER_ROOT is re-parsed rather than
answered about the previous root. Three tests in providers.bats pin all three.
Verified invisible: the 57 docker arguments a launch produces are identical
before and after, and all seven host-side suites pass.
Five findings, all the same defect class this repo names for itself: a
comment or a doc describing a control that moved or never existed.
Debounce stamps leaked on every normal exit. The only code that removed
them is dejima-attach's prune, which needs a registry entry still
present for a container that is gone - and bin/dejima's cleanup() takes
that entry on every clean exit, so the branch could not fire. The
comment claimed the opposite. They are removed in cleanup() now, with
the rest of the per-run state; the prune keeps its copy for the exit
that runs no trap, and says so.
`ttyd_tmux_prefix = C-space` passed the schema and was then rejected by
the page, which fell back to C-b silently - the outcome refusing at
launch exists to prevent. The page matches a named key in any case now.
web/index.html is rebuilt from `npm ci` so the artifact matches the
pinned dependencies rather than whatever install resolved.
docs/security.md still named /etc/dejima-claude, renamed to
/etc/dejima-harness; docker-compose.yml kept two comment blocks
describing mounts that moved to the harness plugin, and one describing
a variable that no longer exists; docs/providers.md listed the briefing
whitelist without network_allowlist, which reads as "not available" for
something that is.
Three tests, each of which would have caught its own finding: the docs
carry no stale staging path, every whitelist entry is documented, and
both sides of the tmux prefix agree.
init-firewall.sh sources docker/fixed-domains.sh with IFS=$'\n\t' set and
dns-proxy.sh with the default, so an unquoted expansion hands the two
consumers different lists from one value - the drift that shared file
exists to prevent. A harness declaring two api_domains reached the
firewall as one glued element and failed its DNS check, which is fatal:
the container refuses to start.
Latent while claude-code declares exactly one domain, and it fires for
the first harness needing two - opencode, which needs models.dev and a
provider endpoint. From the codebase audit at the tip of 152; the same
trap is already commented at init-firewall.sh's own extra-domains loop.
The existing test could not see it: it sources the file in a fresh
bash -c, so with the default IFS and the single-valued real manifest.
The new one uses two domains and reproduces init-firewall's IFS, and
fails on the old code with "firewall sees 3 entries, dns proxy sees 4".
The registration check now covers every hook the harness would install,
not just the briefing, and the launch names which are missing rather
than asserting that none are in force. A hand-edited settings.json that
dropped one guard was silent before; it is named now.
The wrapper-name refusal resolves through PATH instead of checking
/usr/local/bin alone. The entrypoint invokes find, install and mkdir as
root after linking the wrapper, so a manifest naming one of those would
have shadowed it. Two resolutions stay legitimate: the real binary being
shadowed, and the wrapper itself from an earlier start.
The R1 comment sits above the no_confirm_args refusal it describes,
rather than above the command_path one added beneath it.
The conversation shipper dropped the tail of every session. bin/dejima
SIGTERMs it the moment the container exits and it had no trap, so both
the in-memory batch and everything written since the last poll went with
it - typically the agent's final answer. The comment at the kill site
claimed the opposite, which is worse than saying nothing. It traps TERM
now, scans once more and flushes; confirmed by writing a line, killing
immediately, and finding it shipped, against a control that loses it.
It also exits if bin/dejima was SIGKILLed rather than polling an ended
session forever.
Nothing said when the hooks were never registered. install.sh registers
them gated on [core] harness, so an operator who set that key without
re-running it got no briefing and no guards, on every launch, silently.
The launch says so now, in the same voice as the guard report.
The audit's section rename broke the one property those boundaries
exist for: a state file written before it, compared after, sent the old
statusline entry into the tcb range and produced a CRITICAL for a file
nothing touched. The old boundary is read as the new one when the new
one is absent.
The entrypoint refuses a wrapper name that already exists in
/usr/local/bin. `identifier` permits any bare name, so `command = gosu`
would have had the entrypoint exec the harness as root - the manifest is
trusted, so this turns a bad one into a clean refusal.
/etc/resolv.conf and /etc/hosts join the container paths a harness mount
may not shadow: the DNS pin that routes the model API to the TLS
terminator is as load-bearing as anything already listed.
An empty command_path is refused at launch beside the no_confirm_args
refusal, rather than as the wrapper's exit 127 inside a started
container.
no_confirm_args and settings_args take a new `args` type. `template`
promises substitution against the briefing whitelist, and these never
pass through composition, so a {{...}} in one shipped literally.
docs/security.md names the trade in moving the transform host-side: jq
now parses session-controlled bytes outside the container as the
operator, which is the cost of a log a session cannot silence.
opencode derives its storage directory from the git remote - sha1 of the
normalised origin URL, in packages/core/src/project.ts. That lives in the
mounted workspace, so a session can change it in one command and send
the next launch at another project's storage. A mount whose destination
the session chooses is not a scoped mount.
emit_mount_at keeps the source keyed by something the session cannot
change and lets only the target follow the harness. A session that
rewrites the remote sends the harness looking at a container path
nothing is mounted at, which is empty rather than someone else's.
emit_mount is now a same-path call of it, so there is still one place
the guarded-store refusal happens.
The target gets its own refusal, since a host store is not what it can
expose: a mount landing on /usr/local/bin, the settings layer, the
briefing or the marker directory replaces something the sandbox needs in
place. A plugin is trusted code; core is still the one checking.
Writing that check found a hole in _paths_overlap. Stripping a trailing
slash turns "/" into the empty string, and "//" is not a prefix of
"/usr" - so root overlapped nothing, and `[mounts] readonly = /` passed
the guarded-store refusal and mounted the approval store, the audit
reports and the epitaphs in one go. Confirmed against the unmodified
code before fixing it.
_paths_overlap moves into dejima-harness-lib.sh with the mount checks it
serves, so they can be exercised without starting a launch.
Both branches appended to test/static.bats, which conflicts every time
even when the additions are unrelated. They are here: this side added
the three transcript-handling tests, the base added the .dejima.md rule
test. Kept both.
Two test comments on this side still narrated what the code replaced -
where conversation shipping used to run, and what the container's log
shipper used to parse. The rule that arrived from the base with this
merge says not to, so they are restated in the present. The test name
"...no longer reads anything the session writes" went with them.
"Cut how you found it" is about the discovery - which CI run failed, how
many attempts it took. Describing the arrangement a change replaced is a
different habit, and nothing named it, so it kept being written.
The rule above it was part of the reason. "Keep why a check is shaped
the way it is" reads as licensing a previous shape, since that is the
easiest answer to the question - the guidance did not merely fail to
forbid this, it pointed at it. That bullet now asks for the reason as a
property of the code that is there, and the new one says why a
comparison with something gone is not a reason at all.
The exception is written down too: code whose only purpose is the past -
a rename cleanup, a compatibility shim - needs its history, and cutting
it leaves a block that reads as dead weight. Two of those were found and
left alone during the sweep this comes from.
Also says the pass is reading rather than grepping, and why: "used to"
misses comments that narrate a replacement without the phrase, and
catches "deliberately not used to change how $project is resolved",
which means employed-to.
Eleven comments described a previous arrangement and why it was worse.
That is the story of a change rather than anything a reader of the code
needs, and .dejima.md already asks for it to be cut.
Each is restated in the present with its reason intact: the config file
is parsed once per process because a per-read parse is two passes and
two forks a line, not because it "used to" be; remote access is switched
by an explicit boolean because overloading base_url would drop a
supported setup, not because a file's existence "used to be" the switch.
Nothing is removed that explains why code is shaped the way it is. The
failure modes, the issue numbers and the reasoning all stay. What went
is the route each one took to get here.
Two near misses worth noting, both left alone. install.sh's
claude-sandbox symlink cleanup and the plugin's pre-rename
gitea-merge-guard handling read as history, but there the history IS the
reason the code exists - delete the comment and the block looks like
dead weight. And project-lib's "deliberately not used to change how
$project is resolved" means employed-to, not formerly.
Scoped to files no open pull request touches, so this does not conflict
with #146 or #147 while they are being read. bin/dejima and the harness
files carry the same habit and can be swept once those land.
Several said where something used to live and why that was worse, which
is the story of the change rather than anything a reader of the code
needs. What survives is the current arrangement and the reason for it:
the shipper is host-side because an oversight log must not run in a
process the session can reach, and transcripts resolve by slug because a
prefix glob catches a sibling project (#105).
The failure modes stay. The route each thing took to get here does not.
Conversation shipping ran in docker/log-shipper.sh: it read the
transcripts, applied the transform and held the Loki URL, in a process
the session shared a filesystem and a signal namespace with. It was the
one thing dejima did that observed a session from within the boundary it
was observing, and a session that killed it ended its own logging with
nothing on the host noticing.
The transcripts are bind-mounted from the host rather than produced in
the container, so bin/dejima-conversation-ship reads them where they
already are. Started alongside the session and stopped in cleanup()
before the audit, so the last lines a session produced are shipped
rather than discarded with the batch, and reaped the way the credential
proxy is. DNS and firewall logs still ship from inside, because those
files only exist there.
The transform moves to the plugin, being a fact about the harness's
transcript format. The shipping, batching, Loki labels and the opt-in
stay core's.
Moving it fixed a latent bug rather than carrying one over. The
in-container watcher globbed "<key>-*" to pick up worktrees, which also
matches a sibling project whose directory name merely extends this one's
- #105, in the shipper. It was harmless there only because the container
is handed this workspace's slugs and nothing else, so a sibling's
directory did not exist to match; on the host every project's does. It
resolves through dejima-slug-lib now, and a test confirms a sibling's
transcripts are not shipped.
entrypoint.sh's justification for running the shipper as root said it
parses session-written transcripts. That is no longer true, and a
comment that argues for a privilege from a false premise is worse than
none - it now says what remains and why root is still right.
bin/dejima-epitaph parsed Claude Code's transcript format directly: the
.jsonl glob, the isCompactSummary flag, the compact_boundary record that
carries the metadata, and which fields describe a compaction.
harness_extract_epitaphs takes a transcript directory and an output
directory and returns how many it wrote. Everything around it stays
core's, because none of it is a fact about the harness: which slugs a
workspace owns, that the store is never mounted, the 0700 directory and
0600 files, and that a directory appears only once something is kept in
it.
The provenance note is core's too, and is handed to the plugin rather
than left for each harness to phrase. It is dejima's claim about the
artefact - that this preserves what was recorded without vouching for
it - and a harness restating it in its own words would make a promise
dejima is the one keeping.
Verified against the real store as well as the fixtures: 31 epitaphs,
byte-identical headers, mode 600.
settings.json and CLAUDE.md were named in docker-compose.yml,
statusline.sh in bin/dejima, and entrypoint.sh knew all three by name
and which mode each got. Every one of those is a fact about Claude
Code's configuration.
harness_staged_config declares them. The staging tree mirrors the
destination - /etc/dejima-harness/<rel> is installed at $HOME/<rel> - so
entrypoint.sh needs no mapping passed in and no harness's paths written
into it. That also avoids inventing a text protocol to carry one, which
is where this repository's parsing bugs have been.
The mode comes from whether the source is executable rather than from a
per-filename rule. That is the distinction the 444/555 split already
encoded: a statusline is run, a settings file is read.
Writing my own verification caught a regression. The plugin first
skipped files the host does not have, which is what the old code did for
statusline.sh - but a dangling symlink also fails -e, so it was silently
skipped rather than refused, reopening the gap staging exists to close.
Whether a file exists, and what a dangling symlink means, are core's;
the plugin now emits unconditionally and a static test holds it there.
Ten mounts were enumerated in docker-compose.yml by name, and the
symlink-target scan in bin/dejima named five of them again. Static YAML
can only ever name one harness's directories, so a second harness would
have received Claude Code's and none of its own.
harness_config_dirs declares each directory with its mode and whether to
follow symlinks out of it. Core mounts them through emit_mount - so the
guarded-store refusal applies to a harness's config paths the same way it
already did to its state paths - creates them before the mount is set up,
and runs the scan over the ones marked for it.
The scan flag is per directory rather than applied to every read-only
one, because that is what the old list meant: hooks, skills, commands,
agents and output-styles routinely hold links into a dotfiles checkout,
and plugins is bookkeeping the harness writes for itself.
Verified by diffing what a launch actually passes docker. All ten
arrive with the modes compose used, and a symlink into a dotfiles
directory still gets its target mounted read-only.
Two test fixtures needed correcting rather than repointing. The five
mount assertions now run the hook and assert on what it declares, which
is stronger than grepping YAML. And remote.bats asserted the session
registry is never mounted by looking for the bare word "sessions" in the
docker command line - correct only while ~/.claude/sessions lived in
compose. It matches the registry's own path now.
The privileged job's test 8 asserts api.anthropic.com resolves in
proxy-off mode. Its container is started by the suite rather than by
bin/dejima, so nothing supplied SANDBOX_HARNESS_PROXIED_DOMAIN and the
name was no longer allowlisted - the image does not name it any more.
The fixture now passes both harness domains, read from the manifest
rather than written into the test, so it cannot drift from what a real
launch supplies. That keeps the test asserting the arrangement that
actually occurs instead of one the image used to hardcode.
Only the shared container needed it. The tls-transport test runs
proxy-on, where the name is deliberately unresolvable and the session
reaches the API through the forwarder; the rebind test asserts on
registry.npmjs.org, which stays in the core list.
platform.claude.com was written into docker/fixed-domains.sh and
api.anthropic.com was appended there conditionally, so the image's fixed
allowlist named one harness's endpoints. A second harness would have
inherited both and still had no way to declare its own.
Two manifest keys carry them: api_domains for what the harness's own
machinery must reach, and proxied_api_domain for the one endpoint the
credential proxy stands in front of. The rule about the second - it is
allowlisted only when the proxy is off, because with the proxy on
leaving the name unresolvable defeats a bypass that looks it up by DNS -
is dejima's and stays in fixed-domains.sh. Which name it applies to is
the harness's.
They join the fixed list rather than the extra one. That distinction is
load-bearing and unchanged: dns-proxy.sh exempts the extra list from
stop-dns-rebind and init-firewall.sh downgrades a failed lookup there to
a warning, neither of which is right for a public SaaS name the session
cannot work without.
registry.npmjs.org stays in the core list. It reads as the harness's,
being how Claude Code updates, but it is not only that: nvim's mason
fetches language servers through it and [core] command = nvim is a
supported way to run a session, so a harness declaring it would leave
that broken for any harness that did not.
Manifest data reaching the firewall is the arrangement a forge's
fixed_hosts already has, and since #142 the provider directories are in
the audit's TCB list, so widening this unseen is a CRITICAL finding.
They hold no code and nothing executes them, which is why they were
left out. What they hold decides what the next launch permits.
A forge's fixed_hosts reaches DEJIMA_FORGE_HOSTS, which entrypoint.sh
joins into extra_domains and init-firewall.sh allowlists - so a line
added to a manifest widens the egress boundary on the next run, and the
egress boundary is what bounds where a compromised session can send
anything. A briefing fragment is what the next session is told about
where it is and what it may do; docs/security.md already named that as
reachable from a session with write access to this checkout, and nothing
reported it.
Found while considering an api_domains manifest key for the harness
axis. Adding one would have widened the same hole, and the same question
decided PR #141 the other way: manifests are data a session can edit in
the self-mount case, and provider directories were the one part of what
decides a launch that the TCB snapshot did not hash.
Costs nothing in ordinary use. The TCB report splits on whether git can
see the change, so editing a manifest while developing dejima is a diff
the operator is about to read and is not reported. A change git cannot
see - hidden by .gitignore, .git/info/exclude, assume-unchanged or
skip-worktree - is a CRITICAL finding, which is the case the list exists
for.
The audit watched exactly one path, named statusline_path, reported it
with a label written into dejima-audit, and keyed it under a
`statusline:` id. All three assumed Claude Code.
The path, the id and the label are now the plugin's, and the mechanism -
snapshot, hash, stage, CRITICAL severity, the ack path - stays core's. A
harness declares its surfaces through emit_host_exec with fixed literals;
a harness with none is watched for none, rather than for the previous
harness's path.
Declared by the plugin rather than the manifest, and the reason is a
trust boundary rather than taste. `ack` recovers an id by stripping a
fixed label from the front of a finding, anchored deliberately: an
unanchored match lets chosen text derive an id in the wrong namespace,
and approving it baselines an id that never matches while the real
finding counts as acknowledged (#45). Those labels are a parser's
grammar. Provider directories are not in tcb_snapshot's list, so in the
self-mount case a session can edit a manifest without raising a
CRITICAL; a plugin is under bin/, which the audit does watch.
That choice also removes the migration this looked like it needed. The
namespace and leaf come from the harness, so the id stays
`statusline:statusline.sh` byte for byte and every approval an operator
already holds keeps matching. A test records one and asserts the
baseline line, so a future rename has to break it out loud.
The ack arm becomes an exact prefix strip over the declared labels
rather than a literal case arm - `"$body"` with the label removed
differs from `"$body"` only when the label really is at position 0 -
which also stops a label's own characters being read as globs.
install.sh knew ~/.claude/hooks and settings.json's hook schema: it
symlinked five files into one harness's directory and wrote four
registrations in that harness's JSON shape, for any operator who ran it.
An operator who had chosen another harness got their Claude Code config
edited anyway.
That work is now harness_register_guards in the plugin, and each guard
is gated on the manifest declaring it - a harness declaring only
bash-deny gets only that, and registering something it had not declared
would contradict the launch message from #138 that says it is missing.
Moved to after the starter config is written rather than before. That
config is where [core] harness comes from, and install.sh seeds it, so
on a fresh install the selection exists by the time registration reads
it. With no harness set - an operator upgrading from before it was
required - nothing is registered and the message says which key to set.
harness_plugin_load resolves the plugin from the library's own location
instead of the caller's SCRIPT_DIR, which bin/dejima sets to bin/ and
install.sh sets to the repo root.
deny-dangerous-bash.sh keeps the property that makes it different: it
has no DEJIMA check, so it applies to the operator's own host sessions,
and it is still only claimed when nothing else already provides it. The
comment now says why rather than leaving the next reader to notice the
missing check.
The capability bar allows a harness to be supported at a smaller guard
set. Nothing made that visible: dejima applied four guard rails and
assumed every harness could host them, so a harness that could not
would have shipped three protections short with no one told.
`guards` in the manifest lists which of bash-deny, push-guard,
merge-guard and notify the harness hosts, the same declared-capability
shape the forge axis already uses for `capabilities`. A name outside the
known set is refused rather than treated as an unknown guard: a typo
would otherwise turn a guard off in silence, which is the failure the
declaration exists to prevent.
What a harness does not declare is named once at launch, with what each
guard costs. Nothing is printed when it hosts them all, so the message
stays a signal rather than a line to scroll past.
The notify registration moves into the plugin. How a harness is told to
run something when the agent wants attention is its own business -
Claude Code merges hook entries across settings layers, another may take
a config key - so the writing is the plugin's, while whether it happens
is core's and is gated on the declaration rather than on the plugin
happening to define the hook. A harness that forgot to declare it but
implemented it would otherwise get a guard the launch message had just
said it does not have.
The providers suite caught DEJIMA_KNOWN_GUARDS being defined in
dejima-harness-lib.sh while the validation that reads it lives in
dejima-provider-lib.sh: that suite sources only the latter, so it
validated against an empty set and rejected every guard there is. A
constant the validation depends on now lives where the validation does.
The wrapper hardcoded --dangerously-skip-permissions, was COPYed to
/usr/local/bin/claude at build time, and exec'd a path under
npm-global. Core's own schema defaulted [core] command to the literal
`claude`, while the harness manifest's `command` key was read by
nothing at all. Between them, unattended operation was a property of
the image rather than of the selected harness.
Four manifest keys carry it now: `command` (which [core] command falls
back to), `command_path`, `no_confirm_args` and `settings_args`.
docker/harness-wrapper.sh names no harness and is installed under a
neutral name; entrypoint.sh links it into place as the harness's own
command, because the image is shared across harnesses and the selection
is per launch. The link is refused for anything but a bare name - it
becomes a path under /usr/local/bin, and the value arrives as
environment.
A harness that declares no no_confirm_args is refused at launch. R1 of
the capability bar is not a formality: a harness that stops to ask,
running against a container built on the premise that it does not,
presents as a hang rather than as a misconfiguration.
settings_args names /etc/dejima-settings.json in full rather than
templating it. The providers suite caught the first attempt, which used
{{settings_file}} - that syntax belongs to the briefing composer's
substitution, and a second mechanism that merely looks like it is worth
neither the confusion nor the whitelist entry. The path is dejima's own
fixed contract, so the manifest can say it.
Three tiers, and which one a thing belongs in is the design: core holds
the mechanism and every enforcement point, the manifest holds what can
be stated as a value, and a plugin holds the harness-specific remainder
as code. A feature goes in the manifest unless it demonstrably cannot -
`projects_dir` and `file_history_dir` are both paths, so both are
manifest keys, while the rule that a name in the first selects an entry
in the second is Claude Code's own and is the plugin.
Plugins live under bin/ rather than beside their manifest because
tcb_snapshot covers install.sh, bin/, hooks/, docker/ and test/ and not
the provider directories. Host-side plugin code under harnesses/ would
be code that runs on the host and that a session could edit in dejima's
own checkout without raising a CRITICAL. Under bin/ it is covered, and
"manifests are data, never executed" stays true word for word.
Sourced rather than invoked. They are trusted code, so the risk worth
designing against is three plugins implementing the security model three
ways, not one careless plugin - and sourcing lets a plugin use core's
primitives instead of re-deriving them. An invoked protocol would put a
new text parser on the launch path, which is where this repository's
bugs have been.
What replaces isolation is that a plugin computes and core enforces. A
plugin declares through emit_mount and emit_env, which validate at the
point of declaration: emit_mount runs the guarded-store refusal there,
so a harness state directory overlapping the approval, audit or epitaph
store is refused without each plugin remembering to check. A plugin
never touches extra_mounts, a plugin defines only hooks core calls, and
every harness has both a manifest and a plugin - all three held by
static tests rather than by convention.
The projects/ and file-history/ mounts move into
bin/harness/claude-code.sh unchanged in behaviour. The existing test
that drives a real launch and asserts on the mount arguments passes
untouched, which is what says the extraction was faithful; the two
static tests that grepped bin/dejima for the code now grep the plugin
for the same property.
Support is per harness and deliberate, at the depth Claude Code has: the
same mounts, the same guards, the same audit surfaces. That is only
possible if the harness can host what those protections need, and
nothing said what that meant - so the first harness that could not host
one would have been argued about rather than measured.
Five requirements a harness cannot be supported without: unattended
execution, tool-call interception, host-controlled context at session
start, state dejima can locate or discard, and a credential it can
withhold. Three more that may be declared absent, with what is lost
named and said at launch, so a harness can be supported honestly at a
smaller guard set instead of shipped with protections quietly missing.
aider is the worked example of a refusal. It clears four of the five and
fails tool-call interception: it has no hook or event system, so the
push guard, the merge guard and the dangerous-command denylist have
nowhere to live and no equivalent to rebuild from. The container would
still bound it, but "supported" would mean something different for that
one harness than for every other.
The precondition section this replaces said the same thing about
unattended execution alone, which turned out to be one requirement of
five rather than the whole bar.
`keys` is declared per harness, so reading one through a literal
[harness.<name>] section makes it a declaration only one harness can
ever be read through: a second harness gets selected, gets validated,
and is then handed the first one's paths with nothing erroring
anywhere. Three call sites did that - two in bin/dejima-audit, one in
bin/dejima-epitaph. `harness_config_get` resolves whichever [core]
harness names, and a static test holds the rule.
The two remaining mentions in bin/dejima are message text in the
legacy-environment-variable detector, whose own test requires each entry
to name a real schema key. They point a reader at a config setting; they
read nothing.
The audit suite found something the refactor would otherwise have
shipped quietly. Its fixture set [harness.claude-code] keys but never
[core] harness, so with the paths resolved properly the statusline
surface stopped being watched and four tests failed. That is a real
state, not a fixture artefact: dejima refuses to start without a
harness, but the audit can be driven by hand against a config dejima
would reject, and it would have reported a clean run over a surface it
never examined. It now says so on snapshot and compare. The fixture
gains the [core] harness every launchable config has, matching what
config.bats already does for the same reason.
A harness manifest declares four things. Everything else dejima does for
Claude Code is written against Claude Code, and nothing said where or how
much - so the first question of a second harness, "what has to be built",
had no answer short of reading the tree.
docs/development.md gains "Adding a new harness": the precondition, what
the manifest already covers, and a file-by-file inventory of what does
not - the image and the wrapper that forces the permission bypass, the
credential proxy's shape, the egress entries, the hook protocol and how
the briefing rides on it, the enumerated ~/.claude mounts, the project
slug scheme reproduced in three places, the transcript and compaction
formats, and the audit surfaces.
Two findings are worth more than the list. Five call sites in bin/ read
`harness.claude-code` by literal name rather than the selected harness,
so `keys` is declared per harness and consumed as though there were only
one; that is the first thing to fix, because until it is a second
harness silently inherits Claude Code's paths. And `statusLine.command`
runs on the host, so the question "what does this harness execute
outside the container" belongs in the audit from a harness's first
release rather than after someone finds it.
A static test holds the call-site count to what the audit lists, so
fixing one forces the row to go with it.
Between the container exiting and the shell prompt returning, dejima
audits every host surface the session could have written to and reads
back the transcripts it left. On a real project that is seconds of an
entirely silent terminal, which reads as a hung one - and the operator's
answer to a hung terminal is Ctrl-C, during the audit.
Each of the two tasks now names itself as it starts. Only once a
snapshot exists, which is the same condition `dejima-audit compare` uses
to decide it has anything to do: an exit before the session started is
instant, and its last line is the error that caused it, which an
announcement would push off the screen.
Epitaph moves ahead of the audit while its output is being considered.
It prints a line whenever it keeps a summary, and that line was landing
under the audit verdict - the one thing in the trap that is deliberately
last, because the operator reads bottom-up from their prompt.
A workspace's slugs include one per live worktree, and worktrees have
transcripts. What they mostly do not have is a compaction summary in any
of them, so a directory made per slug up front left the store reading as
one entry per worktree with nothing in any of them.
The directory is now made by the first write into it. An `rmdir` per
slug clears the empty ones earlier runs left behind; it cannot touch a
slug that kept anything.
Also corrects dejima-slug-lib.sh's header, which still named
bin/dejima-audit as the second caller. The cross-project finding that
used workspace_slugs was removed in #115; bin/dejima-epitaph is what
shares the definition now.
Claude Code writes a summary each time a session is compacted. It is the only
durable account of what a long session worked out, and until now it went with
the container.
bin/dejima-epitaph runs host-side from the exit trap, reads the workspace's
own transcripts, and writes each summary to [epitaph] dir with its trigger
and token counts. Nothing in the container takes part, and the store is never
mounted - so a session can neither read what earlier sessions concluded nor
edit what this one is about to record. That is the same arrangement as the
audit store, for the reason #104 established.
Nothing loads an epitaph back into a later session, deliberately. A file a
session writes and a later session reads is the channel #104 closed; this
one goes to the operator, who can promote anything worth keeping into
.dejima.md, where it is reviewed like code.
The transcript it reads IS session-writable, so an epitaph records what was
there rather than vouching for it. The header says so.
[epitaph] loki, off by default, also pushes each one to [logging] loki_url -
host-side rather than through the container's log shipper, so the shipping
path keeps the same property.
main stays releasable while work lands quickly. development merges to main
periodically; nothing else about the workflow changes.
Stated in the three places it gets looked for - .dejima.md for an agent
working here, CONTRIBUTING.md for a reader, docs/development.md for the
mechanics - with a test that all three say it, because a convention with one
stale copy is a convention someone follows wrongly.
The mistake worth naming is gitea-pr's base: it defaults to the repository's
default branch, so --base development has to be passed. It is recoverable, a
pull request's base can be changed afterwards, so the instruction is to read
what gitea-pr printed rather than to be careful.
Nothing enforces the target. git-push-guard blocks the branches a workflow
names, and that list is workflow-wide rather than per-project, so adding
development would impose it on every dejima user - most of whom have no such
branch. Said plainly in docs/development.md rather than left to be discovered.
CI needed no change: tests.yml triggers on pull_request_target with no branch
filter, so it already runs whatever the base is.
The deleted-surface behaviour is audit mechanics, not something a reader
needs on first contact with the project. It moves to docs/security.md beside
the rest of the approve/ignore/skip rules, which is where someone asking that
question already is.
The README keeps one line: show indexes, ack walks it.
Five problems, one of which caused another.
A finding whose file had since been deleted could not be approved - there is
no content to pin a `safe` entry to - so `ack` refused. But refusing meant
crit_acked never reached crit_seen, the report kept -CRITICAL.txt, and
select_report prefers CRITICAL: one deleted file pinned the audit to a report
no decision could resolve and hid every other report behind it, permanently.
It is cleared now. Nothing is recorded, because nothing unmatchable should
reach the baseline, but the finding is done with - a surface that does not
exist does not run, and if it comes back it comes back as new.
`ack` walks every report in one run, asking after each whether to continue,
and says up front how many are waiting. It used to walk one and exit with no
indication there were more, so clearing a backlog took as many runs as there
were reports and read as the tool being stuck on the first.
The cross-project finding is gone. It reported a poisoning route a session
could once take, when the whole ~/.claude/projects tree was mounted; since
belongs to another session doing its own work - and with several running it
reported all of them, on every report. Detection that cannot observe what it
was built for, and fires constantly on what it was not, trains an operator to
skim.
TCB findings now split on whether git can see the change. tcb_snapshot only
populates when the workspace IS dejima's own repo, so an ordinary change is
already in the working tree about to be read in `git status` and in the pull
request - one CRITICAL per file duplicated a stronger control and made
developing dejima unusable. Those collapse to one INFO line. A change git
CANNOT see stays CRITICAL, because nothing else catches it: .gitignore,
.git/info/exclude, assume-unchanged and skip-worktree all hide live code that
runs on the next launch.
`show` indexes the reports - how many, which are CRITICAL, which `ack` will
open - instead of dumping one report's contents including findings already
acknowledged. `show --full` prints one.
Two bugs found while doing this, both mine, both fixed with the tests that
caught them: the `reviewed` marker recorded a filename before the report shed
its -CRITICAL suffix, so it never matched afterwards; and a cleared finding
was neither baseline-suppressed nor un-ackable, so it re-presented on every
later run - the same loop the clear was meant to end.
The prose in this repository has been through two compression passes, and
both cut the same things: how a bug was found, consequence chains followed
past the first step, restatements of the code below, meta-commentary, and
numbers that were wrong within a month. Writing that guidance down is
cheaper than running the pass again.
Framed as keep/cut rather than "be concise", which is not actionable, and
explicitly not an argument for thinner documentation - the README's note on
voice prefers thorough over polished, and this has to not contradict it. The
distinction is padding, not length: the sections named at the end are long
because the argument is the content, and they survive every pass.
The default was fine while claude-code was the only harness and stops being
fine the moment there is a second: an operator who never wrote the key made
no choice, and a release changing the default would move them silently on
upgrade.
dejima now refuses, naming the file, the line to add, and what is available.
install.sh writes it uncommented in the starter config, the same treatment
forge and workflow already get, for the reason already written there.
--print-config keeps working with it unset, which took one fix: the schema
enumeration resolved [harness.<name>] keys through the selected name and
printed "invalid harness name:" for an empty one. An unselected axis
contributes no keys.
Every fixture that launches now selects a harness, as a real config must.
config.bats' write_config seeds one only when the caller has not, matched as
a key assignment rather than as the word anywhere in the arguments - a
[mounts] path containing "harness" otherwise suppressed it.
The repository argues at length in a voice that is not its maintainer's, and
until now nothing said so. A reader's default assumption about a README is
that the person on the copyright line wrote it, and here that is not true.
The note is the maintainer's own words, as a quote. The first version of this
was written about the AI voice in the AI voice, which is a joke the reader
gets to have at the project's expense rather than with it.
Placed above the "Developed in dejima" badge, which now closes the opening:
the note says what the badge means for the words, so it reads before rather
than after it.
Names the contrast with ATTRIBUTION.md, which states the artwork is
human-made with no AI tools. The two claims are opposite and both true, so
stating one without the other invites the wrong inference about whichever is
missing.
Pinned by a test: this is the kind of line that goes quietly in a tidy-up,
and nothing else in the repository makes its absence visible.
"Nothing about that is an attack, the change might even be good" framed an
agent pushing to a stranger's repository as a good deed with bad timing,
needing only the operator's sign-off. docs/responsible-use.md says the
opposite and says it at length: maintainers are already triaging a flood of
unsolicited AI-generated pull requests, and adding to it is a cost imposed on
someone who did not ask. Two documents in one repository should not disagree
about that.
It now says the block exists so the push cannot happen, not so it can be
approved first, and points at the fork-and-review workflow as what to do
instead. The "not an attack" clause stays, because the misreading this whole
section exists to correct is that the block is containment - but it no longer
slides from "not an attack" to "therefore harmless".
The test pins the corrected framing rather than the phrasing, so a future
edit that softens it back fails.
The section described the mechanism - GitHub allowlisted on 443, port 22
unreachable - and left the reason to be inferred. An agent working on
dejima-ci-hardener inferred the wrong one: that it is containment against a
hostile session. It is not, and reading it that way makes the default look
like something to switch off once the "real" protection is in place.
The principle, in the words dejima-ci-hardener settled on so the two do not
drift: a session that is not being used to push to a forge should not be able
to push to it. Your configured forge is the one you chose. GitHub is where
everything else lives, so that is where the rule bites by default.
The case is an eager agent rather than a compromised one - it finds a real
bug in an unrelated project and helpfully pushes a fix to somebody else's
repository. Nothing about that is an attack, the change might even be good,
and it is still a decision that was the operator's to make.
Closing the CI chain follows from the principle rather than being the reason
for it, and the docs now say so in that order.
It failed once on CI and left nothing to diagnose: `[ "$status" -eq 0 ]` and
no curl exit code, no HTTP status, no way to separate DNS from the ipset from
the far end. Working out that it was transient took reading the rest of the
run rather than the failure itself.
The rest of that run is what settled it. Tests 12, 13, 14 and 32 passed, and
12 reaches GitHub on port 22 through the push grant - which cannot happen
unless github-domains holds real addresses. So the firewall was correct and
one five-second connect to a live third-party service was not, which is a
thing that will happen again.
Three attempts, and only because this is a positive assertion: more attempts
can only turn a pass into a pass. The unreachability assertions either side
are deliberately left alone - retrying one of those could hide a hole that
opens intermittently, which is the opposite trade.
On failure it now prints the exit code and the last output, and says how to
read them: 6 is DNS, 7 or 28 is the route or the firewall, and an HTTP code
means the connection worked and something else is wrong.
docs/responsible-use.md is mostly addressed to the operator - which
repository to point the sandbox at, what else is in the directory, what to
merge. Three of its points are things the agent itself decides, and it only
knows them if it is told.
When GitHub push is enabled - by the flag, or by forge = github under a
pushing workflow - the briefing says the grant is for the repository the
session was pointed at and nothing else, and that an unsolicited pull request
elsewhere is a cost imposed on maintainers rather than a contribution. The
default exists for that reason, so lifting it hands over the ability without
the reason unless something supplies it.
Two more go to every session, since neither depends on a forge. A domain can
be an action rather than a source - a deploy hook or a production API acts
the moment it is called, with no diff to review afterwards - so an agent
should not ask for one to finish a task. And the project directory is mounted
whole, so it can hold a .env or a dump that is not code: say so, do not open
or send it.
Kept to eight lines between them. This text is in every user's context on
every launch, and the scope-selection advice already there covers the rest of
the allowlist point.
.dejima.md gets the two that bind an agent working ON dejima, where there is
no global context cost: .dejima/allowed-domains is read from origin/main by
dejima-ci-hardener and treated as reviewed, so a domain added there widens
every future CI job; and security here is the product rather than a later
pass.
README:
- The Loki bullet read as though shipping were the default. It is off unless
[logging] loki_url points at an instance of your own, and there is no
endpoint of ours in it - which a reader who does not know Loki cannot be
expected to assume.
- "So this one is work on the runner" -> "Preventing this requires hardening
your CI runner".
- New bullet for the ~/.claude isolation. A session gets its own project's
transcripts, memory and edit history and nothing else. That is a property
users should be told about rather than a fix buried in security.md.
- "One maintainer, no support" moves below the feature list. It interrupted
the run from what dejima is to what it ships, and reads better once the
reader knows what is on offer.
ATTRIBUTION.md scopes the trademark claim to the software. dejima is a real
island in Nagasaki harbour, rebuilt and standing today, and a file that reads
as claiming the name of a real place is both wrong and rude. A mark attaches
to particular goods and services, never to a word in the abstract, so the
terms now say so and disclaim the historical and geographic senses outright.
The artwork restrictions stay absolute; the name restriction is about being
taken for this project, which is the only sense in which a name can be
claimed.
Also compressed: tarball dependencies, ci.md's runner-reachability section,
and providers.md's validation rules. Net -314 lines overall.
Same rule as the comment passes. The exit-time audit section was half of
security.md and had grown a second explanation of itself alongside the first:
the enumerated exec keys, the withholding rules and the multi-value git config
case all stay, since each names a decision someone would otherwise undo. The
retelling around them went.
The test-suite bullet no longer counts tests. "Over 300 bats tests across six
files" was wrong within a week of being written and drifts every time a suite
is added, which is the argument against numbers in prose that nothing checks.
Rebased onto main, taking the README tagline edit and #112's worktree
paragraph, which landed in a bullet this pass rewrote. Both are kept - the
worktree text is compressed to match the surrounding register rather than
dropped.
Second pass, same rule: the fact that stops a regression stays, the retelling
goes. Net -272 across 21 files now.
Mostly file headers this time, which had drifted into explaining the design
twice - once to justify it and once to describe it. dejima-ttyd's flag notes,
merge-guard's, gitea-actions', the config schema's, and the two Dockerfile
blocks all say the same things in half the lines.
The load-bearing detail is untouched: --check-origin's WebSocket
same-origin argument, reqOwner() being unfixable by widening a token, the
port-443 restriction being the read-only guarantee, and the ERR trap's reason
for existing all read the same.
Net -195 lines across 13 files. Every comment here still says the thing that
stops the next person reinstating the bug: the issue number, the failure mode,
the reason a check is written the way it is. What went is the narrative around
it - the retelling of how a bug was found, consequence chains spelled out to
their third step, restatements of what the code above already shows.
Nothing was cut because it was long. The blocks that survive at length are the
ones where the reasoning is the content: the O_NOFOLLOW open in
dejima-project-lib.sh, the id-derivation anchoring in dejima-audit, the
enumerated ~/.claude mounts.
Two operator-environment details are gone: a directory size and checkout count
from the machine this was written on, in bin/dejima and docs/security.md. They
were useful for judging severity in a handoff and have no business in a public
repository - the same distinction dejima-ci-hardener's addresses.bats exists
to keep.
The no-support notice said nothing was promised, which reads as a statement
about responsiveness rather than about maturity. Someone could take it at
face value and still conclude the software itself is finished.
It is not. Nothing here has been through outside review, an external audit,
or adversarial testing by anyone but its author. The reasoning is one
person's and the tests assert what that person thought to check, which is a
weaker thing than it can look like from a repository that argues at length
for its own security properties.
In all three places a reader lands: the README opening, above the no-support
block; SECURITY.md, as its own section ahead of the support and warranty
terms, since that file is canonical for the organisation; and the top of
docs/security.md, which is a thousand lines of confident claims and the place
over-confidence is most likely to take hold.
Pinned by a test. This is the first line that gets quietly dropped as a
project starts to feel established, and dropping it should take a deliberate
edit to a test rather than an oversight.
#98971a was gruvbox's faded green, chosen from the palette rather than from
the mark. Sampling the wordmark gives #8ec07c for the island tile, so the two
now agree. Dark text on it is 7.0:1 rather than 4.8:1.
It sat under the wordmark, where two marks stacked read as branding rather
than as a statement about the project. Last thing before "The name" puts it
after the reader knows what dejima is, which is where the badge means
something.
The link goes to the project, not the badge repository - what a reader wants
from it is dejima, and that is what the badge repo already tells everyone
else to link to.
Everything between the command and the container's first output is silent -
the audit snapshot, an image build that prints nothing when every layer is
cached, and Docker creating the container with a mount per allowed path. Long
enough to read as hung. Two lines to stderr now say what is happening, and
name the first-run case specifically, since building the image takes minutes
and is when someone is most likely to interrupt.
Placed after --print-config and --print-briefing exit, so machine-readable
output stays machine-readable on both streams.
The badge is dejima's own, as the first project to carry it. The SVG is
copied into assets/ rather than hotlinked or fetched from shields.io: a badge
in a README should not make every reader of it fetch from a third party, and
that goes double for a project whose argument is about not trusting reach you
did not choose.
test/static.bats' image check now covers markdown image syntax and svg, not
just <img> and png - the badge is referenced the markdown way, and would have
sat outside the check that exists for exactly this.
A worktree isolates the working tree; `.git/config` is shared with the parent
checkout. So a bare `git config <key> <value>` run inside a worktree writes
the user's real repository config. That happened: an implementer set
`user.name` while reproducing a fixture by hand and misattributed three
commits, noticed only because a later subagent read an odd author off
`git log`.
The briefing tells a session to work in a worktree, and says nothing about
what a worktree does not cover, which reads as more isolation than is on
offer. It now says both, and gives the scoped form.
In `_briefing_core_worktree` rather than a workflow's own prose, so a
workflow can opt into worktrees but cannot ship them without this - same
reasoning as the containment paragraph already there.
A guard, not a boundary, and the doc says so. `.git/config` sits in the
read-write project mount and cannot be made read-only without breaking
`git push -u`, which writes `branch.<name>.merge` on every push. Detection is
what covers a session that does not care: the audit already watches that file
for `core.hooksPath`, `credential.helper` and the other exec keys the same
unscoped command reaches, and is how this was found.
Closes#32
The nvim data, state and cache directories are Docker volumes rather than
host paths, so nothing a session installs has ever reached the host's own
nvim. They do outlive the container, which is the point - a plugin install
should not happen on every launch - but they carried one fixed name each and
so were shared by every project on the machine.
That made them a persistence channel between sandboxes. A session writes a
Lua file under `lazy/`, or replaces a binary under `mason/bin/`, and the next
session to run nvim in the sandbox executes it, whatever project that session
is working on. `mason/bin` is the sharper half: those are invoked directly,
with no load path to arrange and no plugin to enable. It is bounded by the
container - a payload there runs in another sandbox, not on the host - but
cross-project persistence is what this repository closes everywhere else.
Scoped per project, the same way the derived image tag already is. The
service still refers to the static compose keys; only the volume Docker
creates varies, and the defaults reproduce the previous names so a bare
`docker compose` behaves as it did.
The `dejima_nvim-*` prefix is load-bearing rather than cosmetic: project_slug
maps a leading `/` to `-`, and a Docker volume name may not start with one.
Without it every launch would fail inside `compose run` rather than anywhere
that names the cause, so there is a test for it.
Reading another checkout while migrating code out of it, or writing to a
scratch directory for one session, meant editing the config file and then
remembering to edit it back. These do it per launch: --extra-readonly PATH
and --extra-read-write PATH, short forms --ro and --rw, both repeatable.
They append to [mounts] readonly and [mounts] read_write rather than carrying
their own mount code, which is the design rather than an implementation
detail. Everything that already applies to a configured mount then applies to
a flag with nothing duplicated and no second path to keep in step: the
existence check, the refusal when a path overlaps the approval store or the
audit stores, the refusal when one path appears in both lists, and the
read-write paths named in the session briefing and the audit report header.
A flag says where, not whether, and no flag waives a refusal.
Two differences from the config form, both because a command line is not a
file. A relative path is accepted and made absolute against the working
directory - by prefixing rather than by readlink -f, so a symlinked path is
mounted where the operator typed it rather than at its target. And a path
containing a colon is refused: the lists are colon-separated, so it cannot be
represented in one and would otherwise split into two mounts that each fail
the existence check.
The refusals name the flag rather than the config key when a flag supplied
the path, since "[mounts] readonly entry" sends someone to a file that does
not mention it. That record is kept at parse time: the lists are assembled
inside a command substitution, whose subshell would discard it and report
every flag as a config entry.
The tree was bind-mounted read-write in full, and each
`<session>/<hash>@v1` in it is a verbatim copy of an edited file. So it is
other projects' source rather than a record about it, which makes the read
side worse than the transcripts #104 covered: on the host this was developed
on, 47M across ten checkouts, including a dotfiles repo. The write side puts
chosen content into a later restore in another project.
A launch now gets the sessions belonging to its own workspace and nothing
else - 11 directories here instead of 50.
Keyed by session id, so unlike projects/ there is no slug in these paths to
scope by. The transcripts supply it: projects/<slug>/<id>.jsonl and
file-history/<id> name the same session, so the slugs a workspace owns select
the session ids it owns. That routes through workspace_slugs_sorted, so the
sibling case is excluded here for free - a naive count of "dejima's" history
says 12, and one of those belongs to dejima-ci-hardener.
The session about to start has an id nothing can know in advance, so its own
directory is container-local and does not survive the run. That costs undo
history for that session if it is later resumed on the host. It is the one
respect in which this cannot be scoped as tightly as projects/, where the
workspace's own slug is known at launch, and it is why the image pre-creates
file-history/: without it Docker creates the parent root-owned while setting
up the mounts nested in it, and the session cannot record its own undo entry
at all.
The static test asserting that compose still mounts the tree, and the
Dockerfile mkdir test whose regex is anchored to the RUN line, both had to
move with it.
Two mount paths reached `extra_mounts+=(-v ...)` with a path computed at run
time, and neither was checked: a `[mounts] readonly` entry naming the store,
and a ~/.claude symlink whose target directory sits inside it. Reproduced
directly against main - `readonly = <approval store>` produces
`-v <store>:<store>:ro` and the launch exits 0 with nothing said.
The static test guarding "the approval store is never mounted into the
container" greps bin/dejima for a literal $PROJECT_STORE or config_root, so
it could not see either one and passed throughout. That is what #15 named.
Read-only is not enough to make those mounts harmless. The approval store
discloses every project's approved domains and dependencies, and the audit
baselines say which findings were already waved through.
`[mounts] read_write` has been guarded since it was added. The guard is now a
function, and the two unguarded paths call it with the stores alone - not
read_write's wider list, which also covers $REPO_ROOT and the workspace,
because mounting those read-only is ordinary and expected.
The half of #15 that is already closed: the symlink is no longer
session-plantable. It required ~/.claude to be mounted read-write, and the
enumerated-mounts rework made the five scanned directories read-only.
Verified from inside a running container - every one of them refuses a
symlink with "Read-only file system", and ~/.claude itself is container-local.
So what is left is an operator's own symlink, and the invariant.
Closes#15
The credit moves out of the opening and into the License section, naming the
part it applies to. docker/init-firewall.sh, docker/Dockerfile,
hooks/sandbox-context.sh and docs/security.md carry it at the point of use
and are unchanged.
bin/dejima warned that ssh-agent had no identities loaded even when
[core] ssh_key was set, and told the operator the push would fail "unless
[core] ssh_key is also set" while it was. With a key configured the premise
does not hold: the key is loaded into the container's own agent and pushes
work without the forwarded one carrying anything. The warning is now silent
in that case, and its last line points at the key rather than treating it as
an afterthought.
Two tests in config.bats, driving a real ssh-agent with nothing added to it,
since a handmade socket does not reproduce the `ssh-add -l` failure the
warning keys on. The first fails against the previous behaviour.
Two tests need an exit-time audit that fails, and they got one by writing a
two-line `exit 3` stub over `$REPO_ROOT/bin/dejima-audit` and restoring it
from a backup afterwards. Three things wrong with that:
A run killed partway never reaches the restore, so the checkout is left
holding the stub. Two runs of this file at once are worse: the second backs
up the first's stub as the "real" script and restores that, permanently. A
suite that passes while silently disabling the exit-time audit is the exact
failure this file exists to prevent.
And it reads like an attack. A file watcher that samples the tree during the
window sees a security tool replaced by `#!/bin/bash` / `exit 3`, with no
indication anywhere in the bytes that a test put it there. That is worth
avoiding on its own: a real compromise should not have somewhere innocent to
hide, and an alarm that fires on ordinary test runs is an alarm that stops
being read.
Tests now mutate a throwaway copy under $BATS_TEST_TMPDIR instead. bin/ is
copied rather than symlinked, which is load-bearing twice over: bin/dejima
resolves its own location with readlink -f, so a symlink would send
SCRIPT_DIR back to the repository; and `cat >` through a symlink truncates
the target, so linking would have written the real script anyway. The first
draft of this change did link, and the new teardown_file guard caught it
overwriting bin/dejima-audit on the first run - which is the argument for
having the guard.
The stub also says what it is now, so the alarming version of it explains
itself if anything ever does surface it.
Two guards, because the fixture is not the only way to get this wrong:
teardown_file hashes bin/dejima-audit around the whole file, and a static
test refuses any redirect or removal aimed at $REPO_ROOT or
$BATS_TEST_DIRNAME from any suite.
The tree holds every project's transcripts and agent memory, mounted
read-write. Memory is loaded into context at session start, so a write into
another project's memory/ and its MEMORY.md index is text a future session
there reads as established background rather than as input. No container
escape and no forge credential were needed for that; the mount was enough.
A launch now gets the workspace's own slug plus one per live git worktree
nested under it, and nothing else. On the host this repo was developed on
that is 17 directories instead of 71. Sibling projects are absent rather than
present-but-unwritable, which is the first-run state Claude Code already
handles - the risk that a read-only projects/ would hang it, the way a
blocked ~/.claude.json write did, does not arise, because projects/ itself
stays container-local and writable.
The set comes from real worktree paths, not from the slug string, and that is
load-bearing. A slug cannot tell a child from a sibling: /w/proj/wt1 and
/w/proj-wt1 produce the same one. The audit's existing "$slug"-* glob
therefore treated any project checked out beside this one whose directory
name merely extends it as part of it - on this host, a session in dejima had
dejima-ci-hardener's writes silently exempted from cross-project reporting.
Sharing that glob with the mount would have carried the hole straight into
it, so both now call one function in bin/dejima-slug-lib.sh. Hosts with
sibling-named projects will see CROSS-PROJECT findings they were not getting
before; those are real writes, not new false positives.
Detection is unaffected: dejima-audit runs host-side over the whole tree, so
narrowing the mount does not narrow what it can see. It now covers what the
mount cannot reach, which is concurrent host sessions.
The runtime assertion could not be run here - this sandbox has no Docker
daemon - so the privileged CI job is the first thing to execute it.
Closes#104
A leading on the same line as the tag puts the image inside a
paragraph and indents it by one space. hspace would be the obvious way to do
that and is the wrong one: it survives Gitea sanitizing and is then ignored by
every browser that dropped HTML4 presentation attributes, so it reads as
working in the source and does nothing on the page.
The <br> did not render. A break at the end of a block ends a line that
nothing follows, and browsers give that no height, so the tagline stayed
where it was. survives Gitea sanitizing as a real U+00A0, which means
the paragraph holding it has content and occupies a line. Verified against
the instance markdown endpoint: the rendered output is <p>\xa0</p>, not a
collapsing space.
An image alone between two blank lines is not wrapped in a paragraph, so it
has no bottom margin and the tagline sits right under it. `style` is not the
fix: Gitea's sanitizer strips it from the tag and from any wrapper, verified
against the instance's own markdown endpoint, and `vspace` survives
sanitizing only to be ignored by every browser that dropped HTML4 presentation
attributes. A `<br>` renders on both Gitea and GitHub, so that is what this
uses, with the reasoning in the comment above the tag.
The prose leaned on a handful of constructions hard enough to be a tell,
mostly the "X, not Y" antithesis as a closing beat and the meta-commentary
that tells a reader how to read the sentence they just read. Where the
contrast carries information, and in a security doc it usually does, it
stays; where it was rhythm, it goes. "worth knowing" went from nine uses to
none, "which is the point" and "the whole point" from three to none, and the
filler senses of "actually", "simply" and "precisely" are gone while the
load-bearing ones (nominal versus real) are untouched.
No claim changes. The agent-voice section is deliberately untouched, since
its whole premise is that it was written by one.
Provider axes is now three bulleted lists rather than a run-on sentence
naming seven directories, and ATTRIBUTION.md credits gruvbox for the palette.
The README points at one wordmark file unconditionally rather than switching
on `prefers-color-scheme`, which reports the browser's setting and never the
site's. A forge that renders dark whichever way the browser is set would then
show dark ink on a dark page, silently, to a reader whose browser prefers
light. Both files ship, so a mirror on a light-themed host changes one line
where someone can see whether it worked. The suffix names the mode a file is
for, not the colour of its ink, so `-darkmode` is the pale one.
ATTRIBUTION.md and SECURITY.md are canonical for the whole organisation and
live here because this is the repository the others link back to. Keeping one
copy is the point: the terms cannot drift into two divergent statements of
who holds the marks, and a reporter cannot find two different addresses to
send a vulnerability to. dejima-ci-hardener links to both by absolute URL, so
a rename here is a 404 in a repository whose tests cannot see this one, which
is why static.bats pins the two filenames.
The README's opening now says there is one maintainer and no support, where
somebody deciding whether to deploy this sees it before investing effort.
Both repositories moved out of the personal namespace and into a `dejima`
organization, so every link into either one is now one path segment wrong:
four to dejima-ci-hardener and one to this repository's own issue #97.
Gitea redirects an old path, so none of these were broken. They were merely
lying about where the thing lives, which is the kind of stale that survives
indefinitely because nothing fails.
Only the organization segment changes. The host stays `gitbox.branderson.io`,
which is the name a reader resolves; the git remote uses the internal
`gitbox.bradina.localdomain` and is not a documentation concern.
Nothing in `bin/`, `test/` or the workflow hardcoded the old owner, so this is
documentation only. The forge CLIs read owner and repo out of the git remote
rather than from anything committed here, which is why moving a repository
does not require a code change to keep them working.
Two tiers were carrying three sets of names. dejima-ci-hardener called them
confined and privileged, the runner labels called them dejima-ci and
dejima-runtime, and the workflow and tests called them sandbox and host-docker.
Someone reading a red `runner-env (host-docker)` and then opening the runbook
to fix it had to map three ways before they could act.
Now: `dejima-confined` and `dejima-privileged` as labels, `confined` and
`privileged` as job names and DEJIMA_CI_TIER values. The hardener's runner
generator derives its default label from the role it was given
(`dejima-$ROLE`), so accepting that default produces exactly the strings this
file names, and neither side is typed from memory.
Deliberately not a substitution. `sandbox` in this repository overwhelmingly
means the dejima container, not a CI tier: git-push-guard.sh, providers.md,
security.md, responsible-use.md and most of .dejima.md all use it that way and
are untouched. Only the tier sense moved, and test/ci-runner.bats no longer
contains the word at all, which is right for a file that is entirely about
tiers.
`/tmp/dejima-ci` stays as it is. It is a path the privileged job bind-mounts,
not a label, and renaming it would need the runner's filesystem to change in
the same breath.
## This does not take effect until the runners are relabelled
`runs-on:` matches labels a registered runner actually carries, and under
pull_request_target the workflow is read from main. So from the merge until
both runners come up as dejima-confined and dejima-privileged, jobs queue
rather than fail, and Gitea has no concurrency: to clear them. That downtime
is accepted: nobody else ships here, and dejima-ci-hardener cannot put two
labels on one box without hand-editing a generated runner, which would defeat
the point of generating it.
Note also that this file cannot test itself. pull_request_target reads the
workflow from the base branch, so this change is exercised for the first time
after it lands. static.bats asserting on DEJIMA_CI_TIER's values is what
catches a half-done rename before then, and it is updated here.
- A git push credential is not a prerequisite. The default workflow is
`local-only`, which pushes nowhere, so nothing about getting dejima running
needs one. It moves to Setup, next to choosing a pushing workflow, which is
the point at which it becomes required. The ordering it gained last round
survives the move: a key of the session's own first, a forwarded agent as
the fallback with the cost of authenticating as you stated.
- The loop section says dejima itself is developed under `worktree-pr`, so
this repository is that workflow in use rather than a description of one.
- configuration.md drops "expect the session to be live". A reader
configuring a project has no reason to hold that; it is dejima's
implementation that has to assume it, which is why the reasoning lives in
security.md, where it stays.
- The Grafana section covers the conversation stream, which had queries for
DNS and connections only. Selecting it, filtering by role, by tool, and by
session, plus why `session` is a field rather than a label, and what is
deliberately absent: tool results and thinking blocks are never shipped, so
a query shows that a session ran a command and never what came back.
- "Code you couldn't should wait for containment you trust more" made this a
question about trusting dejima. It is not. There is no containment that
fully prevents exfiltration: a determined attacker with code execution and
an outbound path will find one, and an allowlist narrows paths rather than
closing them. So the question is whether you can accept the risk of the
contents leaving, and if you cannot, the answer is not to hand them over.
- "Auditing the directory takes an extra minute" was wrong about the cost and
wrong about the method. Reading a working tree by eye is not a minute's
work and succeeds every time until the once it matters. Point dejima at a
fresh clone: it contains what is in version control and nothing else, so
the whole class of stray uncommitted files goes away rather than being
searched for.
- The allowlist section buried its own rule in a distinction. It now opens
with it: for every allowlist entry you give an agent, consider what an
attacker could do with it. The worked examples follow as the reason the
answer varies.
- `main` is named as "(or `master`, or whatever your default branch is
called)", so this does not read as dejima assuming one branch name.
- "Safe and responsible are not the same thing" overlapped the final point
about dejima's security saying nothing about your software's. This one is
about the merge specifically: what makes a default branch different is that
other things read it and believe it. The general code-quality argument
belongs to the other point and is left there.
- The retrofit point goes further, because the important half was missing.
Security is not something a future agent can add either. Early
architectural decisions close off the fixes for whole classes of problem,
and a later pass can find a missing bounds check but cannot move a boundary
the system was built on top of. dejima makes writing a lot of code fast,
which makes writing a lot of insecure code fast if nobody is holding that
concern, and holding it is the reader's job at every step rather than a
phase to schedule.
configuration.md:
- "least specific first" said the order without saying what the order does.
The layers add rather than override, so a narrower layer can only widen
what a broader one permits and the result is a union. Read the old wording
cold and you would guess the opposite.
- The historical justification for `forge = none` being the default is gone,
along with the changed-default blockquote. Nobody needs the argument for
why we do not ship a broken default, and install.sh covers the migration
for the handful of configs it could apply to.
- Tarball digests: how to get one. Fetch and hash on the host, and prefer a
published checksums file, because that tells you the archive is the one the
project shipped rather than the one your connection produced.
- "a mixed-architecture fleet needs a spec per project" implied a feature
that does not exist. A tarball dependency locks the project to one
architecture, full stop, with #97 filed for per-architecture specs.
- The symlink paragraph was a vulnerability deep-dive in a configuration doc.
What a reader here needs is that both writers refuse a symlink and that the
refusal is the open itself. The attack and the O_NOFOLLOW reasoning move to
security.md under a heading of their own.
- "Config parity with your host Claude Code setup" becomes "...with your host
agent harness setup", with Claude Code as a subheading, so adding a second
harness does not mean rewriting the heading every link targets. Headless
git push is promoted back to a top-level section: it was nested under
Claude Code by that change, and it is harness-agnostic.
- Claude Code is named only where it is actually specific. The .dejima.md
passage keeps it, because that passage exists to explain why a
harness-neutral name is needed.
- The Dockerfile caveat pointed at editing the base image. That is what
dejima-dep is for, and a dependency added through it is declared, approved
and survives a rebuild.
- Headless git push led with running unattended. The reason that matters is
identity: a forwarded agent authenticates as you. Uptime is the lesser,
second reason now.
- ~/shared says to clear it out periodically, for the same reason as the
README.
security.md: the WARNING git-config tier is described by what it does rather
than by what it used to fail to do.
CONTRIBUTING.md says the thing that is true: this is developed on a private
forge that allows neither account registration nor anonymous issues, so
there is currently no way for anyone outside to file either. That will
change. Until it does, fork it and change your fork. It also warns that a
fork taken today diverges quickly, which is the part worth knowing before
building on one.
README, from review:
- "Check whether they want it first" had no antecedent for "they". It names
the project, and points at dejima's own CONTRIBUTING now that there is one.
- The push credential listed agent forwarding first. That is backwards: a
forwarded agent authenticates the session as you, which is the arrangement
docs/security.md argues against two sections earlier. `[core] ssh_key` with
an identity of its own leads, forwarding is the fallback, and the cost of
the fallback is stated rather than left to be discovered.
- The "typical loop" was one workflow, and not the default one. A reader
would have concluded that pushing and opening a PR is how dejima works. It
now leads with the part every workflow shares and names what each does
after that.
- The merge-guard paragraph asserted the hooks do not stop a hostile session,
unconditionally. That only holds if you gave the session a token that can
merge. Stated conditionally, with a token scoped below merge named as a
control alongside branch protection.
- "Every session ends at something you read before it lands" was still
workflow-shaped: under local-only there is no pull request to read. Replaced
with what is true of all three, that none of them puts work on your default
branch.
- The session briefing was described as telling the agent to push and open a
pull request. It carries whichever workflow's instructions were selected.
- ~/shared says to clear it out between sessions. It persists by design, so
anything forgotten there is handed to every session after it.
- The approval sentence takes the reviewer's wording: approval is a separate
act you do yourself on the host, by hand, and approvals are stored outside
the reach of the session.
The first line was "Unsupervised agentic development you can take
responsibility for." Responsibility is something this project argues for, in
a section that exists precisely because nothing in the software enforces it.
dejima works fine for careless use. A first line implying otherwise claims a
property the tool does not have, which is the same overclaiming the merge
guard paragraph was just corrected for.
What dejima is actually built on is narrower and more distinctive: let the
agent implement unsupervised, and assume it is hostile while it does. Not
fallible, hostile. It can be turned mid-session by a prompt injection or a
poisoned dependency and will not announce that it has been, so the design
question is never whether to trust it, only what it can reach when it should
not be trusted.
The paragraph under it followed the old framing and described mistakes, which
undersells the premise, so it now describes the case the container is
actually for.
PR #95 left the docs alone deliberately, with this rewrite in flight, and
listed what the rewrite should pick up. This is that.
Approve and ignore were doing different things and the prompt made them look
identical, which is the confusion #95 fixed in the tool. Saying it in prose
is the other half: approving records the exact content, so any later change
re-fires; ignoring records the surface, so that key is fine whatever it later
contains. Approve a hook you have read. Ignore a key some tool rewrites every
run. Skipping decides nothing and asks again, which is the right default for
anything you are unsure about. The keymap is quoted so the doc and the prompt
agree.
A WARNING-tier git config change is now curatable, which the baseline
discussion here predates. It used to be the one class of finding with no id
to record a decision against, so keys that get rewritten every run re-fired
forever and pushed real findings out of the capped list.
Also states what neither verb can do: severity is decided by what the surface
is, an exec key or a shell alias is CRITICAL whatever the baseline says, and
an ignore entry is refused against a CRITICAL surface when the baseline is
read rather than only when it is written. A curation mechanism that could
quietly downgrade something is worse than none, so the limit is worth stating
where the mechanism is.
The audit discussion had no heading of its own, sitting unmarked inside a
five-hundred-line section, so nothing could link to it. It has one now.
Two other things ride along. The note that `--allow-github-push` is a
per-launch addition rather than a substitute for selecting a forge, preserved
from the README's Prerequisites tail. And three em-dashes this branch's rebase
onto main brought back into this file, cleared to match the rest of it.
Prerequisites ran to about a hundred lines, most of it a thirty-two row
config table, and it sat between the responsible-use section and Setup. A
reader who wanted "what do I do" had to scroll the entire schema to reach it,
and the schema is the one thing nobody reads top to bottom: they arrive at it
looking for one key.
What is actually required stays: Docker, python3 for the credential proxy,
and a git push credential. Then a sentence saying everything else is optional
and lives in one file, and a link. Twenty-two lines, and Setup is next.
The table moves to a Configuration reference section in docs/configuration.md,
which is where someone editing their config already is. The reasoning for why
no forge is the default, and the changed-default migration notice, go with it.
The trailing "to push, set [core] workflow" paragraph is deleted rather than
moved: Setup's Core block already says all of it. Forgejo working unmodified
is still in the README. That `--allow-github-push` is a per-launch addition
rather than a substitute for selecting a forge is preserved separately, in
docs/security.md next to the posture it modifies.
Moving the table also moved the only mention of `[audit]` out of the README,
which test/config.bats caught. The test is right, and not merely about a
string: the feature list advertises an exit-time audit, and the README would
no longer have said what it watches, that it is advisory rather than a block,
or what `[audit] strict` gates. It gets a section, which is also where the
approve-versus-ignore distinction belongs in short form.
bin/dejima names docs/configuration.md in user-facing diagnostics, and
bin/dejima, bin/dejima-anthropic-proxy and bin/dejima-audit all name
docs/security.md the same way. Before the docs restructure these named the
README, which cannot be renamed; now they name files that can be, and a
rename would silently send an operator nowhere. Follow the shape of the
existing docs/remote-access.md path test in test/remote.bats: read the
referenced path out of the script rather than restating it, generalized to
walk every docs/*.md reference actually present rather than hardcoding the
two names again.
Four references pointed at headings that used to be in the same document
before content moved into README.md/docs/configuration.md/docs/providers.md
during the restructure: a deleted README section, two headings that now
live in docs/configuration.md, and one that lives in the README, wrongly
pointed at with 'above'. Turn each into a real markdown link to where the
target actually is, and clean up the double-semicolon left by the em-dash
removal in the GitHub-forge reference.
The merge-guard claim, the GitHub-forge push cost, the writable-host-surface
answer, and the GitHub-push HTTPS exception all had their qualifying detail
moved into docs/ while the unqualified claim stayed in the README. Restore
each qualification in the README's own voice, next to the claim it modifies,
and link to where the fuller detail now lives. Also anchor the two
docs/security.md and docs/configuration.md links that already had a correct
anchored form available elsewhere in the README but weren't using it, and
correct the GitHub-forge Setup row to agree with docs/providers.md and to
point at the section that actually has the steps.
security.md pointed at a README subsection that became a Setup table
row; link the providers.md section that actually documents selecting
the GitHub forge instead. development.md pointed at a README section
that moved into security.md; link the heading it moved to. ci.md
pointed at a ci/ directory that does not exist in this repository;
point at dejima-ci-hardener, which is where that configuration
actually lives and which the rest of the file already links.
Each em-dash was repunctuated by the job it was doing (colon for an
explanation, semicolon or a split sentence for two independent
clauses, parentheses or a comma pair for an aside, a plain comma for
a contrastive tail), not swapped for a single character throughout.
One sentence in README.md's Logging bullet was rewritten rather than
repunctuated, because the em-dash there was covering a fragment with
no verb of its own.
README.md ends at 5, docs/ci.md, docs/security.md and
docs/remote-access.md at 0. All 5 remaining are inside "An agent's
perspective on working inside", which is explicitly exempt and left
untouched. That is one more than the four this task's brief expected
there: direct measurement shows the section holds five, not four (one
line carries two). Per the exemption rule against editing so much as a
character of that section's punctuation, none of the five were
touched; the sweep of everything outside the exempt zone is still
clean.
Moves the Network egress firewall, Network request logging, and GitHub
push sections (with their subsections) from README.md into
docs/security.md, after the existing Provider trust section, leaving
load-bearing stubs behind for the two security-guarantee sections and
an operational summary for logging. Repoints every anchor into the
moved headings (README's own Setup section, docs/configuration.md,
docker-compose.yml's comment) at their new home, and restates the
"deliberately do not touch a running container" guarantee in the
firewall stub since test/static.bats checks for it in README.md
itself.
`gitea-pr comments` printed every inline comment a pull request had ever
received, resolved or not, in one flat list. On a PR reviewed in several
rounds that is mostly noise: the reader has to work out which comments are
outstanding by recognising which passages have already been rewritten.
The deciding field is `resolver`. A resolved comment carries the user who
resolved it; an open one carries null. Neither the review's `state`
(COMMENT vs REQUEST_CHANGES) nor whether comments arrived grouped under one
review says anything about it - both describe how the reviewer chose to
submit, and reading either as a resolution signal works until someone files
differently.
The catch, and the reason this is not a one-line filter: **Gitea stamps only
a thread's root comment.** A reply inside a closed thread comes back with
`resolver: null`, so judging each comment alone reports a thread you already
dealt with as still open. That happened while reading review feedback on #96:
a closed-and-refiled thread's reply presented as outstanding, and the passage
it pointed at had already been rewritten.
So a thread is every comment sharing a path and a diff position, and it is
resolved if any of them carries a resolver. Unresolved threads print by
default; `--all` includes the rest, marked. When anything is hidden it says
how much, because a list that silently shrank is worse than a long one.
## Also
.dejima.md gains the batching rule this session earned. Gitea does not
implement `concurrency:`, which the workflow file already records with a test,
so a second push to an open PR queues behind the first rather than cancelling
it. Pushing commit-by-commit while iterating queued enough runs to saturate
this project's forge: nginx could not open connections to Gitea, the requests
timing out were the runners' own UpdateLog and UpdateTask calls, and the
instance was down long enough to trip uptime alerts. Commit freely, push once.
The same section covers forge API reads, since `comments` costs one request
per review.
## Testing
Two tests, both confirmed to fail against the previous `gitea-pr` and at the
right assertions: the resolved-thread one on the reply leaking through, the
`--all` one on the missing marker. The shared curl stub now returns three
comments, two of which are one thread whose reply carries no resolver, so the
case that caused this is what the suite exercises.
static 204, providers 65. Both green.
`gitea-pr comments` rendered every inline review comment as `path:?`. Not
sometimes: always. The jq asked for `.line`, and Gitea's review-comment
objects have no `line` field, so `// "?"` was the only branch that could ever
be taken.
The effect is not cosmetic. A reviewer writing "this is confusing" or "let's
reword this" is pointing at a passage they can see and the reader cannot. All
that survived the fetch was the file. On a review of any size, several
comments land on the same file, and some of them say "this" about a phrase
that occurs more than once, so acting on the review means guessing which
passage was meant and sometimes guessing wrong. That happened on #96, on a
comment about historical content in a file with two candidate passages, and
cost a round trip to resolve.
The objects do carry what is needed: `diff_hunk`, which is the exact text the
comment was attached to, and `position`, the offset into the diff. Both are
printed now, with the comment marked off from the quoted code so the two
cannot be read as one block.
The hunk makes the output longer, and that is the right trade: scrolling
costs nothing, and guessing wrong costs a round trip through a human.
## Testing
The shared curl stub answered every read with `[]`, which meant `comments`
had no coverage at all: the reviews list came back empty, so the per-review
comment fetch never ran and the rendering under test was never reached. The
stub now answers the two review endpoints with shaped data and everything
else as before.
One test, and it was confirmed to fail against the previous `gitea-pr`,
at the assertion for the hunk text rather than incidentally.
static 202 and providers 65, both green.
Closes#65 and #45.
The headline bug was not what it looked like. Approvals were being written
correctly the whole time - `ack` simply never consulted the baseline when
deciding what to SHOW, so every later run re-presented the same CRITICALs
and appended a duplicate entry for each. It reads from the outside as
"exact approvals are not being recorded".
Two reports can also list the same finding, and approving it in the first
left the second permanently -CRITICAL.txt: bin/dejima then warned about a
report whose every finding had been dealt with, forever. The rename now runs
on a pass that records nothing new, so a report covered entirely by earlier
decisions stops counting as unacknowledged.
#45: a WARNING-tier git config change could not be acknowledged at all - no
`ack` arm derived an id for it, and neither branch of `compare` that emits
one consulted the baseline. It was the only class of finding with no way to
curate it, so keys some tools rewrite on every run re-fired every session
and pushed real WARNINGs out of the capped list. Safe by construction rather
than by care taken here: id_severity still calls an exec key or any alias
CRITICAL whatever wrote the entry, and baseline_suppresses still refuses a
`spam` entry against a CRITICAL surface at read time.
Findings that cannot be acknowledged at all - now only a cross-project write
- are presented once per report rather than on every run. There is nothing
to record for one, so a `reviewed <report>` line marks the report walked.
Only an un-ackable finding is governed by it; skip still means "ask again"
for everything else, which is the whole difference between skip and ignore.
Quitting does not mark it, since the report was not walked.
The keys: skip is [s], and the permanent option is [i]. Both prompts now say
what they do to future findings, because the difference between them was
invisible - approve pins this exact content and re-fires on any other,
ignore blankets the surface whatever it later contains, and the WARNING
prompt used to offer a bare "approve" that read as the same thing. The noun
follows the surface, so a git config key is not called a file.
Colour, from a small lib both bin/dejima and bin/dejima-audit source so the
launcher's warning and the review it sends you to agree about what red
means: severity on the tier, the file or key picked out of the label around
it, dim on the audit's own framing so it stops running together with the
content between it, and green/red on a retained diff. Off unless the stream
being written to is a terminal, and off for NO_COLOR and TERM=dumb - reports
and piped output are read back by machines, this suite included.
And the launcher's warning offers [a] to review right there. It already knew
the findings were unacknowledged and then asked the operator to go and run a
command in another terminal, at the one moment when nothing was waiting on
them: the container has not started yet. It loops, because `ack` walks one
report per run.
Tests: 7 new in audit.bats and 3 in static.bats, the four load-bearing ones
confirmed to fail against the previous dejima-audit. Suites green - audit
148, static 202, config 94, project 132, providers 64.
Docs are deliberately untouched: a README/docs rewrite is in flight, and
this belongs in that rather than in the version being replaced.
"Give the sandbox its own identity" said write access without saying that
write is the ceiling. An account that owns the repo, or holds admin on it,
can merge its own work and edit the branch protections in that section's own
table - so every row goes back to being decorative, which is the failure the
section exists to prevent.
And the consequence a reader will otherwise meet as a puzzle: parts of
Gitea's API are gated on ownership rather than permission, and answer a
collaborator with "user should be the owner of the repo" whatever scopes its
token has. That reads as a scope error, and the reflex - widen the token -
cannot work, while the thing that would work is handing the sandbox an owner
token and undoing the section. Named here with what gitea-actions does
instead, so the dead end is documented rather than rediscovered.
The sandbox holds a token for a dedicated account with push access and
nothing more, which is the point of having one. Gitea gates
/repos/{owner}/{repo}/actions/runs behind reqOwner(), so that account got
"user should be the owner of the repo" and every subcommand failed at the
first call. It is an ownership check, not a scope check - widening the token
cannot fix it, and the only thing that would is handing the sandbox an owner
token, which is the opposite of the point.
Three sources replace it, all of them plain repo reads:
actions/tasks one record per job, carrying run number,
head_sha, event and result
commits/<sha>/statuses the same jobs as statuses, and the only place a
job's index is visible to a non-owner
the web log route the run view's own endpoint, same token header
The job index is what addresses a log and is not a field anywhere - only the
tail of a status's target_url. Statuses are written twice per job and
filtered here by run number as well as commit, since a re-run puts a second
run's statuses on the same one.
Also drops `column`, which is not in the sandbox image: being last in the
pipeline, a missing one fails with 127 after the request has succeeded, so
`runs` and `jobs` have been broken in the shipped image while looking like
an API problem. Padded with printf like dejima-audit and dejima-attach.
Verified against the live forge with the dejima-agent token that could not
run any of this before: runs, jobs, logs by default/--job name/--job index,
and both not-found paths.
The new CLI tests build a throwaway repo and commit into it so that
create's `rev-parse --abbrev-ref HEAD` has a branch to report. `git commit`
needs an author, and a CI job container has no git config at all - it exited
128 with "Author identity unknown" and took three of the four tests with it,
while every local run passed on whoever happened to be configured.
Identity is now passed per-command, so nothing about the environment is
assumed. Verified by running the whole suite with HOME emptied and both
GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM pointed at /dev/null: 197 tests,
no failures, where the same run reproduced all three CI failures before.
The section had grown into agent instructions: which gitea-* subcommands
exist, why --body-file beats a heredoc, how deny-dangerous-bash.sh reads a
command string, when to write Fixes #N. None of that is something a person
running dejima does or needs - the agent gets it from the session briefing,
which is where it already lives.
What is left is the loop a human is actually in: point it at a project, ask
for a change, review and merge the PR it opens. Plus the one thing that
governs how far a session goes, which is the workflow setting.
Two halves of #79.
An agent that said it had updated a description had not, because there was
no way to: create was write-once and rewriting the file the body came from
sends nothing. Both CLIs gain `edit N [--title] [--body|--body-file]`, which
sends only the fields it was given - a fixed {title, body} pair would have
traded a silent no-op for a worse bug, every title-only edit wiping the
description. An edited body keeps create's provenance marker rather than
shedding it.
The hard-wrapping half is left to the briefing, deliberately. A tool that
reflows prose has to guess which newlines the author meant, and it will be
wrong about trailers, addresses and one-line-per-item text; these stay dumb
and pass the body through as written. The worktree-pr fragment now says not
to wrap, why (the forge renders a newline as a break, worst on a phone),
which breaks to keep, and that revising the source file sends nothing.
gitea-pr adopts gitea-issue's api_or_die and parse_body_args rather than
growing a fifth hand-rolled response check and a second copy of the body
parser. That is what makes create and edit agree about --body vs
--body-file, and it leaves the two files the twins they are documented to be.
First behavioural tests for any of these CLIs, against a stub curl: what
lands in the request, not what the source greps like. They cover create too,
which had none before its parser moved.
The list of what running an agent unattended takes is where a reader decides
whether this is a serious tool, and it ran to nine bullets without mentioning
the one exposure the container cannot close. CI came ~1000 lines later, which
is after the point where someone has already pointed dejima at a project and
given a session push access.
The bullet is deliberately the odd one out. Every other entry is something
dejima provides; this one is something it does not, and says so - the
container bounds what a session reaches directly, not what it can arrange to
have run elsewhere. Both destinations get named there rather than deferred:
docs/ci.md for the reasoning, dejima-ci-hardener for the tool.
Placed directly after the PR and issue workflow, which is where push access
is introduced and therefore where the consequence belongs.
GitHub's addresses live in github-domains, which init-firewall.sh opens on
443 and nothing else. That restriction is the read-only guarantee: with 22
shut there is no transport left for the forwarded ssh-agent's key to push
with, whatever a hook does or does not catch.
Two paths put addresses somewhere else. init-firewall.sh's extra-domains loop
and bin/dejima-allow's live add both wrote unconditionally into
allowed-domains, which carries no port restriction at all. So a project with
github.com in its .dejima/allowed-domains - this one has, and so does any
project whose CI runner needs GitHub, since the hardener reads that same file
- opened port 22 to whichever address GitHub returned at container start.
dns-proxy.sh already got this right and says so in a comment: dnsmasq applies
the longest matching ipset= line, so GitHub names reach github-domains no
matter which list allowlisted them. The other two paths never asked.
ipset_for_domain now answers for all three, and lives in fixed-domains.sh for
the reason that file exists - so the answer cannot drift between the places
that need it.
How it presented is worth recording, because it is nearly the opposite of
what it was. The port-22 self-check resolves github.com a second time, at the
end of startup. When GitHub returns the same address the check sees the open
port and kills the container; when the name has rotated in between, the
second address is only in github-domains and the check passes. So a security
hole surfaced as an intermittent boot failure, on a commit that had nothing
to do with it - #88 was suspected and reverted-in-preparation on exactly that
evidence. The self-check earned its keep here; what it could not do was say
which of the two answers it was looking at.
.dejima/allowed-domains keeps its GitHub entries. They are redundant for the
sandbox, which allowlists GitHub by published range and by DNS suffix
already, but dejima-ci-hardener reads the same file to build the CI runner's
egress allowlist, where they are not redundant at all.
The test asserts the address, not the symptom: github.com resolved through
the container's own dnsmasq is in github-domains, is not in allowed-domains,
and port 22 does not connect. That holds whichever way the second lookup
falls, which the self-check alone does not.
The loopback rule carried the claim that DNS "goes entirely through Docker's
embedded resolver at 127.0.0.11, which stays on loopback, so this also covers
DNS". Only the first two hops do. 127.0.0.11 does not answer external names
itself - it forwards them to the host's nameservers, and on Docker 26.1.5
that forward leaves the container's network namespace, so it traverses OUTPUT
like anything else. Nothing there can match it: the host's resolver is in
neither ipset and is not the bridge gateway. It reaches the catch-all REJECT,
every lookup returns SERVFAIL, and the container dies in a self-check that
names a host rather than the cause.
Measured, not deduced: on the failing host the OUTPUT catch-all had taken ten
packets of 646 bytes - DNS query size, one per SERVFAIL in dnsmasq's log -
while every ACCEPT rule including the bridge gateway's sat at zero.
This is not a property of any host's configuration. Two machines with
byte-identical /etc/resolv.conf behaved differently; what differed was the
engine, 29.7.1 forwarding somewhere this ruleset never sees and 26.1.5 not.
Debian's docker.io is 26.x, so the obvious way to install Docker on the
distribution this project's own runner runs is the way that breaks.
UDP/53 and TCP/53 are therefore opened, restricted to root-owned sockets by
the owner match. That restriction is the whole design, not a detail: the
forwarding socket belongs to dockerd, which is root, while the session runs
as `node` and the image ships no sudo - so a compromised session still cannot
address a resolver of its own choosing, and the DNS allowlist still holds
against the attack it exists for, which is data carried out in a query name
to a resolver no allowlist of names can vet. What the rule newly permits is
DNS from processes that are already root in a container holding NET_ADMIN,
and those can flush this ruleset outright instead.
Guarded rather than asserted, since xt_owner is a module: a kernel without it
should produce a container that explains itself, not one that aborts on an
iptables usage error.
The self-check gains a DNS probe ahead of both connectivity checks. It is
what should have made this a one-line failure. With DNS dead the negative
control fails for the wrong reason and prints "example.com is unreachable, as
it should be", so the run reports a passing check and then dies on the
positive control - which said only that a host was unreachable, having sent
curl's actual complaint to /dev/null. The new probe names the hop, prints
dig's answer, the packets the catch-all took, and dnsmasq's last lines.
runtime.bats gains the assertion that makes the opening safe rather than
merely convenient: `node` resolves an allowlisted name, and `node` cannot
reach 1.1.1.1 directly. A real resolver, so that a pass means the packet was
stopped rather than that the probe was aimed at nothing.
Removing the transitional trigger re-added the explanation next to the copy
already above it, so the file opened with the same twenty-five lines twice.
The fuller of the two is kept - it names the controls a branch would be opting
out of, which is the part that makes the paragraph worth reading.
A session pushes with the operator's forwarded SSH key and a token issued under
their account, so at the forge a push, a pull request, a comment or a merge
made from inside the sandbox is not similar to one the operator made - it is
the same event. Every forge control that answers "who did this" therefore
admits the sandbox exactly as it admits the operator, and there is no way to
configure around that while the identity is shared.
That matters most where someone would reasonably believe they had fixed
something. Putting yourself on a branch's merge whitelist puts the session on
it too. Required approvals are not merely weak but unusable, since a forge
will not let a pull request's author approve it and the session opens them as
you. And the forge's own audit log cannot attribute - the same shape as the
exit-time audit's time-window caveat, which this section points at rather than
restating.
The remedy is configuration rather than a missing feature, and it ships
alongside the problem so a reader is not left with nothing to do: a dedicated
forge account with write access, named by [forge.gitea] token_file and
[core] ssh_key, at which point the merge whitelist, the push whitelist and an
approval requirement all start enforcing what they appear to.
Two limits are stated rather than left to be discovered. Nothing enforces
which credential a push actually uses - [core] ssh_key is an independent path,
so a forwarded agent may still sign, making the separation decorative for the
one operation it was meant to constrain, which is worth a test push rather
than an assumption. And a separate identity does nothing for CI: a
write-capable account still pushes feature branches, which is what makes CI
execute code, and the token a forge injects into a job is a third principal
subject to none of those settings.
docs/ci.md's branch-protection prerequisite links here, because that section
told an operator to protect main without saying that on a default dejima setup
branch protection does not distinguish them from the session they are
protecting against.
static.bats pins both halves in security.md - the problem and the separation
that fixes it - and the link from ci.md. The anchor was checked against the
heading a renderer would derive, since a dead cross-document anchor is silent
in both files.
They called container_name(), which is this file's SHARED container - created
in setup_file and used by every dexec test in the run. Starting a second
container under that name is the conflict CI reported, and the `docker rm -f`
at the end of each body would have destroyed the shared one had the name not
collided first. The failure was the only thing that stopped it.
Tests that start their own container here use a distinct prefix and register
it in teardown, which is unconditional; cleanup at the end of a body is not,
because bats runs bodies under errexit and a failed assertion abandons the
rest. Both now do that.
Two things in the first test would also have let it pass for the wrong reason.
It mounted the directory read-only to make it unwritable, which makes the
"reported, not repaired" assertion vacuous - root could not have chowned it
even if entrypoint.sh tried. Mode 555 on an ordinary mount leaves it
repairable from inside the container and still unwritable by the session user,
so that assertion tests something.
And it asserted the directory was still owned by root, which depends on who
ran the suite: mktemp -d follows the caller, so a non-root run would have
failed on ownership having nothing to do with the check. It now asserts what
the check actually reads and what repairing it would change - that the session
user still cannot write there.
Restarting a sandbox container rather than relaunching it silently breaks
remote access, in a way that presents as three unrelated symptoms with no
obvious moment they started: notifications stop arriving, the session is
missing from dejima-attach, and the notify hook reports EACCES.
bin/dejima creates the marker directory per launch and removes it on exit, so
a restarted container has no bind source left. Docker's answer to a missing
bind source is not to refuse - it creates one, root-owned. The directory then
exists, which is all the notify hook's own `[ -d ]` guard asks, so the guard
passes and the write fails instead of the hook exiting quietly the way it does
when remote access simply is not configured. The session's registry entry went
with the same directory, which is the half that explains dejima-attach.
entrypoint.sh now checks it at startup, because that is the only place that
runs on a container restart - bin/dejima is not involved in one, so a check
there would have missed this entirely. A launcher-side check is added too, for
the different case of a stale root-owned leaf that mkdir -p happily succeeds
against, but it is the container-side one that catches the reported failure.
Reported, not repaired. Chowning the directory would restore notifications
while the session stayed absent from the registry - which bin/dejima writes
host-side and never mounts into the container - leaving a session that looks
fixed with half the feature still broken. The message names relaunching
instead.
Both checks are inside the "was this configured at all" guard, and there is a
test for that rather than only for the warning: remote access is opt-in and off
by default, so for almost every session this must say nothing. A warning about
an unconfigured optional feature would fire on every start, and a warning that
fires on every start is one nobody reads.
bin/dejima warns and continues rather than refusing. The state-dir check just
above it does refuse, but that one is about a symlink handing a session an
attacker-chosen path; this is only about an optional feature working, and
blocking a launch over undeliverable notifications costs more than it saves.
runtime.bats covers both directions against real containers - an unwritable
marker directory is reported and left alone, and an ordinary session says
nothing. static.bats pins the guard's placement, that nothing chowns the
directory, and that the launcher does not exit.
Deciding on one lookup was stricter than the step it protects: apt retries,
and a resolver can be a moment behind a container's start, so a single blip
failed a whole job. Six attempts over about fifteen seconds now, and it says
which attempt succeeded when it was not the first - a slow resolver is worth
knowing about even when it works.
The diagnosis also splits on the shape of the failure, because the two mean
different things and only one of them is about this host. Jobs failing while
others in the same run pass is not a resolver fault at all: it is runners
answering the same label with different configuration, so the jobs that land
on one without --dns fail and the rest do not. That reads as intermittent and
sends people looking at the network. Every job failing is the resolver, and
the checks for that are listed separately, ending at the dnsmasq log - which
is what distinguishes a query that never arrives from one forwarded to an
upstream that never answers.
The pair existed so the change introducing pull_request_target could be tested
by its own copy from the head - pull_request_target only takes effect once it
is on the base branch, so that change could not be covered by it.
That is now true, so pull_request comes out. Leaving it would keep taking an
untrusted run from the head on every pull request, with runs-on chosen by the
branch, which is the thing pull_request_target exists to prevent.
This change is itself covered by the copy already on main, which is the point
of the two-step: the first merge makes the trigger real, the second removes
the one it replaced, and neither lands without checks.
A job on a runner behind the egress allowlist fails at `apt-get update` with
"Temporary failure resolving" and then "Unable to locate package bats". None
of that is about apt, and none of it names the resolver or the allowlist -
which is where the fault actually is.
The suite that would say so precisely, ci-runner.bats, cannot run: installing
it is the step that just failed. So the diagnosis has to come before the
install, from what the image already has - getent is libc, and the resolver's
address is in resolv.conf.
The step now names the resolver the job was given and lists what to check in
order, including the case that produces exactly this symptom and looks like
nothing else: an empty allowlist. With no-resolv and no server= lines there is
no upstream for any name, so every lookup is refused and every job dies in apt
regardless of what it was going to install.
pull_request_target is read from the base branch, and Gitea explicitly ignores
a pull_request_target workflow found on a pull request's head. So the change
that introduces it cannot be tested by it: with only pull_request_target here
and only pull_request on main, the head's copy is skipped, the base has
nothing to contribute, and no checks run at all - which reads as a broken
workflow rather than as the bootstrap problem it is.
Keeping pull_request alongside it means this change is tested by its own copy
from the head, the way every pull request is today. Once merged, main carries
pull_request_target and a one-line follow-up removes pull_request - and that
follow-up is covered by the copy on main, so CI is continuous across both.
Between the two merges pull requests get both runs: untrusted from the head,
trusted from the base. That is noisy, and for the untrusted half it is the
status quo rather than a regression, so the window wants to be short.
The static guard now allows the pair only while the transitional note is
present, so dropping the note without dropping the trigger fails rather than
letting a temporary state become permanent. docs/ci.md records the two-merge
adoption, since anyone following this hits the same silence.
The advisory had drifted into being about hardening a runner you already
operate, with "move the repositories somewhere with only hardened runners" as
the way to close what hardening cannot. For a public project that means
breaking its name, its links and its ownership, which is not a thing to ask
of anyone - and it left users whose projects have no CI believing none of this
applied to them.
Both are now addressed by asking a different question first: not "how do I
harden my runner" but "what runners can this repository's branches reach". The
rule is stated once - every reachable runner must be one you would accept an
attacker running code on - and everything else follows from it.
That framing makes the no-CI case visible, which is the one nobody expects. A
repository with no workflows is not safe by virtue of having none, because a
branch adds the first one; the exposure follows from reachability, which is a
property of the forge rather than of the project. Said in the README too,
since that is as far as many readers get.
It also separates the mild case from the sharp one instead of alarming
everybody equally. On hosted runners the machine is disposable and is not
yours, so the "execution on a host you own" item largely evaporates and what
remains is credentials - worth saying plainly rather than leaving a reader to
assume the worst applies to them.
For the sharp case there are now three answers that move nothing:
Disable the Actions unit where a project does not use CI. No workflow runs at
all, so nothing is reachable and a branch cannot add the first one. Re-enabling
needs admin - Gitea puts repository settings behind reqAdmin() - so a bot
account with Write cannot undo it. Free where CI was not being used, and the
most commonly overlooked.
Scope the dangerous runner down instead of moving the repository out. A runner
can belong to an owner, a user as well as an organization; make the hardened
one the owner-level default and register the permissive one at repository
scope for the few repositories that genuinely need a socket. Most repositories
need to run tests, not build images.
Or decline the push grant, which closes the vector rather than bounding it and
is the only answer that does not depend on getting a forge's configuration
right.
static.bats pins the no-CI claim in both files and that the three
move-nothing answers are present, since losing any of them returns the
document to advice a public project cannot take.
Under `on: pull_request` the workflow is read from the head of the pull
request, so the branch decides what runs and - the part that matters - where.
A branch can point its jobs at any runner its repository can reach, which
makes every control the runner provides opt-out by whoever wrote the branch.
The dedicated label was routing, not containment.
`pull_request_target` reads it from the base branch instead. Gitea does this
explicitly: it resolves the base commit, detects workflows there, and takes
only the pull_request_target ones - while discarding any pull_request_target
workflow found on the head commit. So a branch can neither edit this file's
copy nor introduce its own, and runs-on is whatever main says.
The conversion has a trap, and it fails green. The run's ref becomes the base
branch, so a bare checkout fetches main and every suite passes without seeing
the change. Both suites that test the branch now name the head sha explicitly.
The runner-environment jobs keep the bare checkout, deliberately and now with
a comment saying so: they assert on the runner rather than on the change, so
their assertions should come from the base branch - which also means a branch
can neither weaken nor delete them.
This does not make the repository safe by itself, and docs/ci.md says so where
it explains why: a branch can still ADD a workflow with on: pull_request, and
that runs from the head with any runs-on it likes. Only which runners the
repository can reach stops that. What this buys is that the intended CI is
trustworthy, and that escaping it requires adding a file rather than editing
one - a far louder diff.
Recorded alongside it, because it changes what that remaining fix costs: a
runner can belong to an OWNER, a user as well as an organization, and then
serves all of that owner's repositories. Gitea's runner model names the three
scopes directly. So an instance with sixty personal repositories and two
general-purpose runners needs two re-registrations at user scope rather than
sixty at repository scope, with the repositories that receive agent-authored
branches living under a different owner whose reachable runners are all
hardened.
static.bats pins the trigger, that pull_request does not return, and the
checkout split - two jobs on the head sha, four checkouts in total, so a
bare checkout added to a branch-testing job fails here rather than passing
green in CI.
This document said that naming a dedicated label meant jobs "cannot land
anywhere else". That is true of a branch that is not trying, and false of one
that is - which is the distinction the rest of the document is built on, so it
was the wrong claim to make here of all places.
runs-on is in the workflow file and the workflow file is in the branch. A
session can change runs-on: dejima-ci to runs-on: ubuntu-latest, push, and
open a pull request; if any runner answering that label will take the job, it
has stepped outside every control described here - no allowlist, no socket
restriction, no firewall - without touching anything else.
Whether one will is a property of where runners are registered, which is the
actual boundary. Gitea's own FAQ is explicit that there is no per-repository
runner ACL and that registration scope is the control instead: an
instance-level runner serves every repository on the instance, and gitea.com
registers its own for a single organization so they will not run other
repositories' jobs.
So the property worth holding is that a repository's CI posture is that of the
weakest runner it can reach, not the one its workflow names. The runner-host
section framed registration scope as keeping other repositories off your
hardened runner; the converse - keeping your repository off nobody's hardened
runner - is the direction that bites, and is now stated first.
Also recorded: the runner-environment tests do not close this either, since
they are jobs in the same file and a branch redirecting runs-on can drop them
in the same commit. They catch a misconfigured runner, not a redirected job.
What survives the branch is the run log, which records which runner took each
job, and a required status check, which stops such a pull request being
merged. Both are after the fact, which is this document's whole point.
static.bats pins the correction, including that the old phrasing does not
return.
Replaces the earlier attempt to ship a CI hardening bundle from this
repository. That approach required copying files out of a dejima checkout onto
a CI box, which is backwards: a runner is a machine whose purpose is executing
untrusted code, and nothing about dejima belongs on it. dejima-ci-hardener is
self-contained and owns the how; this repository keeps the disclosure and
links out.
What stays here is what only this repository can say or check.
docs/ci.md keeps the threat model: what a run adds over a sandbox - execution
on a host you own, egress from a network position you own, credentials the run
is handed - and why no approval gate is offered, since every candidate is
reachable from the branch, covered by a credential the session already holds,
or not a gate at all. It keeps the reasoning behind the controls, including
that the allowlist has to come from the branch the operator controls rather
than the checkout. It no longer carries the configuration: two copies of a
firewall rule drift, and one of them had already needed the same fix twice.
test/ci-runner.bats stays because it is this repository's CI asserting its own
runners, which is nobody else's business to run. Both failures it covers look
exactly like success from inside a job: nothing in a job's log mentions
whether the Docker socket was mounted into it, and a job that can reach an
off-list resolver still passes every check that only inspects DOCKER-USER. Two
jobs, one per runner, with the tier declared by the workflow rather than
sniffed, and every negative check behind a positive control - an allowlisted
destination must resolve and connect first, or a runner with no network at all
passes the lot.
The sandbox matrix moves to the dejima-ci label so those jobs cannot land on a
runner that grants everything, and host-docker keeps dejima-runtime.
runtime.bats stops polling a container that has already exited, so an aborted
entrypoint surfaces at once rather than after the full two-minute wait.
With ci/ gone, static.bats loses the twelve guards over its files - they move
with what they guarded - and the leak scanner returns to its strict form,
since the private-subnet example that needed a carve-out went with them.
Everything in the base image is carried by every sandbox on the host, for
every project, with no review anywhere - the same property that made the
built-in domain allowlist worth shortening. What belongs in it is what the
sandbox itself needs, plus tooling with no route through the dependency
mechanism. Language toolchains and per-project utilities have a route, so they
are declared by the projects that want them.
Out: the Go toolchain, git-delta, vim, man-db, python3-venv, and
cowsay/fortune with the /usr/games PATH entry that existed only to reach them.
bats moves to this repo's own .dejima/dependencies, which is where a project's
test runner belongs.
Go is the one with a real cost, and it is why the tarball dependency type came
first. A Go project now declares
dejima-dep add --global tarball:go@<sha256>:https://go.dev/dl/go<ver>.linux-amd64.tar.gz
and gets a pinned toolchain in the version it wants, rather than whatever the
image happened to be built with. That is better than what it replaces, not a
consolation: the previous build tracked latest-stable, so two images built a
week apart could ship different Go.
Removing cowsay repairs a test rather than only shrinking a layer.
runtime.bats proves the derived-image dependency mechanism works by declaring
apt:cowsay and then finding cowsay installed - which, with cowsay in the base
image, passed whether or not generate_derived_dockerfile did anything at all.
It now checks the base image does NOT ship it before building the derived one,
so the control cannot be silently defeated again, and static.bats derives the
same check from runtime.bats' own fixtures so it is caught at the point the
package list changes rather than only where Docker exists.
static.bats also refuses a language toolchain in the Dockerfile outright.
Both guards were confirmed to fail when violated, not merely to pass now.
Playwright, nvim, tree-sitter-cli, build-essential and the zsh layer all stay:
Chromium cannot be expressed as a dependency at all (nothing runs `npx
playwright install`), and the editor and shell are documented features with
tests behind them.
Some toolchains are not usefully available from any package ecosystem the
dependency mechanism speaks. Debian 12's golang-go is several releases behind
current, so `apt:golang-go` is not a route to a Go a modern project can build
with - and today the only alternative is baking the toolchain into the base
image for every project whether it wants one or not.
tarball:<name>@<sha256>:<url> is that route. The archive unpacks with
--strip-components=1 into /opt/dejima/<name>, and /opt/dejima/<name>/bin joins
PATH. One convention covers the shapes that actually occur - a Go archive has
everything under go/, an nvim release under nvim-linux-<arch>/, and stripping
one level puts bin/ in the same place for both - so the spec needs no
destination or layout fields and there is nothing per-dependency to get wrong.
Compression is detected from the archive rather than declared, so .tar.gz,
.tar.xz and .tar.zst all work.
The digest is mandatory, and that is what makes this an approval at all.
Approving a bare URL would be approving a name whose contents the far end can
change afterwards, which is not a decision anyone can make once; with a digest,
what was approved is one specific archive. The generated build verifies it
before unpacking, because a tar extracted first and checked afterwards has
already written whatever it contained - there is a test on that ordering
rather than on the presence of a checksum line.
Validation is stricter than the URL grammar allows, for the same reason the
existing spec regex is stricter than package names allow: this string is
interpolated into a RUN line that runs as root at build time. Everything
shell-active is refused outright rather than quoted around, https only, and
the name is refused if it could walk out of /opt/dejima.
sandbox-context.sh matches tarball specs separately, since a tarball has no
package manager to ask - it is present if its directory is. Without that
branch a tarball spec would fall through the package-name regex and be
skipped, which reads to the agent as declared-and-present for something that
is neither. A reported spec keeps its digest in full: that is the part the
operator is being asked to approve, so an abbreviated form would be asking
them to agree to something they cannot see.
Two properties documented rather than smoothed over: a tarball dependency
names one architecture, because a digest does, so a mixed-architecture fleet
needs a per-project spec rather than one global one; and upgrading is an
approval rather than a version bump, since a new release is a new digest.
The positive-branch hook test skips on an image with no tarball dependency
unpacked, and says so, rather than arranging its own directory and thereby
checking that mkdir works. The branch it covers is pinned at source level
alongside it, which holds regardless.
The built-in list grants to every sandbox on the host, for every project, with
no review anywhere. That makes it the one place where "this seems reasonable"
compounds: each entry is individually defensible and the total is a standing
grant nobody agreed to per project.
Five entries were tooling rather than sandbox machinery - gitlab.com, pypi.org,
files.pythonhosted.org, proxy.golang.org and sum.golang.org, which between them
serve mason's Python LSP downloads and the Go module proxy. What remains is
what the sandbox cannot run without: registry.npmjs.org and platform.claude.com
for the harness and its updates, release-assets.githubusercontent.com for the
binaries baked-in tooling fetches, and api.anthropic.com under its existing
proxy-off condition.
release-assets.githubusercontent.com is the arguable one and it stays. It is
the same editor-tooling bucket by origin, but it is also where an ordinary npm
postinstall gets its prebuilt binary, so removing it breaks a much wider class
of routine work than the other four.
Nothing is lost, only moved to where it gets reviewed: `dejima-allow --global
<domain>` restores any of these permanently, and a project that needs one says
so in .dejima/allowed-domains, which is read with the project. That route now
matters more than it did - a hardened CI runner reads its egress allowlist
from that same file on main (docs/ci.md), so a name granted by being built in
is a name that never appears in any diff.
The Dockerfile still ships the Go toolchain and says plainly that shipping it
is not the same decision as granting every sandbox reach to its module proxy.
runtime.bats's ipset-refresh test moves off pypi.org onto registry.npmjs.org:
it needs a name that is built in, since that is the property it exercises.
static.bats gains a guard that no language package host is built in, listing
crates.io and rubygems.org alongside the removed five - the failure mode here
is growth by reasonable-looking addition, and that is what it is there to
catch.
A session cannot reach outside its allowlist, but it can push a branch and
open a PR - and that makes CI run what it wrote, on a host, with no firewall
and no human in between. It does not take an edited workflow file: a workflow
that runs a test suite runs every file in it. The operator-merges gate sits
strictly downstream of that, so by the time anyone reads the diff, it has run.
Nothing here claimed this was handled; the docs were simply silent on it. This
adds the disclosure in all three places a reader could reasonably start from -
README, docs/security.md, and .dejima.md for an agent working on this repo -
and a full runbook in docs/ci.md.
The runbook deliberately offers no approval gate. Every version of one is
reachable from the branch, covered by a credential the session already holds,
or not a gate at all: the trigger and the workflow file are in the tree, a
forge token that can open a PR generally covers the dispatch endpoint, a
stopped runner just means the job lands in someone else's pool, and the forge's
own approval settings apply to fork PRs rather than to same-repo branches. So
it bounds what a run can do instead: a runner host you are willing to lose,
job containers with no Docker socket, deny-by-default egress, and no secret a
run does not need.
The part worth generalising is the egress allowlist. A dejima project already
carries a version-controlled statement of where it may reach, so CI reads
.dejima/allowed-domains from origin/main over the forge API - never from the
checkout, which belongs to the branch. Widening what CI can reach becomes a
diff on main: reviewed, attributable and revertible, in the same file and by
the same act that widens what a session may reach.
Also documented, because they are easy to miss rather than obscure: act_runner
mounts the daemon socket into every job container unless container.docker_host
is "-", and that is a property of the runner rather than of the job; filtering
only DOCKER-USER leaves a job free to use any resolver it likes, because the
resolver on a bridge gateway is INPUT and not FORWARD; and Gitea ignores
`permissions:` before 1.27, which is what makes branch protection on main
non-optional rather than merely advisable.
Where none of it is available - CI you do not administer, a shared pool - the
document says so plainly and points at declining the grant instead:
[core] forge = none with [core] workflow = local-only.
static.bats pins the disclosure in all three files, that docs/ci.md exists and
is linked from both, and that it still says the allowlist comes from main
rather than from the checkout.
cd/pwd resolve --project's path logically, so a symlinked component's
name survives in $project, but declare_entry's makedirs/open resolve
the same path physically - the declaration can land somewhere other
than what $project names, including inside another project's existing
.dejima. A legitimate symlinked checkout looks identical to a planted
one from here, and only the operator can tell them apart, so this
discloses the mismatch (naming the physical target) rather than
refusing on it, which would break the ordinary case with no way
forward. The approval path is deliberately left resolved logically -
resolving it physically too would move the approval itself into the
symlink's target, turning a declaration-only redirect into an actual
cross-project grant.
The atomic rewrite replaced `grep -qxF`, which streams the duplicate check
in constant memory, with a plain fh.read() of the whole file. That file is
the session-writable one this guard exists for, and a session can make it
arbitrarily large for nothing:
truncate -s 8G .dejima/allowed-domains # sparse, du reports 0
Then it asks the user to run the command the briefing tells it to ask for,
and the host reads 8 GiB into a variable. Measured with the read contained
by ulimit -v: a raw MemoryError traceback. Uncontained it is the OOM
killer, aimed at whatever else the machine is running. It failed closed -
no wrong write, the approval was already recorded - so this was
availability rather than privilege, but it was the one dimension of the
new code with no bound at all, and a traceback is the wrong register for
a file every other refusal here explains.
The read now stops at LIMIT + 1 bytes and refuses above LIMIT, inside the
same fd. LIMIT + 1 rather than an fstat size check because the file can
grow between the fstat and the read. 1 MiB is orders of magnitude above
any real declaration - DEJIMA_MAX_DECLARED entries of a domain's 253
bytes is tens of KiB - and that constant is passed in from the shell
rather than duplicated, so the two cannot drift.
The test plants the 8G sparse file and runs the CLI under ulimit -v, so a
regression fails the test rather than the machine it runs on; a
just-under-the-cap neighbour proves the cap is a ceiling on a hostile
file and not a new way to refuse a large real one.
Also narrows the README's "cannot swap either path" to the two leaves the
session actually controls - an intermediate component of an
operator-supplied --project path is still resolved through, as the
residuals already say.
declare_entry guarded the one host-side write that lands on the
session-writable bind mount with a check-then-write: test for a symlink,
then mkdir -p, then test the file, then touch/grep/append. That closes
the pre-planted case and nothing else - a session running right now can
rename across any of those windows.
The old comment bounded that with "the documented moment to run these
CLIs is with nothing running". True for dejima-dep, which needs an image
rebuild anyway; false for dejima-allow --project, because the briefing
this branch added tells the agent to have the user run it from a
terminal outside the sandbox - mid-session, at a moment the session
itself chose, since it is the one asking for the command. Winning the
race is a host-privileged append of one validated token to an
attacker-chosen path: allowed-domains widens the egress firewall for
every project, dependencies is installed as root at image build where
there is no egress firewall.
Measured against a swapper: the old guard appended to the victim 25
times in 400 attempts on the file path and twice in 300 on the
directory.
So there is no check now - the guard is the open. python3 (already a
hard dependency) opens .dejima O_RDONLY|O_DIRECTORY|O_NOFOLLOW, then the
declaration file O_NOFOLLOW|O_CREAT|O_APPEND relative to that directory
fd, so neither path can be swapped for the other's sake, and reads the
duplicate check back through the same fd. O_NONBLOCK plus an S_ISREG
check keep a planted FIFO from wedging the CLI. Same signature, same
validate-first, same idempotence, still never calls exit, and both CLIs
still print the approval before calling it.
Two tests swap each path under a live writer and assert the victim is
never written, with a refusal count so a swapper that never landed
cannot pass vacuously. Dropping O_NOFOLLOW fails all five existing
symlink tests and both new ones; dropping O_NONBLOCK/S_ISREG hangs the
FIFO test out to timeout.
The mitigation sentence is corrected in the comment and the README
rather than left claiming a bound that no longer applies.
dejima-attach creates the grouped session detached, checks it holds the
pane, selects the window, and only then attaches. await_group waited on
has-session, which goes true at the first of those four steps - so a caller
that went on to assert the window index was reading it two steps early. It
held locally and failed on a loaded CI runner, with the group assertion on
the line before it passing: the group is set at creation, the window is not.
Waits for the client instead, the same way the earlier attach test does, and
for the same reason. 12 consecutive runs of both affected tests, no failures.
Also retries the api.github.com self-check in init-firewall.sh. Not from this
branch - it fails on main the same way, and did here in one derived-image
container while a dozen others in the same run came up fine. A transient DNS
answer or a CDN edge rotating mid-boot answers "can the allowlist reach
anything" wrongly in the direction that kills the container. Bounded and
reported like the ranges fetch above it. Its opposite number, the
example.com check, is deliberately left un-retried: a single success there is
the real answer, and retrying would be asking until it says what we want.
A sandbox has no Docker daemon, so the half of the suite that boots real
containers only ever runs in CI - and until now the only way to see why one
of those failed was for the operator to read the run in a browser and paste
it in. The session that wrote the code could not see what it did.
A third self-contained twin rather than a subcommand of gitea-pr: runs are
repo-scoped, not PR-scoped (a push to main has runs and no PR), and the
existing pair already establishes one file per Gitea domain with a
duplicated preamble pinned by static.bats.
Two things about the API are worth knowing and neither is discoverable:
runs are addressed by an internal id that no URL shows, and `limit` on the
run list is ignored unless `page` is passed with it. Both are handled here
so no caller meets them.
With no run given it resolves HEAD's commit - matched on head_sha, since a
pull_request run reports no branch at all - and prints only the jobs that
failed. It reads and never writes, which static.bats pins: it authenticates
with the same write-scoped token as gitea-pr.
register_hook's dedupe tested the existing commands with test(), an
unanchored regex. The gitea-merge-guard.sh -> merge-guard.sh rename left a
registration whose command string contains "merge-guard.sh", so every run
since reported "Skipping: merge-guard.sh already registered" and added
nothing. The guard that blocks the forge's merge command inside the sandbox
was registered nowhere, on any host that had installed before the rename.
The stale entry also names a hooks/ path the rename deleted, so Claude Code
reported a missing hook command on every session start - the visible half of
a failure whose other half was silent.
Both fixed: the dedupe matches the hook's name at the end of the command
rather than anywhere in it, and the pre-rename registration is dropped
alongside the pre-rename symlink, on the same condition (only when the path
it names is gone, so an operator's own file at that name is left alone).
Tested by running install.sh against a throwaway HOME rather than by
grepping it - the call this missed was present in the source the whole time.
The json-file log driver's writes lag the process that produced them - the
same short read settled_logs() was written for. It landed here as a CI
failure in which the container booted, port 22 was reachable, and the
rebind assertion held: everything the test guards passed, and only the
grep for the line announcing the branch that had demonstrably been taken
came back empty.
The shipped defaults were core.forge = gitea and core.workflow = worktree-pr
- a PUSHING pair. Rule 3 in provider_validate_selection refuses a workflow
that pushes when its forge resolves to no host, so every config without a
filled-in [forge.gitea] was refused at launch, including the one install.sh
writes. A fresh install could not start; 26 launcher tests in project.bats
and remote.bats failed for exactly that reason.
The rule is right. The defaults were the thing asserting a setup no fresh
install has, so the defaults change: forge = none, workflow = local-only.
Pushing is opted into. All 26 failures resolve with no launcher fixture
touched.
This is a behaviour change for an existing operator with no explicit
[core] forge / workflow line - their sandbox stops pushing - so it is
reported rather than silent:
- install.sh prints a notice when an existing config parses but does not set
both keys explicitly, naming the old and new values and the two lines to
write to keep pushing. Same stance as the remote.conf "no longer read"
notice: reported, never rewritten.
- The starter config now writes forge and workflow uncommented, so a config
states the selection it makes rather than inheriting a future default.
- install.sh's legacy seed path is repaired: _config_extra_keys enumerates
only the selected forge's keys, so with `none` selected [forge.gitea]
vanished from the starter config and GITEA_SSH_HOST/GITEA_API had nowhere
to land. An operator with those exported was pushing to Gitea, so
install.sh selects gitea + worktree-pr for them explicitly.
- README and docs/providers.md restate the defaults, with the reasoning and
a migration callout.
providers.bats pins the property that broke: the shipped defaults must be a
non-pushing pair that validates with no forge configured, driven off the
schema rather than literal names. config.bats' embedded-newline token test
was relying on the old default to select the forge whose token_file it
poisons, and now names gitea explicitly - its subject genuinely needs one.
Two defects the previous commit introduced, both on lines it added.
The symlink guard in declare_entry only ruled out symlinks, and the
re-offer notice added to `dejima-dep remove --project` grepped the
declaration unconditionally. Both read or write a path the sandboxed
session owns, so a FIFO planted there blocks touch, the duplicate-check
grep and the append itself for as long as the session declines to open
the far end - wedging, for the full timeout, the very commands the
session briefing tells the user to run. Same class of thing the rest of
this guard exists to close, so it is closed by the same idea: the file
test widens from "not a symlink" to "regular file or absent", and the
notice's grep is gated on -f. A FIFO named .dejima needs nothing, since
mkdir -p already refuses to create over one.
Also drops a false clause from dejima-project's collector comment, which
listed a symlinked declaration path among the ways an approval can fail.
It cannot: both CLIs deliberately swallow a declare_entry failure and
exit 0, because the approval is what grants and is already written by
then. The comment now says so, rather than claiming the opposite.
The branch made the host-side CLIs write into <project>/.dejima, which is on
the read-write bind mount the sandboxed session controls. declare_entry had
no symlink guard, so a session could plant one and then ask the user - via
the briefing this branch added, which hands over the exact `dejima-allow
--project` command - to run the CLI that follows it. Three shapes, all
reproduced against the real binaries: .dejima/allowed-domains linked at the
global store turned a project approval into a machine-wide one while the
output claimed the project; linked at the global dependency list it wrote a
domain where only specs are valid, tripping approved_entries' refusal and
blocking every project's image build; and .dejima linked at any directory
worked outright, since mkdir -p follows one. declare_entry now refuses a
symlinked directory or file, explains why in terms of what the link could
reach, and says in a comment that this is a check and not a lock - a live
session can still win the race, which is why the documented moment to run
these is with nothing running.
The rest of the review, in the same pass:
- dejima-project's dep loop had no collector, so under set -e the first
failing dependency aborted the script: the deps after it and every domain
went unattempted, with no summary - making the README's "keeps going"
claim false for dependencies. It now collects and reports like the domain
loop, and "Restart the sandbox" is gated on something having landed.
- approve exited 0 when every approval had failed, so `approve && dejima`
walked into a session with none of them. It now exits non-zero.
- dejima-dep swallowed --container, a flag it inherited from the shared
parser and has no concept of, giving a caller who meant one session a
persistent project grant instead. It refuses it.
- dejima-dep remove --project leaves the declaration in place by design, but
the next approve then re-offers the entry; it now says so.
- README: the reference section still described .dejima as written only by
the project or session. It now names the CLIs that write declarations,
keeps "a declaration grants nothing", and explains the symlink refusal.
- DEJIMA_ALLOW_SKIP_SESSION was consulted by nothing (every caller set a
scope flag, so session_wanted was already 0) and static.bats documented a
call that does not exist. Deleted; the static test now pins its absence.
- Dropped a tautological alternative in the --project-path parser test.
- README.md: the --allow-github-push section still claimed no config-file
path could grant a push path, contradicted since 9d82c7b let
[core] forge = github do exactly that when the workflow pushes; rewrite
to state both truths and align with docs/security.md.
- docs/providers.md: qualify the shipped-providers table's "opens
ssh.github.com:22" with "when the selected workflow pushes", matching
the prose below it; also document hosts_allowlisted_by_core's
GitHub-specific trap for future forge authors.
- bin/dejima-provider-lib.sh: comment the same trap where the manifest key
is declared.
- test/providers.bats: assert the no-host remedy names a forge that
actually exists (provider_list forge) instead of pinning the literal
text "forge = github", which would go stale silently if that forge were
renamed or removed.
- bin/dejima: skip _warn_forge_env_collisions when config_get core forge
already failed, so an invalid forge value reports one error instead of
two; add a regression test.
- test/static.bats: declare the missing local `body`.
C1: `forge = github` could not boot a container with the firewall on. Its
fixed_hosts reached DEJIMA_FORGE_HOSTS, which entrypoint.sh folds into
EXTRA_ALLOWED_DOMAINS - the path that resolves a name into the
port-unrestricted allowed-domains ipset and exempts it from
stop-dns-rebind. Three public GitHub names therefore got all-port egress
and lost rebind protection, and init-firewall.sh's own self-check then
found github.com:22 reachable without ALLOW_GITHUB_PUSH=1 and killed the
container before the agent started.
New forge manifest key hosts_allowlisted_by_core (bool, default off), set
by forges/github/forge.conf and read generically by core: its hosts stay
out of DEJIMA_FORGE_HOSTS, still fill DEJIMA_PUSH_HOSTS so the push guard
permits the push, and when the workflow pushes, port 22 opens through
core's own grant (ALLOW_GITHUB_PUSH) - the documented, logged,
self-checked path. Core branches on the manifest value, never on a name.
Also:
- the "core names no forge" test now builds its pattern from
provider_list forge, so every shipped forge name is covered, matching a
name in literal position (quoted, bracketed, forge.-prefixed, case arm)
alongside the original gitea/github.com prose scan. The two core sites
it was blind to are gone: the GH_TOKEN collision warning is derived
from the selected forge's env_export, and the briefing's
network-posture sentence from hosts_allowlisted_by_core plus the
workflow's push.
- workflows/worktree-pr/briefing.md no longer tells a github-forge
session to push to Gitea and never to GitHub: "the remote",
{{blocked_branches}} instead of a hardcoded main/master, and "the
forge" instead of Gitea.
- rules 5 and 6 require a required key to resolve to a non-empty value,
not merely to be written. install.sh writes every key commented out, so
uncommenting one yields `api =` - set, and worth nothing, with every
forge call failing inside the container instead.
- the README no longer says a fresh install is enough to run dejima (it
is refused: gitea + worktree-pr with no host), nor that
--allow-github-push replaces selecting a forge; rule 3's remedy offers
forge = github; the network-egress and security.md passages the C1 fix
falsified are corrected.
Regression tests for each, including a runtime.bats case that boots with
the github forge's resolved environment and asserts the container comes
up with port 22 in the documented posture (needs Docker; not run here).
- Delete "approve visits every domain even when one fails to be
approved": it stayed green with the set -e collector deleted, so it
provided no coverage. The pre-existing "attempts every domain even
when persisting one fails" test is the real guard (confirmed by
removing the collector and observing which test actually fails).
- report()'s undeclared-approvals pass never added to `seen`, so an
entry approved in both stores and declared in neither printed twice.
- The "Restart the sandbox..." trailer printed even when every domain
failed, directly under "NOT approved ... nothing was recorded". Now
gated on something having actually been approved (a pending dep, or
at least one domain that didn't fail).
Two static.bats regressions from fix round 1:
- "core names no forge": my two fix-round comments used gitea as a worked
example, violating the rule this branch exists to establish (core names
no forge, in code or prose). Rewritten to make the same point generically
(a required key with a non-empty schema default already has a working
value; a non-pushing workflow never invokes the forge at all) without
naming one.
- The four bin/dejima audit-startup tests share a fixture
(startup_warning_setup) that wrote a config with no forge/workflow
override - the default gitea/worktree-pr pair, unconfigured, which rule 3
now refuses before bin/dejima ever reaches the audit-startup path those
tests are about. Added forge = none / workflow = local-only to the shared
fixture, the same fix already applied across test/config.bats.
status only ever iterated declarations, so a domain approved with
dejima-allow --project (which has no declaration behind it) was
invisible - the reported bug in issue #61. status now unions
declarations with actual approvals via a new report() function, tagging
each entry with the store it came from. classify() keeps its exact
existing contract for approve.
approve's domain loop keeps its set -e loop-abort collector (a bare
dejima-allow call would still let the first failure silently skip every
remaining domain - dejima-allow --project can fail on its own even with
no container involved), but no longer claims a domain was "approved, but
not applied to a running session" when nothing was actually recorded.
The requires-key table row and docs/security.md still described a single
shipped forge and a warning-not-refusal, both stale since the earlier
providers.md rewrite in this task.
approve_entry runs first and ungated under set -e, so a declare_entry
failure (e.g. an unwritable project checkout) aborted the script before
either confirmation echo printed - even though the approval, which is what
actually grants, had already been written. Print the approval confirmation
first, then guard declare_entry so its failure can't abort the script and
says plainly that the approval stands.
Three operator rulings on Task 7's review:
- A requires key with a non-empty schema default (gitea's token_file,
defaulting to ~/.config/gitea/token) can never be genuinely missing, so
it no longer counts toward set_n/unset_n. Without this, the README's
documented gitea setup (ssh_host + api only, token saved at the default
path) was refused as "partly configured" at every launch.
- Rule 5 is now gated on push = on, matching the single-required-key check
it sits next to. A non-pushing workflow never invokes the forge, so
setting only ssh_host (e.g. so the firewall allows fetches) is a
legitimate configuration, not a typo.
- Rule 3's message for a forge with no keys at all (forge = none) no
longer tells the operator to "Configure [forge.none]" or "set forge =
none" when the forge already is none - it names the only real fix
(a non-pushing workflow) instead.
Also: reuse the already-computed $push instead of re-deriving it in a
subshell, and fix a stale "warning" comment in test/config.bats.
The page opened with the mechanics of the feature being off - no session
state, no hook registered, install.sh runs no systemctl - which is true and
is not what someone arriving here needs first. It also assumed a good deal
of internal knowledge before saying what any of this was for.
Reordered to: it's off by default and needs your hands; what it is; what it
costs you in security terms; how to configure it; then how it works. The
off-by-default mechanics keep every detail they had, as "What `off` means"
under the new "How it works" section rather than as the opening paragraph.
The security section now says the thing the page only implied. What turning
this on adds is ingress on your own LAN - no second factor, no per-session
authorisation, no audit of who attached - and what it does not add is a way
out of a sandbox, since the container routes to exactly one host address and
neither front door is on it. The two controls holding that are named and
linked: the bind address and authentication on the proxy.
Two ASCII diagrams under "How it works", one per direction. The push:
container writes an empty marker across the bind mount, dejima-notify-watch
reads the name and never the content, curl to ntfy, phone. Coming back:
browser and SSH client as separate columns crossing the LAN boundary into
nginx and sshd, converging through ttyd on dejima-attach and your tmux
server - closing with the sandbox and the reason neither front door is
reachable from it. Trust boundaries are drawn as labelled rules the arrows
cross, which is the part the issue was actually asking for.
ASCII rather than Mermaid so it renders the same in Gitea, in the raw file,
and in a pager; the repo has no Mermaid precedent.
Also drops the "what it is not: a way to start a session, a hosted service,
or anything that should face the internet" list, which read as three
different kinds of claim at once. "Don't open it to the public internet"
covers the one that matters.
Adds a paragraph on the SSH front door at the same altitude as the rest: a
key pinned to a forced command, no proxy and no ttyd involved, lands on the
picker.
Docs only - no behaviour change. Every anchor the README links to is
preserved; verified the diagrams' box borders and connector columns line up
to the character, and that the new #what-off-means and #verifying-it links
resolve.
Closes#66
The existing mutual-exclusion test only used an explicit --project path,
under which dj_project is non-empty regardless of whether the check keys
on dj_project_set or on dj_project itself - so it couldn't catch a
regression to the latter. The no-path form is the one where only
dj_project_set can tell --project apart from its absence.
Turn provider_validate_selection's two deferred rules into refusals now
that forge = none (Task 1) gives a refused operator a correct alternative
to point at: rule 3 (a pushing workflow needs a forge that resolves to a
host) and rule 5 (a partly configured forge is a typo, not a choice).
Closes a gap rule 3 alone leaves open: github pins fixed_hosts
independent of config, so forge_hosts is never empty regardless of
push_token_file, and github's single-key requires can never look
"partly" configured either - a wholly unconfigured github pushing forge
would sail past both rules otherwise. Added a third check, scoped like
rule 3 to a pushing workflow, that refuses when none of a forge's
requires keys are set.
Updated bin/dejima's now-stale comment describing the two rules as
deferred, and every test (in providers.bats and config.bats) that
launched with the default gitea/worktree-pr pair unconfigured, or a
gitea forge only partly configured - switched to forge = none +
workflow = local-only where the test wasn't actually about forge
selection, or to a fully configured gitea/github where it was.
Fix round 1 on code review of the github forge (task 5):
- forge_env_exports now skips an env_export pair whose resolved value is
empty instead of emitting NAME= - selecting forge = github with
push_token_file unset was overriding docker-compose.yml's own read-only
GH_TOKEN with an explicit empty one via -e, silently voiding the baseline
GitHub read access every session gets regardless of forge.
- The collision warning now also checks that push_token_file is actually
set before claiming GH_TOKEN comes from it - previously it only checked
the read-only token_file, so it could fire (with a false claim) even when
push_token_file was never configured.
dejima-project approve's set -e loop-abort guard (the || collector around
each dejima-allow call) had lost its only cover: the docker-failure
induction that used to exercise it is exactly what Task 2 removes. Add a
real induction (an unwritable project store) and a test that fails without
the collector.
dejima-allow --project can now fail with no Docker involved at all, so a
nonzero exit from it means the approval was refused, not merely "not
applied to a running session" - fix dejima-project's message to say so
instead of falsely claiming next-launch effect for something that was
never recorded.
Also: reorder the one old-order dejima-allow invocation left in
test/static.bats, and fix two comments (bin/dejima-allow,
test/static.bats) that still described DEJIMA_ALLOW_SKIP_SESSION as
covering --project persistence, which session_wanted now does on its own.
dejima-allow now takes [--global] [--project [path]] [--container <id>]
<domain>, using the shared parse_scope_args grammar from dejima-project-lib.sh
instead of its own ad hoc loop. The old <domain> [--global] [--project <path>]
order is rejected outright, not accepted alongside the new one.
--project/--global are approvals for future sessions, not a claim about a
container running right now, so an invocation using only those flags never
touches docker at all - the issue's headline complaint. --container is the
explicit opt-in to also patching a live session.
bin/dejima-project's own internal call had to move to the new order too,
since the old one is gone; its "no running session" comment was updated to
match (it can no longer fail there without --container).
Three things, all aimed at the same problem: a change to one component of
dejima needed the whole suite to run before anyone could trust it.
**Naming.** `fast`/`runtime` read as a speed split, and the fast half is the
slow one. What actually separates them is whether a suite needs a Docker
daemon - which is the thing a contributor has to reason about when deciding
what they can run before pushing, since a dejima sandbox has no Docker by
design. Now `sandbox` and `host-docker`.
**Matrix.** The sandbox suites are independent, so running them in one
invocation only serialised them: wall clock was their sum rather than the
slowest. Each is now its own job. A failure also names the suite in the job
list instead of one red check covering seven unrelated areas. fail-fast is off
so a single failure does not hide whether the rest would have passed.
**A component-to-suite table in .dejima.md**, so an edit to bin/dejima-audit
runs audit.bats rather than all of them. The two cases that defeat the table
are called out rather than left to be discovered - bin/dejima and
dejima-config-lib.sh are read by nearly every suite, so those do need the full
set.
Also fixes the tmux flake this kept tripping over. dejima-attach creates the
session and then attaches to it, but the test waited only for has-session and
then asserted on list-clients - inside the window where the session exists and
the client does not. Reproduced at 5 failures in 12 runs under CPU load; 0 in
12 after waiting for the client instead. The assertions are unchanged, so a
real regression still fails rather than timing out into a pass.
Two per-suite "the CI suite includes me" tests are replaced by one derived from
test/*.bats on disk, so adding a suite now fails until the workflow is told
about it - the old pair only covered the two suites that thought to add one.
Verified it fails and names the suite by removing an entry from the matrix.
Tested: all seven sandbox suites, 622 pass. Note runtime.bats is unaffected by
this commit but was run green earlier on the same branch.
Both failed only when the suite ran somewhere that already had dejima's own
services up - a sandbox, or a host running the front door - which is where
this repo is normally developed. Neither was a product bug; in both cases the
shipped code was correct and the test was reading its environment instead of
setting it.
proxy.bats, "forwarder tls mode refuses to start without a cert" (#56)
The issue blamed 127.0.0.1:443 being held. That is not the cause: the
forwarder checks for the cert before it ever calls listen, so a correctly
failing run never binds anything. What actually happened is that
entrypoint.sh exports SANDBOX_ANTHROPIC_TLS_CERT/_KEY into every
tls-transport session, and the test expressed "no cert configured" by not
setting them - which only holds where nothing else has. Inside a sandbox it
inherited real cert paths, the guard under test never fired, execution fell
through to the listen, and the resulting EADDRINUSE was reported as this
assertion failing.
`env -u` on both names states the precondition. The port override the issue
suggested is kept as belt-and-braces: nothing binds today, but if the check
order ever changes the fall-through lands somewhere harmless.
remote.bats, "dejima-ttyd binds ttyd to loopback and takes its port"
Same shape, found while running the suite on a host with the front door
running. Its second half asserts the default port 7681 - exactly the port a
real dejima-ttyd holds - so the port-in-use guard correctly refused and the
test called that a failure.
An opt-in ss stub states "nothing holds this port". Deliberately not folded
into stub_ttyd: the port-in-use refusal has its own test, which binds a real
socket and needs the real ss to see it.
Verified both directions for the proxy case - the old test fails and the new
one passes under the same simulated sandbox environment - rather than only
that the new one is green.
Phase 2 stopped the container deriving hostnames from any one forge's
variables: bin/dejima resolves them host-side from whichever forge is
configured and passes the result as DEJIMA_FORGE_HOSTS. docker-compose.yml and
bin/dejima were updated for that; this test, which builds its container by
hand with `docker run`, was not.
It still started the container with `-e GITEA_SSH_HOST=gitea.internal.example`,
which entrypoint.sh no longer reads - so that hostname never reached
extra_domains, never became EXTRA_ALLOWED_DOMAINS, and never got its
`rebind-domain-ok=` line. The test then asserted on a name the container had
never been told about, and failed on a bare `[ "$status" -eq 0 ]` with no
output, which is why it looked like a test failing for no reason.
The exemption itself was never broken: dns-proxy.sh still writes one per
EXTRA_ALLOWED_DOMAINS entry, and the test passes once fed the name the
container actually reads.
Also drops three comments in dns-proxy.sh and init-firewall.sh that still
described the GITEA_SSH_HOST/GITEA_API derivation. Those are core files this
phase is meant to have de-forged, so a comment naming one forge is the same
coupling the axes exist to remove - just in prose.
Tested on a host with Docker: bats test/runtime.bats - 37 pass, 0 fail. The
first fully green runtime run against this branch.
The fast CI suite went from ~5 minutes on main to 32. This is the cause.
Every config_get walked the whole file twice - once for config_validate and
once to find the key - and both passes called `$(_config_trim ...)` twice per
line, which forks a subshell each time. Every schema lookup called
_config_table, a `cat` heredoc, forking again; the validator did that per
line. A launch performs ~30 reads, so a 30-line config meant thousands of
forks to answer questions whose input never changed. Measured: 83ms per
config_get on a four-line config.
Three changes, no behaviour change:
- _config_trim_into does the identical trim via `printf -v`, a builtin, so
the parser's per-line trims cost no forks. _config_trim stays, since other
scripts and tests call it.
- The schema table is read into two maps once per process. It is a
compile-time constant; reading it repeatedly cannot yield a different
answer. _config_type_into answers core keys straight from the map, leaving
a subshell only for provider keys - a handful, not one per line.
- The file is parsed once into _CFG_VAL/_CFG_SEEN, keyed on the config path
so repointing DEJIMA_CONFIG_DIR mid-process re-parses. config_reset()
covers rewriting the same path in place; nothing shipped does that today.
This keeps the property the file header defends. Every read still sees a
fully validated file, and no read can succeed against a file another read
would reject - there is now one parse for them all to agree with rather than
sixty that cannot disagree.
83ms -> 2.7ms per config_get (17x). Full local suite 25-30min -> 15:02.
Tested: bats static/project/remote/audit/config/providers - 592 pass, the
only failure being the known tmux attach flake, which passes in isolation.
Verified directly that a malformed value and an unknown section still refuse,
that a cached failure still returns 1, and that last-assignment-wins,
config_is_set and schema defaults are unchanged.
The fast CI suite went from ~5 minutes on main to 32. This is the cause.
Every config_get walked the whole file twice - once for config_validate and
once to find the key - and both passes called `$(_config_trim ...)` twice per
line, which forks a subshell each time. Every schema lookup called
_config_table, a `cat` heredoc, forking again; the validator did that per
line. A launch performs ~30 reads, so a 30-line config meant thousands of
forks to answer questions whose input never changed. Measured: 83ms per
config_get on a four-line config.
Three changes, no behaviour change:
- _config_trim_into does the identical trim via `printf -v`, a builtin, so
the parser's per-line trims cost no forks. _config_trim stays, since other
scripts and tests call it.
- The schema table is read into two maps once per process. It is a
compile-time constant; reading it repeatedly cannot yield a different
answer. _config_type_into answers core keys straight from the map, leaving
a subshell only for provider keys - a handful, not one per line.
- The file is parsed once into _CFG_VAL/_CFG_SEEN, keyed on the config path
so repointing DEJIMA_CONFIG_DIR mid-process re-parses. config_reset()
covers rewriting the same path in place; nothing shipped does that today.
This keeps the property the file header defends. Every read still sees a
fully validated file, and no read can succeed against a file another read
would reject - there is now one parse for them all to agree with rather than
sixty that cannot disagree.
83ms -> 2.7ms per config_get (17x). Full local suite 25-30min -> 15:02.
Tested: bats static/project/remote/audit/config/providers - 592 pass, the
only failure being the known tmux attach flake, which passes in isolation.
Verified directly that a malformed value and an unknown section still refuse,
that a cached failure still returns 1, and that last-assignment-wins,
config_is_set and schema defaults are unchanged.
cleanup() killed the proxy but never waited on it, so it stayed a zombie
child until dejima exited and then reparented to PID 1. Where PID 1 reaps
that is invisible; where it does not, the zombie holds a PID for the life of
the container.
A CI job container is exactly that case - Gitea act_runner's PID 1 is
`/bin/sleep`, which never calls wait(2) - which is why dejima's own
containers set `init: true` in docker-compose.yml and a job container gets
nothing equivalent. One leaked PID per launch is nothing interactively and
accumulates over a suite that launches hundreds of times.
Found while diagnosing a CI fast-job failure where the runner reached 9440
zombies and could no longer fork; every zombie was parented to the job
container's `sleep`. That runner also needs a real init to reap what it did
not spawn - this fixes the half dejima is responsible for.
The Dockerfile switches to USER node for the npm installs and only switches
back with the `USER root` that precedes the chmod step. Phase 2's forge CLI
install landed above that line, so its RUN inherited node and could not write
to /usr/local/bin:
install: cannot create regular file '/usr/local/bin/gitea-pr': Permission denied
COPY writes as root regardless of USER, which is why every COPY around it was
fine and only the RUN failed - and why this was invisible until an image
build actually ran. It failed the runtime CI job at the build step, before a
single test executed.
Moves `USER root` above the forge block rather than adding a second one, so
there is still exactly one place the build returns to root.
Brings main's touch ttyd frontend (#58), audit usability (#63), firewall
fetch bounding (#60) and runtime.bats fixes down through Phase 1, along with
the two settings that merge turned into schema keys - [remote-access]
ttyd_tmux_prefix (new "chord" type) and [audit] diffs.
Almost everything merged clean, which is the abstraction holding up: main's
new keys are core settings, not axis settings, so they land in the core
schema table and no manifest changes. Two fixes were needed:
- test/static.bats: "core names no forge" (Phase 2) and "no container mount
covers the audit store" (via main) are adjacent and independent, not
alternatives. Both kept.
- test/remote.bats: the incomplete-checkout test copies the launcher and its
libraries into a bin/ with no web/ beside it. Phase 2's project-lib also
sources dejima-provider-lib.sh, so the copy failed on the missing library
instead of the missing page - passing the non-zero-status check while
proving nothing. Now copies every library the launcher sources.
main's pr-autonomy paragraph was already carried into
workflows/worktree-pr/briefing.md as {{pr_command}}, so it stays
forge-agnostic rather than reintroducing a gitea name into core.
Tested: bats static/project/remote/audit/config/providers - 593 pass,
0 fail, 0 skip. Verified --print-config resolves all three axes plus the two
new keys, and --print-briefing still composes with the forge substituted.
runtime.bats still needs a host with Docker and has not run against either
branch.
main gained the touch ttyd frontend (#58), audit usability (#63), firewall
fetch bounding (#60) and four runtime.bats fixes while this branch was open.
All three conflicts were the same shape - main kept adding keys to
remote.conf, which this branch replaced with the single config file - so each
is resolved by expressing main's new capability in the config schema rather
than reinstating remote.conf.
Two settings main added as a remote.conf key and an environment variable
become schema keys:
- [remote-access] ttyd_tmux_prefix, under a new "chord" type carrying main's
control-chord validator verbatim, so an unparseable prefix is still refused
at the config rather than on the phone. Unset still passes no -t at all.
- [audit] diffs (bool, default on), replacing DEJIMA_AUDIT_DIFFS.
config_get_bool prints 1/0, so the existing gates read unchanged.
DEJIMA_AUDIT_DIFFS joins _warn_legacy_env's table; TTYD_TMUX_PREFIX
deliberately does not, since it was a remote.conf key and never an
environment variable - install.sh's "no longer read" notice already covers
the whole file.
Main's tests arrived configured through the retired DEJIMA_AUDIT_* variables,
which this branch ignores, so they were silently exercising nothing. They now
go through an audit_set helper. The one case that cannot be expressed at all
is a relative audit store: the schema's "path" type refuses it outright, so
that test now asserts the refusal - the guarantee store_reachable used to
provide by canonicalising, moved earlier.
Tested: bats static/project/remote/audit/config - 510 pass, 0 fail.
runtime.bats still needs a host with Docker; main's four fixes to it land
here and have not yet run against this branch.
Two Important findings from the final whole-branch review.
A multi-valued git config exec key made `ack` show a fabricated diff.
promote_gitconfig's before-loop has no break, so it emits every
pre-session value of the key; write_diff's empty-diff withhold therefore
only covered the shape where there was exactly one. With two, a diff was
retained saying the second one had been removed - nothing was removed,
and the value the session appended, the one git will actually execute,
appeared nowhere in it. Approving it pinned the sha of a value the
operator was never shown. Reachable by a one-line append to .git/config,
and ordinary besides: include.path is legitimately multi-valued.
Count both sides instead and withhold when either holds more than one
value. Deliberately no attempt to resolve which value git would use:
git_config_snapshot sorts, so lexicographic-last is not file-order-last,
and a naive first-to-last swap is still wrong. That is its own change.
This leaves write_diff's empty-diff arm reachable only by a config line
whose text changed while its parsed value did not, so its comment now
names that route instead.
Separately, every size cap here applied after the bytes were already on
disk. compare runs from bin/dejima's EXIT trap and the size is the
session's choice, so a .claude/ file grown to 300 MB during a session
cost 573 MB of peak $TMPDIR - frequently a tmpfs, i.e. host RAM - to
produce a 44-byte marker, and a SIGKILL mid-compare left a whole copy of
it behind. Check the source size before copying it, reusing the existing
too-large marker, and bound the generated diff at generation with
head -c rather than after.
Tests cover both multi-valued shapes and the oversized after side (peak
$TMPDIR sampled against an isolated TMPDIR); the existing multi-valued
test is rewritten in place because the reason text it pinned legitimately
changed. 275/275 pass.
The transport test detached its container and asked `docker logs` for the
output afterwards. Across six CI rounds that arrangement insisted the
container was aborting partway through init-firewall.sh. It was not.
The tell was in what those logs never contained. Every line any of them
ever carried came from an `echo` to stdout; not one line written to stderr
appeared in any of them. Not init-firewall.sh's ERR trap, which nothing
reaching that point can bypass. Not resolve_domain's per-attempt reports,
which print on every path before it returns. And not the FATAL message
this test asserts on, which entrypoint.sh writes to stderr too - so the
assertion was failing on the same missing half as the diagnostics that
were supposed to explain why.
The output was also measured rather than eyeballed, twice: 2100 bytes and
2189 bytes, both exactly the length of the visible text. Nothing was
truncated. The stdout half arrived complete and the stderr half never
arrived at all.
So stop going through the log driver. A foreground `docker run` attaches
to the container's streams directly - nothing is buffered, flushed or
dropped in between - and one command yields both the output and the exit
status, with no wait, no settling, and no read-after-exit race. It is less
machinery than what it replaces, not more.
settled_logs stays for wait_ready, which has no foreground option: it is
watching a container that is meant to keep running.
The static test counting container starts against --network flags is
updated to match: it counted `docker run -d` lines, and now counts
foreground starts too, exempting runs that replace the entrypoint with a
plain shell - those never reach dns-proxy.sh, so a resolver cannot be
missing from them.
Refs #59
$IMAGE is a single mutable tag on a shared daemon, and CI sets
SKIP_BUILD=1 because it builds the image in an earlier step - so nothing
in runtime.bats ties the image it tests to the code in the working tree.
When those disagree, every assertion in the file is made against code that
is not under test, and the failure looks exactly like a bug in the code
you are reading.
That is what the transport test has been doing. Its container log is
2100 bytes and ends mid-way through init-firewall.sh's domain loop -
measured, not eyeballed: the visible text is exactly 2100 bytes, so the
log is whole and nothing followed it. But the commit under test carries an
ERR trap that nothing reaching that point can bypass, and a resolver that
prints on every path before it returns. Neither said anything. A script
cannot be running there and produce that log, and the same image tag run
by hand on the host gets all the way to the transport check and prints
the FATAL message the test is looking for.
So compare the image against the checkout before trusting either: one
container start, md5 of the three startup scripts, and a refusal that says
which ones differ and how to rebuild. Content, not any claim about how the
image was produced.
This is a guard rather than a fix - it does not make a stale image fresh,
it stops thirty-seven tests from reporting on the wrong code. Six CI
rounds went into a fault that this would have named on the first.
Tested: driven against a stubbed docker whose image reports a different
digest for init-firewall.sh - refuses and names that file, with the other
two matching so the message is specific rather than a blanket mismatch;
and silent when all three agree. Full fast suite passes.
Refs #59
--allow-github-push, [forge.*] sections breaking remote-access CLIs, and
the & substitution bug in briefing composition
- provider_dir already refuses a symlinked provider directory; the
briefing fragment FILE inside it was unchecked, and head -c followed a
symlink pointing anywhere on the host. Reproduced the leak directly
(a harness fragment symlinked to a secret file printed verbatim, exit
0) and add the same readlink -m / containment check at file
granularity.
- --allow-github-push stopped doing anything once git-push-guard.sh
moved to reading DEJIMA_PUSH_HOSTS instead of ALLOW_GITHUB_PUSH
directly; the flag now appends github.com/ssh.github.com to that
list, which is what the guard actually checks.
- dejima-project-lib.sh only sourced dejima-config-lib.sh, so the
[forge.*]/[harness.*] extension point never got overridden for any
CLI that doesn't separately source dejima-provider-lib.sh - confirmed
dejima-ttyd dying with "unknown section [forge.gitea]" against a
config install.sh itself generates. Now sources provider-lib too,
fixing every affected CLI in one place.
- _briefing_substitute used bash's ${text//pat/rep}, where 5.2's
patsub_replacement makes a literal "&" in the replacement re-expand
to the matched placeholder text - a [mounts] read_write value like
/tmp/x/R&D made composition refuse outright. Reuses the same
prefix/suffix-removal helper already written for
hooks/sandbox-context.sh's copy of this problem.
- Two false doc lines corrected: docs/security.md overstated what
briefing read-only-ness protects (the composed text, not a
provider's own fragment file living in the checkout), and
workflows/worktree-pr/briefing.md still named the pre-rename
gitea-merge-guard.sh hook.
Each fix has a test that reproduces the failure against the unpatched
source and passes against the fix. Full fast suite: 515 passing (508 +
7 new), 0 failing.
Four runs of the transport test have now ended with a log that stops
partway through init-firewall.sh and a container that exited 1, and the
last of them came from an image whose init-firewall.sh cannot fail
silently: it carries an ERR trap that nothing reaching that point can
bypass, and a resolver that prints on every path before it returns -
success, dig failure and no-A-records alike. Verified the code is in the
image, too: the COPY layer was rebuilt (DONE, not CACHED) in the three
builds that included it.
A script cannot both be there and say neither of those things. So the
question is no longer where in the script it aborted - it is whether the
script aborted at all, or whether something is ending the container from
outside. The log cannot tell those apart; `docker inspect` can.
So capture it before the container is removed: exit code, OOMKilled, the
daemon's own Error field, and the start/finish timestamps. Plus a
timestamped tail, which separates a container that stopped dead from one
that stalled first.
No behaviour change, and no new assertion - this only widens what a
failure prints.
Refs #59
`docker wait` returns the moment the container exits, but the json-file log
driver writes asynchronously, so reading the log right then can return one
that stops mid-stream.
This is what the transport test has actually been failing on, and it took
three fixes to the wrong thing to see it. The log ended partway through
init-firewall.sh's domain loop, at a slightly different domain each run,
which read as an abort in that loop. It survived bounding the lookups,
retrying them, and reporting each attempt - and the giveaway was in the last
run: the log ended with no message from an ERR trap that cannot be bypassed,
and no output from a retry that prints on every path before it returns.
Nothing had aborted. The read was short.
settled_logs takes reads until two consecutive ones match, which is the
driver having caught up. It is deliberately content-neutral: waiting for an
expected string would make the wait pass or fail on the very thing the caller
is about to assert. wait_ready's own dump goes through it too - a truncated
log is just as misleading when the container is stuck as when it has exited.
The failure dump now also prints the log's length, because a short read and a
container that died mid-startup are indistinguishable without it. That is the
line that would have ended this three rounds earlier.
Tested: settled_logs driven against a stubbed docker whose first two reads
return a growing prefix and whose third returns the whole thing - it takes
four reads and returns the complete log, where a single read returns the
first line. Full fast suite passes.
Refs #59
The runtime suite aborted a container in the fixed-domain loop twice, at a
different point each time - once after files.pythonhosted.org's addresses,
once immediately after its "Looking up" line with none. A cause that moves
between runs is not a bad domain; it is the lookup itself failing, and
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
under `set -o pipefail` dies on the ASSIGNMENT when dig exits non-zero -
before the "returned nothing" check below it can say anything. Identical in
shape to the GitHub fetch earlier in this branch, and identically invisible.
So the same treatment. resolve_domain retries three times, reports each
attempt with dig's own exit status and output, and distinguishes the two
failures that an empty A list conflates: a lookup that errored, and an
answer that arrived carrying no A records - a CNAME-only response, or one
dnsmasq's stop-dns-rebind refused. Each attempt is bounded with +time=3
+tries=1, because dig's defaults are 5s x 3 tries, so a single unanswered
lookup could spend 45s of a startup budget across three attempts.
Retrying is right on its own terms here, not just as a workaround: these are
the first queries through a dnsmasq that dns-proxy.sh started moments
earlier.
The extra-domains loop below used the same construct, and there it also
contradicted its own comment. Failures there are documented as non-fatal -
"a host may be transiently unresolvable, and that shouldn't prevent the
firewall from coming up" - and that held for a domain that resolved to
nothing, but not for one whose lookup errored: pipefail took the whole
script down rather than warning and skipping. It now goes through
resolve_domain, which makes the comment true.
Tested: six more bats tests drive resolve_domain against a stubbed dig -
records returned, a failing dig reported instead of swallowed (with dig's
own message preserved), a CNAME-only answer distinguished from a failed
lookup, recovery on a later attempt, each attempt bounded, and the
extra-domains loop no longer assigning a dig pipeline directly. The last of
those fails when the bare construct is restored, checked. Full fast suite
passes.
Refs #59
Follow-up to the fetch fix in this branch, from the same root cause showing
up in a second place. The runtime suite's transport test, with the log dump
this branch adds, produced:
Reinstating captured Docker DNS rules
Looking up GitHub's IP ranges...
Loading GitHub's ranges into the ipset...
+ 4.208.26.197/32
... (GitHub ranges) ...
Looking up registry.npmjs.org...
Looking up platform.claude.com...
Looking up gitlab.com...
Looking up pypi.org...
Looking up files.pythonhosted.org...
+ 151.101.192.223 (files.pythonhosted.org)
and then nothing, exit 1. Five of eight fixed domains resolved, and the
script died in the loop without a word - the same silent `set -e` abort as
the GitHub fetch, in a different place.
So rather than patch the second instance, put a floor under all of them:
-E and an ERR trap in each of the three scripts that run during container
startup, naming the script, the line and the failing command. The explicit
`exit 1` paths say more than a trap can and stay exactly as they are; this
only covers what previously said nothing at all.
Each trap names its own script, because the three run back to back into one
log and "aborted at line 128" is useless without knowing whose line 128.
-E rather than plain -e because without errtrace the handler does not fire
inside functions or subshells, which is where the fetch and both resolution
loops live. A failure inside a command substitution therefore reports twice,
once from the subshell and once from the statement - worth the noise, since
for `x=$(a | b)` the first line is the only thing that says whether it was
`a` or `b` that failed.
log-shipper.sh is deliberately left alone: it does not use -e at all, on
purpose, so one bad line does not kill the tailer.
Tested: a grep test holds all three scripts to setting -E and installing a
trap that names them, and a behavioural one extracts the real trap line from
init-firewall.sh and fires it, asserting it reports the failing line number
and command. That second test is what catches the mistake this is most
prone to - single-quoting the trap body wrongly, so $LINENO and $BASH_COMMAND
expand at definition time and it reports its own line forever. Both fail
when the trap is renamed or -E dropped, checked. Full fast suite passes.
Refs #59
Document write_diff's sz-eq-0 withhold path: a multi-valued git config
key can compare as unchanged because gitconfig_value_for_key reads only
the first matching line while git itself resolves to the last, so a
reported finding can still yield an empty diff - state why, so it reads
as a known limitation rather than a bug. Also add an ${HOME}/.local
pattern to the audit-store boundary test: the existing three patterns
missed that ancestor of $AUDIT_DIR, which a mount there would equally
expose to the container.
init-firewall.sh runs under `set -euo pipefail`, and pulled GitHub's
published ranges with a bare `gh_ranges=$(curl -s ...)`. Three separate
problems compounded into one:
`curl` has no default overall timeout, so with neither --max-time nor
--connect-timeout the call had no deadline at all - it waited as long as
the kernel kept retrying the SYN, and the container's startup waited with
it.
When it did fail, `set -e` took the script down ON THE ASSIGNMENT. The
"ERROR: Empty response fetching GitHub's IP ranges" branch below it is
unreachable in that case - it can only fire when curl exits 0 with an
empty body - so the careful diagnostics written for a failed fetch never
ran for the most likely failure. The container exited having printed
nothing after "Looking up GitHub's IP ranges...", which reads as the
sandbox being broken rather than as GitHub being unreachable.
And nothing was retried, though both failure modes here are transient: a
network blip cost the whole launch, and a burst of container starts can
trip GitHub's secondary rate limit, which answers with a JSON body rather
than a transport error. The retry therefore wraps the response check as
well as the transfer - curl's own --retry would not cover the second case.
Three attempts, each bounded at 10s with a 5s connect timeout, delays of
2s and 4s: ~36s worst case, against an unbounded wait before. Each
attempt says why it failed, and curl keeps -S so its own reason ("Operation
timed out after ...") reaches the log too.
The two firewall self-checks further down get --max-time as well. They sit
inside `if`, so set -e cannot take the script down there, but
--connect-timeout alone does not bound a transfer that connects and then
stalls.
Also makes the failure legible from the test side. runtime.bats's
transport test captured the container log into a variable, asserted against
it with `[[ ]]`, and removed the container on the line before - so when a
container exited 1 somewhere earlier in the entrypoint it presented
identically to the check under test being broken, and the one artefact that
would have named the cause was discarded. It now prints the exit code and
the log on every one of those assertions.
wait_ready goes from 60s to 120s: a run that loses two fetch attempts before
succeeding now spends ~26s of the budget on that call, which on a runner
that has just spent three minutes unpacking an image was enough to turn a
recovery into a spurious failure. A longer budget only delays reporting a
stuck startup; nothing there passes on a timeout.
Tested: five new bats tests drive the real fetch function against a stubbed
curl - success, a failing transfer reported rather than swallowed, a rate-
limit body retried and surfaced, recovery on a later attempt, and an empty
response named as empty. Two greps guard the shape against regression: every
runtime fetch carries --max-time, and the ranges fetch is not a bare
assignment again. All five behavioural tests fail if the retry loop is
removed, and both greps fail if the old invocation is restored - checked
both ways. Full fast suite passes.
Refs #59
Document dejima-audit's staging/retention scheme as it actually stands:
bounded per-surface diffs kept under $AUDIT_DIR outside the container's
reach, the withhold cases (denylist by basename, oversized-to-stage,
binary/size, and the best-effort credential screen), the per-report
retention budget and its ABNORMAL alarm on exhaustion, and SIGKILL/reap
semantics keyed on pid + process start time. Pin the store/container
boundary with a regression test in test/static.bats, and fix install.sh's
header comment to mention dejima-audit among the CLIs it puts on PATH.
docs/providers.md gives the manifest contract per axis, with the shipped
manifests as worked examples and the key:type=default syntax that lets a forge
declare its own config keys - the thing that makes adding one require no edit
to core. It says plainly which two validation rules are deferred to Phase 3
and why, so the gap reads as intentional rather than forgotten.
The retired-variable guard is narrowed to ignore manifest-field syntax and
backtick-quoted names. GITEA_API and friends are not retired as container-side
names - that is precisely what env_export produces and what gitea-pr reads in
the container - they are retired only as host-side variables an operator used
to set. Verified the guard still fails against a planted instruction.
ttyd's own page is close to unusable from a phone for anything beyond
typing, and the reason is one missing event: xterm.js turns a tap into a
mouse click, but a touch drag is consumed by the browser as viewport
scrolling and never becomes a mouse drag. Nothing downstream ever sees
one, so tmux pane borders and nvim split separators cannot be dragged
even though both resize from a drag by default. The same gap is why
selecting text and scrolling an application's output do not work.
web/index.html replaces the frontend via --index. It is one self-contained
file because that is the only shape ttyd can serve: its HTTP handler
answers a token endpoint, a redirect and the index, so there is no route a
separate .js or .css could load from, and the sandbox's egress allowlist
rules out a CDN. xterm.js is pinned in package-lock.json and inlined at
build time; the generated page is committed so a checkout needs no build.
The page synthesises the mouse reports the application is waiting for -
SGR when it enabled 1006, X10 otherwise, and nothing at all when X10
cannot express the coordinate, since a wrapped one would make the
application act on a cell nobody touched. On top of that: two-finger and
one-finger scroll, an explicit DRAG toggle for forwarding a real drag,
long-press selection driven through xterm's own selection service, OSC 52
for copying out, and a toolbar carrying Esc, a sticky Ctrl, Tab, the
arrows, the tmux prefix, window switching, pane zoom, paste and a button
to raise the keyboard. It sizes itself to the visual viewport, so the soft
keyboard no longer covers the prompt. All of it activates only for a
coarse pointer, so a desktop browser gets a plain terminal.
TTYD_TMUX_PREFIX joins TTYD_PORT and TTYD_FONT_SIZE in remote.conf,
delivered through the same -t mechanism, and refused at the config rather
than on the phone if it is not a control chord.
The nginx example's sub_filter viewport injection is gone: the page ships
the tag itself, which also drops the requirement for an nginx built with
the sub module. Replacing the frontend gives up ttyd's zmodem, trzsz and
sixel support - rarely what anyone reaches for in an agent sandbox, and
removing --index restores them.
Tested: 40 node unit tests over the pure layers (mouse encoding and cell
geometry, the private-mode tracker, sticky Ctrl, OSC 52, prefix parsing,
endpoint construction), run from test/remote.bats so they cannot quietly
stop running; the full fast bats suite; and the built page driven in a
headless browser against a stand-in ttyd, confirming the handshake, the
framing, prefix-button output, wheel reports for both scroll gestures,
press/motion/release for a forwarded drag, long-press selection reaching
the clipboard, sticky Ctrl producing 0x03, OSC 52 decoding to UTF-8,
bracketed paste, and a stationary tap still clicking as before.
The comment neutralisation landed in the working tree after the previous
commit had already been made, so it was left behind. Both guards now describe
what they do in terms of the resolved values they read rather than naming one
forge, which is what lets the "core names no forge" assertion cover them
without carving out exceptions.
Behaviour is unchanged and re-verified: an empty push-host list refuses every
push, an empty branch list protects none, an empty merge pattern is inert, and
a pattern of ".*" still matches only the literal text.
bin/dejima-config-lib.sh no longer hardcodes forge.gitea.*/harness.claude-code.*
rows or the two literal section names - core now exposes _config_extra_spec/
_config_extra_section_known/_config_extra_keys extension points (stubbed here,
mirroring _config_extra_type_valid), which dejima-provider-lib.sh overrides to
derive a key's type/default from that provider's `keys` manifest entry. A
forge/harness manifest's `keys` line now packs KEY[:TYPE[=DEFAULT]] per key
(_provider_keyspec_parse), the same three columns the config schema table
itself used, so adding a forge no longer means editing core - it means adding
a directory whose manifest declares its own keys. Unknown keys in
[forge.<name>] stay fatal, each value still gets the same per-key type check,
and token_file's schema default survives (config_is_set is still what
distinguishes "unset" from "set to its default").
Deriving the schema per-key inside config_validate's per-line pass (which
itself runs on the order of thirty times per launch) turned out to be
expensive enough to matter - a bare manifest_get/manifest_validate round trip
per key pushed a test launch from ~10s to ~27s. _provider_keys_field reads and
validates only the manifest's `keys` field instead of the whole manifest
(the other fields still get their own full validation at their own existing
call sites - provider_validate_selection, forge_hosts, forge_env_exports,
briefing_compose), _provider_keys_cached memoizes it per axis+name for the
rest of one command substitution, and provider_root/provider_dir drop a
redundant external `dirname` call and a duplicate provider_root fetch. Net launch
overhead for this axis is now ~5s instead of ~17s.
install.sh's starter-config writer and bin/dejima-audit now source
dejima-provider-lib.sh (the config-lib source alone can no longer resolve a
forge/harness key), and the seeder walks _config_keys + config_default instead
of _config_table directly, so it keeps seeding [forge.gitea] correctly.
docker/entrypoint.sh drops its Gitea-specific host derivation in favour of the
already-resolved DEJIMA_FORGE_HOSTS; docker/docker-compose.yml drops
GITEA_SSH_HOST/GITEA_API/GITEA_TOKEN, which now arrive as bin/dejima's own -e
flags and were only interpolating to empty in compose. bin/dejima and
dejima-provider-lib.sh reword their remaining gitea-as-example comments and
fix a couple now-stale ones (a pre-rename hook filename, an unset built-in
comparison) to name no forge.
test/static.bats gains "core names no forge": a scan of gitea|github\.com
across the files above, hooks/git-push-guard.sh, hooks/merge-guard.sh and
hooks/sandbox-context.sh, with narrow, individually justified exclusions for
_warn_legacy_env's migration table (bin/dejima), the schema's actual default
forge value, two citation URLs, and the GitHub-read-only feature's own
github.com mentions - not a blanket per-file skip, so it still fails against a
real regression (verified against a planted stray "gitea.example.com" comment
and a reintroduced GITEA_SSH_HOST compose line).
test/config.bats's lib() helper now sources dejima-provider-lib.sh (a strict
superset of dejima-config-lib.sh) so its direct _config_spec/_config_keys
calls can still resolve forge/harness keys.
git-push-guard.sh takes its allowed hosts and protected branches from values
bin/dejima resolved from the selected forge and workflow. An empty host list
blocks every push, which is how a workflow that does not push becomes enforced
rather than merely described; an empty branch list protects nothing, because a
workflow with no branch policy genuinely has none. Each direction is pinned by
a test so a later change flips loudly rather than silently.
gitea-merge-guard.sh becomes merge-guard.sh and matches a literal command
prefix supplied by the forge, regex-escaped so a pattern cannot widen itself.
Empty means inert, so the hook installs unconditionally instead of being
conditionally registered.
These still read container environment a session can re-export, exactly as
before. They are fast-fail guards against an overeager agent, not a boundary;
the firewall is the boundary.
- sanitize the gitconfig key in source_hint's git-config arm; it was the
only display arm that skipped sanitize(), letting a crafted subsection
name reach the terminal verbatim
- current_sha_for_id now accepts a dangling symlink (target missing) the
same way current_content_for_id does, so approving a symlinked finding
the operator was just shown can no longer be refused as nonexistent
- scope the git-config content test to the block after the
'--- current content ---' marker, since the finding's own body line
already carries the value and made the old assertion vacuous
bash 5.2's patsub_replacement (on by default) makes "&" in parameter expansion
replacements expand to the matched text. A session id from the harness payload
(untrusted) containing "&" would defeat the substitution, leaving the marker in
place and corrupting the --resume line the operator is told to run.
Fix by not using the replacement operator on untrusted values. _briefing_subst()
uses prefix/suffix removal instead, which never interprets the replacement text,
and is applied to both @@SESSION_ID@@ and @@CONTAINER_ID@@.
Add a regression test feeding a session id with &, /, and \ characters,
asserting the id appears verbatim and no @@ markers remain. This pins all three
special cases so a future rewrite that switches to sed would catch any regression.
DEJIMA_RW_MOUNTS was resolved after --print-briefing had already composed and
exited, so the diagnostic omitted the paragraph naming a project's writable
mounts while a real session was told about them correctly. That matters twice
over: --print-briefing exists to show what a session will actually be told,
and it is what the phase's parity check runs through, so the check never
covered that paragraph and would not have caught its loss.
Resolving the mount list earlier also means --print-briefing exercises the
protected-path validation, so a bad read_write entry is now reported by the
diagnostic rather than only at launch.
The -e DEJIMA_RW_MOUNTS passed into the container is dropped: nothing in there
reads it now that the briefing is composed host-side.
sandbox-context.sh now reads the host-composed briefing from
DEJIMA_BRIEFING_FILE, substitutes the two runtime-only placeholders
(@@SESSION_ID@@, @@CONTAINER_ID@@), and appends the in-container
requirements report and .dejima.md, instead of holding the briefing
prose itself. Falls back to just the requirements report and
.dejima.md when the briefing file is unset or missing, so a
SessionStart hook failure never drops a whole session's context.
Also drops rw_mounts_line, now redundant with the {{writable_mounts}}
placeholder briefing_compose resolves host-side.
Updates static.bats/config.bats assertions that drove the hook
directly for prose that moved to bin/dejima-provider-lib.sh and the
workflow briefing fragment.
The briefing is assembled on the host from a core fragment that no provider
can override, plus the harness and workflow fragments, and mounted read-only -
so a session cannot rewrite what the next one is told. Only two placeholders
survive composition, for the session and container ids, which are not knowable
until the session starts.
forge_env_exports and forge_hosts failures were caught but discarded in
process substitutions and $(...) subshells. bin/dejima would print the
refusal and then launch anyway with a partial or invalid environment.
Capture outputs to variables first and check status before using them:
- forge_env_exports at the new call site
- forge_hosts in DEJIMA_FORGE_HOSTS assignment
Add integration test driving the real launch path with a stubbed docker,
verifying that a token file with an embedded newline makes bin/dejima exit
non-zero before invoking the container runtime.
forge_env_exports now refuses token files containing embedded newlines,
which would otherwise truncate the token (breaking authentication) and leak
host environment variables through a bare -e argument to docker.
config_is_set now validates that the requested key exists in the schema,
matching config_get's behaviour. Previously an unknown key silently returned
"not set" instead of erroring.
Add tests for both fixes: newline rejection in forge_env_exports (with
coverage of single-line files with trailing newlines), and unknown key
validation in config_is_set.
bin/dejima now resolves the forge, workflow and harness once, host-side, and
passes only resolved values to the container: DEJIMA_FORGE_HOSTS,
DEJIMA_PUSH_HOSTS, DEJIMA_BLOCKED_BRANCHES, DEJIMA_MERGE_BLOCK and the three
axis names. The forge's own environment (GITEA_API, GITEA_SSH_HOST,
GITEA_TOKEN) now travels as -e flags built from its manifest's env_export
rather than being hardcoded, with identical values.
Two of the six planned validation rules are deliberately not enforced yet.
The rule that a pushing workflow needs a forge with a resolved host would
refuse a fresh install, because gitea's ssh_host and api have no defaults and
install.sh writes them commented out - while today that config launches fine
and git-push-guard.sh simply blocks pushes. README.md documents it as
supported ("Skip this if you only push to GitHub"). It becomes enforceable in
Phase 3, once forge = none exists as the honest way to say "no forge".
The rule that every key in a forge's `requires` is set is a warning rather
than a refusal, and only for a partly configured forge. It counts with a new
config_is_set, not config_get: token_file has a schema default, so "has a
value" would have been true for a forge nobody had configured, and every
fresh install would have been warned at.
gitea-pr/gitea-issue moved out of bin/ (which SCANNED_PATHS covered) into
forges/gitea/bin (which it didn't), silently dropping both files out of
the private-address/hostname documentation-leak scan. Add the three
provider directories to the scanned set - they're shipped, operator-facing
content, and Phase 3 adds more forges to it.
Ships the first provider on each axis (forges/gitea, workflows/worktree-pr,
harnesses/claude-code) per the phase 2 plan, and moves the two Gitea CLIs
under the forge that owns them with no content change - their interface
(GITEA_API/GITEA_TOKEN from the environment) is unchanged, only their
location and how the image installs them.
The Dockerfile now copies every forge's bin/ generically instead of
hardcoding the two Gitea CLIs by name, so the image doesn't vary by which
forge ends up configured. Updated the handful of test/static.bats
assertions that hardcoded the old bin/gitea-* paths and the old literal
Dockerfile COPY/chmod lines.
Review of the previous commit found two ways past the guard.
readlink -f returns nothing unless at most the final component of a path is
missing, and the code skipped a guarded path it could not resolve. The audit
stores only happened to resolve because an unrelated mkdir earlier in the
script had already created their parent. Point [audit] dir two levels below a
directory nothing has created and a writable mount over its ancestor was
accepted - handing a session write access to where dejima-audit was about to
write the findings naming it. readlink -m canonicalises whether or not the
path exists, so a guarded path that does not exist yet is still guarded.
The readonly/read_write duplicate check compared raw config text, so the same
directory spelled with a trailing slash appeared in both lists and emitted two
-v flags for it, leaving Docker's ordering to decide whether the read-only
entry was actually read-only. Both sides are now canonicalised, which also
covers "." components and doubled slashes.
Four tests added, including the containment direction and the audit stores -
neither of which the previous tests exercised, so a regression dropping them
from the guarded list would have passed.
Mounted at their own absolute path, like [mounts] readonly and unlike
[core] share_dir, so a convention that says specs live in ~/specs keeps
working inside the container instead of being rewritten to point at
~/shared. That leaves share_dir as what it is best at: a handoff point the
agent is told about by name.
A writable mount is a host-persistence surface, so an entry overlapping
anything dejima needs out of a session's reach - the approval store, either
audit store, ~/.claude, ~/.local/bin, dejima's own checkout, or the project
mount - is refused rather than warned about. Overlap is checked both ways
after resolving symlinks, so read_write = ~ is caught by the same rule.
Refusing rather than warning because, unlike warn_if_inside_workspace, this
is a deliberate line in a hand-edited config with an obvious alternative.
The session is told which paths these are, because the briefing's
'everything else is disposable' would otherwise be false in the direction
that gets work thrown away.
.dejima.md is a project's agent-instruction file - CLAUDE.md's role under a
name that does not name a harness. Claude Code finds a CLAUDE.md by itself
and nothing else does, so as dejima grows the ability to run other agents, a
project needs one file that reaches all of them; dejima delivers it rather
than leaving each harness to look for its own.
Loaded verbatim, capped at 16 KB with truncation disclosed. Not validated -
the point of an instruction file is arbitrary prose - so the controls that
fit are the bound and an honest attribution: the surrounding text says the
project supplied this and that nothing in it widens the sandbox.
This repo's own says which half of the suite a sandboxed session can run,
including the proxy.bats test that collides with the container's own
forwarder on 127.0.0.1:443.
The sandbox already knows where a finished branch is meant to go - the
workflow ends in a PR, and the merge guard already draws the line at
merging. Generic branch-finishing guidance offers merge-locally /
open-a-PR / leave-it as a menu, which is the right default only where
nothing knows the destination. Here it is known, so asking makes the
operator answer the same question every time.
Finding 4: dejima-audit ran under set -uo pipefail (no -e), so a broken
config left AUDIT_DIR empty instead of refusing to run, letting ack/show
report a clean history while unacknowledged CRITICAL findings sat in the
real store. Add config_validate || exit 1 right after sourcing
dejima-config-lib.sh.
Findings 5, 6, 7, 9, 10, 12: fix remaining shipped-file prose that still
told operators to set variables this branch retired, or misdescribed
current behaviour (containment warning scope, the dejima vs dejima-attach
validation claim, the -- flag's actual position requirement, the header's
SANDBOX_CMD mention, a dead env var in test/remote.bats). Same class of
fix applied to four additional stale references found while building the
new test below (bin/dejima x2, docker/entrypoint.sh, docs/security.md).
Add a test/config.bats test driven off bin/dejima's own _warn_legacy_env
table (not a hardcoded list) that scans docs/, systemd/, docker/, and bin/
for any of the 25 retired variable names still being prescribed, so this
class of drift is caught going forward.
bin/dejima read the key nowhere and exported it nowhere, while
docker-compose.yml still sourced LOKI_URL from the environment and
entrypoint.sh derived the firewall's Loki allowlist entry from it - so
following the retired-variable notice (LOKI_URL -> [logging] loki_url)
silently broke log shipping and made a configured Loki endpoint
unreachable. Add the missing export alongside the other config-sourced
exports, using plain config_get since this is a URL, not a bool.
install.sh now writes a starter ~/.config/dejima/config from the schema
(commented at defaults, seeded from legacy env vars where present) when
none exists, and never overwrites an existing one; a leftover
remote.conf is reported as no longer read but left in place.
test/config.bats gains 6 tests covering the above plus CI and README
completeness, and test/config.bats is added to the fast CI job.
README.md and docs/remote-access.md are rewritten to document the
config file and the [remote-access] enabled switch and
--allow-github-push flag instead of the retired environment variables;
test/remote.bats' doc-completeness assertions are updated to match.
ALLOW_GITHUB_PUSH=1 left exported in a shell profile still opened the GitHub
push path while the retired-variable notice claimed it was no longer read.
A stale exported GITEA_TOKEN or GH_TOKEN also survived a missing token file
and reached the container. Both now take a plain assignment, matching the
stance ALLOWED_DOMAINS already documents: an exported value must not be able
to grant something with nothing recording that it did.
The branch removes the sandbox's client-side fingerprint - no ANTHROPIC_*
variables, no explicit base URL - but the request the API actually received
was still built by CPython's http.client, which put a different one back on
the wire. Measured against a raw upstream socket, three tells:
- `Accept-Encoding: identity`, which putrequest() supplies whenever the
caller passed no accept-encoding. No browser or Node client sends it.
- every header name lowercased, where the client sent mixed case
(`Content-Type`, `Anthropic-Version`, `X-Stainless-Lang`).
- the block reordered: a dict puts every header the proxy touches
(authorization, content-length) at the end, whatever position the
client gave it.
A dict also collapses repeated headers to the last value, silently.
Emit the block header by header instead - putrequest(skip_host,
skip_accept_encoding) then putheader() per header in arrival order - so what
goes upstream is the client's own block with only the values that must
change changed: Authorization, Host, Content-Length, and anthropic-beta when
the OAuth value isn't already among the client's own. That last case is now
left completely untouched rather than re-joined, since re-joining normalizes
the client's spacing, which is the same class of tell as the casing. A
bodyless request no longer gains a Content-Length it never sent.
The fixture grew a `header_lines` field (names as they arrived, in order,
repeats included) because its existing `headers` map lowercases keys and so
cannot see any of this. Eight tests, five of which fail against the previous
implementation; the other three guard behavior the rewrite could have broken
(one injected token for a repeated Authorization, chunked de-framing, Host
rewritten to the upstream).
Not closed, and now stated in docs/security.md rather than left implied: the
connection is still made by CPython's ssl, so the TLS handshake (JA3/JA4)
and the absence of connection reuse distinguish it at that layer.
Verified: proxy 30/30, static 134/134, audit 84/84, project 69/69.
runtime.bats still needs a Docker-capable host.
The three containers this branch adds took the file from thirteen to
sixteen, not fifteen. The number is what the note's arithmetic about the
unauthenticated api.github.com/meta quota rests on.
main stopped mounting ~/.claude as a whole (#51) and dropped both
credential masks with it (#50), which is where every conflict came from -
this branch was still written against the masked, whole-directory shape.
- bin/dejima: the mask over .credentials.json becomes a plain mount of a
file the container would otherwise not have. Renamed to
SANDBOX_FAKE_CREDENTIALS_FILE, created only on the tls path (the loopback
transport authenticates with the placeholder token and needs no file at
all), and added to cleanup()'s rm -f. The .credentials-proxy-tmp mask and
its mkdir are gone entirely: the proxy's staging directory never crosses
into the container now. main's proxy-off branch, which mounts the real
credential explicitly, is kept as-is.
- docker/entrypoint.sh: keep main's staged-config copy step and its
empty-credentials fallback ahead of this branch's transport validation and
TLS terminator setup. The fallback's non-existence guard now covers the
fake credential too, so the tls path is left alone.
- docs/security.md: one credential bullet, describing the TLS terminator on
top of main's "it simply isn't mounted" framing; this branch's
cert-pinning ladder kept; its ~/.claude read-only-overlay bullet dropped
in favour of main's mount inventory, which supersedes it.
- test/static.bats: comments that still described the masks corrected, and
the tls test now asserts the fake credential mount by name.
Verified: static (134), proxy, audit and project suites pass. remote passes
too - the single failure in this worktree is the suite's `-t` argv grep
matching the "anthropic-tls-transport" path, not a code change.
Claude Code ignores an unparseable settings.json wholesale, with only a
startup warning, so the sandbox comes up with none of the operator's
permissions, hooks or plugins - and, because
skipDangerousModePermissionPrompt goes with them, the first visible
symptom is an unexpected bypass-mode warning that says nothing about
settings at all.
This used to be caught by accident. The sandbox settings copy was
generated by running the host file through jq, so a malformed file
failed there and set -e stopped the launch. Since the override layer
became a jq -n delta (#47) and the real file is staged verbatim (#48),
nothing reads it on the host any more and the breakage surfaces inside
the container instead, far from the file that caused it.
Check it explicitly at launch, where the fix is at hand, and print jq's
parse error with the path. Found by a stray character appended to a real
~/.claude/settings.json, which cost a session its whole config.
The delta-not-a-copy invariant is retested rather than counting
mentions of the operator's settings.json: it now asserts the override
layer is built with jq -n and that the operator's file is never an input
to it, which is the property that actually matters.
Closes the split-brain where bin/dejima resolved audit.dir from the config
while bin/dejima-audit still resolved it from the environment with an
XDG_DATA_HOME fallback, so the launcher's pre-launch unacknowledged-CRITICAL
check could inspect a store dejima-audit never wrote.
docker-compose.yml bind-mounted ${HOME}/.claude read-write and then
layered read-only overlays back over the config files inside it. Every
one of those overlays was a single-file mount nested inside a
host-writable mount, and a single-file bind mount is pinned to the
dentry at its mount point. A host-side atomic write - temp file then
rename, the normal pattern - unlinks that dentry, the mount lazily
detaches with no error anywhere, and the path falls through to the
host's real writable file. docker inspect still reports the mount;
/proc/self/mountinfo does not.
That is #47 (settings.json), #48 (the same for every other overlay) and
#50, which is the one that matters: the empty-file mask over
.credentials.json detaches on the first token refresh and hands the
sandbox the host's real OAuth access and refresh tokens. Confirmed live
in a running container.
The cheaper fixes don't hold. ~/.claude is a Syncthing folder on at
least one host, so an external daemon renames settings.json and
CLAUDE.md on its own schedule. .credentials.json is renamed by the
host's own Claude Code as well as by dejima's proxy, so making the
proxy write in place wouldn't keep the mask attached. The container has
only NET_ADMIN/NET_RAW, so it can't re-assert a mount from inside.
So stop mounting ~/.claude wholesale. It is now created in the image,
container-local, with three classes of content layered in:
- read-write directory mounts for state that must survive the session
(projects, file-history, backups, tasks, plans, sessions). Directory
mounts are immune to this bug: a rename replaces an entry within the
mounted directory, not the mount point itself.
- read-only directory mounts for the config that carries code
(plugins, hooks, skills, commands, agents, output-styles).
- container-local copies for the top-level config files. settings.json,
CLAUDE.md and statusline.sh are mounted read-only at
/etc/dejima-claude/ and installed into ~/.claude by entrypoint.sh,
the pattern PR #49 established. Nothing on the host can rename the
container's own filesystem.
Everything else, .credentials.json included, is simply not mounted. The
credential is now unreachable from the sandbox by construction rather
than by a mask that has to stay attached; entrypoint.sh gives the
session an empty container-local one so the in-container state matches
what the mask used to provide. SANDBOX_CREDENTIAL_PROXY=0 still mounts
the real credential, but now has to say so explicitly.
Staging statusline.sh as a copy also closes the gap the audit was
carrying as a confirmed finding: the old read-only mount protected a
plain file but not a symlinked one, because runc resolves symlinks when
it sets up the destination. Docker resolves the symlink on the source
side of the staging mount, so what lands in the container is already
the real content and the host symlink is out of reach. Both audit and
static tests keep the check as defence in depth.
With the top-level files staged as copies, bin/dejima no longer needs
to scan "." for symlink targets - that entry would mount a whole host
config checkout read-only into the container for no benefit - so the
scan is narrowed to the directories that are actually mounted.
Accepted losses, all fail-closed: security/agent-sdk-venv, in-sandbox
history.jsonl, caches, and anything a future Claude Code release adds
under ~/.claude are container-local until enumerated. They work during
the session and don't persist, instead of landing unprotected on a
writable host mount.
Fixes#48Fixes#50
Refs #47
The generated settings copy was mounted over ~/.claude/settings.json, a
single-file bind mount whose mount point sits inside the read-write
~/.claude mount. A host-side atomic rewrite of the real file - which is
how Claude Code itself persists settings - unlinks the dentry the mount
is pinned to, detaching it. The session then falls through to the
operator's real file, with sandbox.enabled back on and the remote notify
hooks gone, and nothing reports it. Three concurrent sessions went silent
together this way.
Generate a delta instead of a copy, mount it at /etc/dejima-settings.json
where nothing on the host can rename over it, and pass it as --settings
from claude-wrapper.sh so every entry point picks it up, not just the
ones the entrypoint spawns. ~/.claude/settings.json goes back to being a
plain read-only self-mount, alongside CLAUDE.md, purely to block writes.
A delta rather than a copy because --settings layers rather than
replaces: the operator's own hooks keep applying from their own file, so
copying them in would run each twice. Verified against Claude Code
2.1.220 that a SessionStart hook in a --settings file and one in
~/.claude/settings.json both fire, and that --settings outranks a
project-level .claude/settings.json.
bin/dejima now pre-creates ~/.claude/settings.json when absent. The jq
read that used to fail loudly on a missing file is gone, and Docker
answers a missing bind source by creating a directory there.
Fixes#47
The scoped re-review of 15139df confirmed all five findings addressed and
raised four Minors of its own:
- fixed-domains.sh (and its static test) claimed entrypoint.sh pins the
name in /etc/hosts unconditionally. That write only happens under the
tls transport, so the claim was false for a loopback-transport session
with a proxy socket. Reworded to the property that actually holds in
both: the session reaches the API through the forwarder rather than by
resolving the name.
- The transport-validation FATAL blamed a disagreement between the host
and container halves of the credential proxy, but the check is
unconditional and also fires under SANDBOX_CREDENTIAL_PROXY=0, where no
proxy is running. Restated without asserting one is.
- The static test's unset case ran without set -u, unlike both real
consumers, so dropping the `:-` default would have survived it.
- dejima-test-badtransport was the only per-test container not registered
in teardown(); an abort between `docker run -d` and the inline `rm -f`
leaked it and then the per-run network.
- The claim that a bypass of the /etc/hosts pin has "no route to the real
API" was false: platform.claude.com is still allowlisted and resolves to
the same address, so its IP is in the port-unrestricted allowed-domains
set. Reword fixed-domains.sh, entrypoint.sh and runtime.bats to say what
is actually enforced - the name does not resolve, which defeats a bypass
that resolves by name, not a connection made to the address directly.
- Move the api.anthropic.com allowlist conditional out of entrypoint.sh
(where it appended to EXTRA_ALLOWED_DOMAINS) and into fixed-domains.sh,
which both dns-proxy.sh and init-firewall.sh already source. As an extra
domain it was exempted from stop-dns-rebind and its lookup failures were
downgraded to a warning; as a fixed domain it regains both, so the
SANDBOX_CREDENTIAL_PROXY=0 path behaves exactly as it did before the
branch.
- Reject an unrecognised SANDBOX_ANTHROPIC_TRANSPORT in entrypoint.sh. The
host and container halves branch on opposite polarity, so a typo silently
configured them for different transports and failed every API call with
nothing naming the cause. Checked in the entrypoint rather than bin/dejima
because every launch reaches it, including a bare docker run.
- docs/security.md presented the ANTHROPIC_UNIX_SOCKET rung as a supported,
tested mode; nothing here implements or tests it. Describe it as depending
on client support outside this repo, and name the kill switch as the only
rung that surrenders the credential.
Static and proxy suites pass (127 and 22). The new runtime test is
unexecuted - Docker is unavailable here; the file parses at 37 tests.
The domain is removed from fixed-domains.sh to prevent bypass attempts when
the credential proxy is active. But when the proxy is off (kill switch or
API-key auth), DNS is the only path to the API, so entrypoint.sh allowlists
it conditionally in that case. This preserves the proxy's security guarantee
while keeping kill-switch and API-key modes working.
The previous commit fixed ids on the way into the report, but the snapshot
record they are derived from is itself path<TAB>sha, read back with
`cut -f1`. A filename containing a TAB was therefore truncated at it
before any encoding ran: ".git/hooks/pre<TAB>commit" was reported as
".git/hooks/pre" - still CRITICAL, since the truncation still lands under
.git/hooks/, but naming a file that does not exist. Unackable, and nagging
at startup forever: issue #40's exact failure, reached through the record
format instead of through sanitize().
hash_paths and tcb_snapshot now write the encoded path, which by
construction contains neither TAB nor newline, and compare decodes it
before touching the filesystem, classifying severity, or matching a
baseline entry. The report body keeps the encoded form it already arrives
in. Ordinary paths encode to themselves, so the record is byte-identical
for them.
enc_id gains a subprocess-free fast path, because this puts it on a
per-file loop on both the snapshot and the compare side and a bare
`git init` alone ships ~14 sample hooks - unconditionally running the
exact check took the audit suite from 55s to over two minutes. The fast
path decides by pattern matching under `local LC_ALL=C` and is
deliberately conservative: anything that might need encoding falls through
to the exact check, so ordinary text carrying a \302 or \342 lead byte
(an em dash, say) costs a little time and changes nothing.
compare wrote sanitized text to the report, ack derived the baseline id
from that text, and the next compare matched the id against the raw bytes
on disk. Any id containing a character sanitize() strips - C0, DEL, C1, or
a Unicode bidi override - therefore could not round-trip: the surface
stayed unsuppressible and its report unclearable, so the startup warning
fired on it forever. That is the alert fatigue this feature exists to end,
reached from the other side.
Ids now travel hex-encoded behind a {hex} marker, and the encoded form is
canonical wherever an id is written down - the report AND the baseline
file. An id that survives sanitize() unchanged is written verbatim, which
is every ordinary id, so reports and existing baseline files are
byte-for-byte what they were and need no migration. Display-only text (a
git config value, a cross-project slug, the workspace path) still goes
through sanitize; only the id-bearing half of a git config finding is
encoded, so a value full of control bytes cannot drag its key into the
opaque form and hide which setting changed.
The encoded form is also the honest one. sanitize() DELETES those bytes,
so a planted ".git/hooks/pre<U+202E>commit" was displayed as
"precommit" - a name no file on disk has, sending the operator grepping
for nothing. Two tests asserted that stripped rendering; they now assert
the encoded one.
An id that already begins with the marker is encoded too, so decoding
stays unambiguous. No monitored path can reach that (they are all rooted
at .claude/.vscode/.git/.envrc/.mcp.json), but a git config section name
is free text in a file the session can write and the audit parses that
file directly rather than through git - covered by a test.
docs/security.md documents the marker and how to decode it.
ack was fixed to present the oldest unacknowledged CRITICAL report before
falling back to the newest, so a stale one could not hide behind a newer
clean report. show kept its own `sort | tail -1`, which always prefers
the newer file - so an operator following the startup warning and
reaching for show first was shown a different report than the one being
warned about, and went looking for a finding that was not in it.
Both verbs now share one select_report(), which is where the reasoning
for the ordering now lives. show also names the report it printed, on
stderr so stdout stays report content alone: it is no longer necessarily
the newest, and the warning lists runs by timestamp.
ack's local variable was called `newest` even after it stopped being the
newest, with a comment apologising for it; it is `report` now and the
comment is gone.
The audit report and baseline stores were folded into the approval
store's containment check by reusing its warning text verbatim, so all
three reported that 'the global approvals and every project's under it
are writable' - the right alarm attached to the wrong consequence, and
now printable up to three times in one run with no way to tell which
store was meant.
Each store states its own: the approval store lets a session approve its
own domains and dependencies; the report store lets it delete the
exit-time findings of earlier runs, including unacknowledged CRITICAL
ones naming it; the baseline store lets it write its own
acknowledgements and suppress its findings from every later report.
Also collapses the audit-dir default, which was spelled out a second
time at the startup-warning site, back onto the AUDIT_DIR variable that
already holds it.
Tests now assert the store-specific text, plus that the report store's
warning does not mention the approval store - the reuse this fixes.
SANDBOX_GITCONFIG_DIR is the only name cleanup() removes that is first
assigned below `trap cleanup EXIT` rather than above it, so it alone
lacked the `:-` default its neighbours carry.
Issue #42 described this as reachable when `mktemp -d` fails. It is not:
a failed `VAR="$(...)"` binds VAR to the empty string before set -e
fires, so the trap completes. The window between the trap and the
assignment holds only comments, leaving no way to exit inside it at all.
Fixed anyway as an invariant rather than a live bug, and pinned with a
static test that checks the whole class - every name cleanup()
dereferences is either assigned above the trap or carries a `:-` form
somewhere in the function - so the edit that puts something exitable in
that window fails the suite instead of silently truncating the trap
before the audit it deliberately runs last.
An issue fixed by a merged PR sat open until someone noticed, and the
reason generalises past that one ticket: the session ends at the PR, the
operator merges later, and the agent is not there when it happens.
Anything left for "after the merge" - closing the issue it just fixed,
most obviously - is work scheduled for a moment the agent does not attend.
Gitea will do it instead, given a closing keyword in the PR body: Fixes,
Closes or Resolves against #N, applied when the PR merges. That is the one
part of the handoff still under the agent's control at the time it writes
the PR, which is why it belongs there rather than in a plan to tidy up
afterwards. So the context asks for it now, with Refs #N as the deliberate
opposite for work that touches a ticket without finishing it.
Two details that are easy to get wrong and that fail silently either way.
The reference has to be the short #N form - an issue's full URL renders as
the same link and does not trigger the close. And a keyword closes the
issue whether or not the PR addressed all of it, so the whole issue body
is worth reading first, since the ask is routinely wider than the title.
Kept general rather than naming the issue that prompted it: this hook
ships with dejima and runs against whatever project it is pointed at, so a
reference to this repo's own numbering would mean nothing there.
Tested: 104 static tests pass, including a new one pinning all three
points against future trimming. The hook still renders valid JSON with the
added prose - its backticks live inside a quoted shell string, so escaping
is load-bearing - and every added line is under 80 columns.
GH_META_TOKEN raises the api.github.com/meta ceiling from 60 requests an
hour to 5000, and nothing about a run looks different when it stops doing
that. A revoked token, or an expiry a year out, presents as green for a
while and then containers failing to boot in init-firewall.sh - which reads
as a regression in the firewall rather than as a lapsed credential, and
sends whoever hits it looking in the wrong file entirely.
So ask the API what the ceiling is rather than testing the variable for
emptiness and assuming the rest. 5000 means the token authenticated, 60
means it did not, whatever the reason. That also separates a wrong secret
name from a rejected token: the first arrives empty, the second comes back
with the anonymous ceiling, and each gets the message that fits it.
No secret at all is not a failure. The suite still runs, it just spends the
anonymous quota - the right default for a fork, and only a problem once
runs get frequent. It says so and exits 0.
Deliberately no curl -f: a rejected token still answers with the header
this reads, and the ceiling it reports is a better diagnostic than curl
exiting silently on the 401.
Verified the header parse against the live API - it returns 60
unauthenticated, which is the value the check rejects - and exercised each
branch of the comparison. 103 static tests pass.
Refs #37
Every container runtime.bats starts fetches GitHub's IP ranges from
api.github.com/meta in init-firewall.sh, and unauthenticated that endpoint
allows 60 requests an hour per source IP - shared with everything else on
the runner host. The file starts thirteen containers, so two runs in an
hour exhaust it, and every container after that fails to boot with a rate
limit error rather than anything the test was checking. Which tests fail is
then a matter of timing rather than of what they assert.
Pre-existing, but not reachable until the build got fast. At ~48 minutes a
run the starts were spread thin enough to stay under the cap; at ~4 minutes
they are not. The two prior runs on this branch had no rate limit hits, and
the first run after the job reached 3m38s had two.
init-firewall.sh already sends the token when one is set, and its comment
there says a session that starts sandboxes in a loop will exhaust the
anonymous quota and every subsequent launch will fail. CI is that session -
it just never set the variable. So forward GH_TOKEN to every container
start, and set it in the workflow from a repo secret.
Unset stays fine: it expands empty, and both init-firewall.sh and
entrypoint.sh test it with -n, so empty behaves exactly as absent. Local
runs keep the no-token path as their default, which is what the comment
this replaces was protecting.
The secret behind it wants to be a fine-grained PAT with no permissions at
all. /meta is public data and needs none, and entrypoint.sh hands GH_TOKEN
to `gh auth setup-git`, so any scope granted here becomes a scope available
inside every sandbox the suite starts.
Refs #37
Three independent hardening fixes:
- compare's "no snapshot to compare against, skipping" notice printed
even when the state file existed but was empty - the routine case on
every pre-snapshot fatal exit from bin/dejima's cleanup() trap - so it
became the operator's last line instead of the real error. Gate the
message on the state file being absent, not empty; a genuinely
missing path still gets it.
- Report rotation's eviction loop retried the same file forever if
rm -f failed (e.g. a non-writable report dir), spinning inside the
EXIT trap. Break out on a failed removal instead.
- sanitize() stripped C0 controls but not C1 controls or Unicode bidi
overrides/isolates (U+202A-U+202E, U+2066-U+2069), which can visually
reverse or reorder a planted path or config value in the report
without changing the bytes an operator would grep for. Strip those
too.
Also strengthens the two "ack refuses to spam a CRITICAL surface"
tests, which asserted only a generic "cannot" substring (matched the
prompt text, not the refusal) and a grep that passed vacuously when the
baseline file didn't exist at all. Both now assert the exact refusal
text and that the baseline file exists.
The approval store already warned when it sat inside the mounted
workspace (writable from inside the sandbox, no longer out of reach).
AUDIT_DIR and BASELINE_DIR - the exit-audit's own report and baseline
stores - had no such check, so the same trust-boundary break could
happen silently for them. Extend the existing check to all three
stores.
ack picked the newest report overall (find ... | sort | tail -1), so a
stale *-CRITICAL.txt sitting behind a newer clean report was
unreachable through ack at all - the exact failure this feature exists
to prevent. Prefer the oldest unacknowledged *-CRITICAL.txt report,
falling back to the newest report overall only when none is pending.
SANDBOX_GITCONFIG_DIR is bind-mounted read-write into the container (no
:ro in docker-compose.yml), so a session can leave an undeletable entry
in it - an unwritable subdirectory is enough. Under set -e, a failing
command in an EXIT trap replaces the shell's own exit status, so an
unguarded `rm -rf` there aborted the trap before dejima-audit compare
ever ran, and the shell reported 1 instead of the container's real exit
code. Guard it the same way the marker-directory removal already is.
Two ways an unacknowledged CRITICAL finding could lose its report's
eviction protection without being genuinely acknowledged:
- id="${body##*: }" took everything after the LAST ": " in the finding
body, so a monitored path containing its own literal ": " (a legal
filename byte sequence, e.g. ".git/hooks/pre-commit: decoy") derived
the wrong id. Anchor each extraction to the same fixed label text the
surrounding `case` arm matched instead (shortest-prefix strip).
- Approving a finding for a path deleted between `compare` and `ack`
recorded a `safe <id> ` baseline entry with an empty sha, which
baseline_suppresses() can never match - permanently inert - while
still advancing crit_acked and letting the report shed -CRITICAL.txt.
Refuse instead when current_sha_for_id returns empty, and record
nothing.
This job needs three things a workflow cannot ask for: the host network
namespace, the Docker socket, and a /tmp shared with the host. All three
come from act_runner's config.yaml. The preflight step already checks for
them and fails in seconds with the fix in the message - but ubuntu-latest is
a label every runner carries, so which runner picked this up was a coin
flip, and a re-run that landed on an unconfigured one failed on a repo that
had not changed.
Requiring a label only the configured runner has turns that from something
diagnosed into something that cannot happen: no matching runner means the
job queues rather than running somewhere it cannot work. The preflight check
stays, since a runner can carry the label and still lose part of its config.
The fast job keeps ubuntu-latest - it needs nothing but node and apt, so it
genuinely runs anywhere, and leaving it unpinned keeps it off the one runner
that is scarce.
bin/dejima-audit's terminal header and report file both claimed "this
session changed" the host-persistence surfaces it diffs. That's an
attribution claim the tool can't support: the diff covers everything that
changed during the snapshot-to-compare window, regardless of source - a
concurrent dejima or Claude Code session on another project, the operator
editing files by hand while the session ran, or any other background
process. The tool has no way to tell those apart from the audited
session's own actions.
The sharpest case is ~/.claude/projects: it's a single directory shared by
every Claude Code session on the host, not scoped to the one being
audited. An entirely innocent concurrent session working on an unrelated
project produces a CROSS-PROJECT history/memory write finding that reads
like the audited session poisoned another project's transcript - a
frightening, wrong accusation in the highest-attention part of the report.
Operators who learn findings can have innocent causes start discounting
all of them, which is the alert-fatigue failure this feature exists to
prevent.
Reworded the terminal header and the report-file header to say the
surfaces changed while the session ran, not that the session changed
them, added a one-line explanation to the report file (the terminal block
is size-budgeted to ~20 lines and the corrected header already carries
the accurate claim, so the fuller explanation lives in the file instead),
and documented the caveat - with the ~/.claude/projects case called out
specifically - in docs/security.md and the README Security notes teaser
that links to it.
The ARG block sat above every expensive layer in the file, so any build
overriding one of them - test/runtime.bats does, and so does any host whose
uid isn't 1000 - invalidated the cache at that point and rebuilt everything
after it. That is git-delta, gosu, Neovim, the Go toolchain and Playwright's
chromium apt dependencies, all re-fetched to arrive at a difference that
amounts to one useradd. It cost 1169s of a 1379s runtime.bats run, in a
single test.
Nothing between the top of the file and `USER` needs the sandbox user to
exist - those steps all install system-wide as root - so the ARG block and
the chowns that depend on it move down to just above `USER`, and everything
above stays reusable across an override.
One step needed more than moving. `chown -R $SANDBOX_USER /usr/local/share`
ran before Neovim unpacked into /usr/local/share/nvim; below the move it
would run after, and would hand the sandbox user Neovim's runtime files -
a privilege widening arriving silently as a side effect of a change about
build time. The recursion is now scoped to the npm prefix, which is the
only thing under there that user writes to, with /usr/local/share itself
still chowned so anything creating entries in it still can.
The static Docker tarball the runtime job installs ships the CLI and the
daemon but not the buildx cli-plugin, and that plugin's presence is what
`docker build` uses to decide which builder to run. Without it every build
in this job fell back to the legacy one, which creates, commits and removes
a real container per instruction - measured at ~10s a step across 56 steps,
more of the build's wall time than the downloads and compiles it wrapped.
Installing the plugin switches all three builds in the job over without
changing a single build command. Nothing parses build stdout, so BuildKit's
different output is not a concern: all three invocations already send it
to stderr.
The choice of builder is silent, and so is getting it wrong - a download
that returned an error page still chmods fine, and the only symptom would
be the slow builds this is meant to remove, which look like nothing being
wrong at all. So the step asserts the plugin loads rather than assuming it.
Review follow-up to the previous commit. Two coverage gaps, no code defects.
The brief called exit-status preservation the single most important
correctness question of that change - moving the audit into the EXIT trap
tail means a bare failure there would replace the shell's exit status, which
is the exact bug the marker-directory comment already existed to prevent -
and it was verified only by reasoning. Two tests now drive bin/dejima with a
stubbed docker exiting 5, once against an audit binary that exits 3 and once
against one that is missing entirely (127), and assert bin/dejima still
reports 5. Neither discriminates this task's move on its own: the invocation
carried `|| true` before it too, so both pass against the parent. They are
honest regression guards for a guard whose failure mode became far more
dangerous once the call moved into a trap - not fail-first evidence, and are
recorded as such.
The static test named "the exit-time audit is the last thing that writes to
the terminal" only asserted the call sits inside cleanup(). A reordering that
put it before the marker-directory warning would have passed while violating
the invariant the name claims. It now also asserts the marker line precedes
the audit line. The original assertion is what fails against the parent.
Also notes in cleanup() that the guarantee holds relative to the guarded
tail, not end-to-end: the unguarded rm calls earlier in the function are all
host-mktemp'd paths rather than session-writable, so a genuine failure there
is not session-triggerable, but a future reader should not assume the whole
function is exception-safe under set -e.
bats test/static.bats test/audit.bats test/remote.bats - 282/282.
bash -n clean on both scripts. runtime.bats and proxy.bats need Docker and
still want a host run before merge.
cleanup() ran after the audit and can print - deliberately provokable by
a session, per the comment there. Move the audit into the trap tail and
pin the invariant, so the next teardown message added does not silently
defeat the report layout.
Exit-time reporting can be ignored exactly once, permanently. Startup is
also the correct moment: every CRITICAL surface is defined by running
code the next time you use the repo.
The runtime job's containers are siblings, not children: the host daemon
creates them over the mounted socket, so it resolves every -v source
against the host's filesystem rather than the job container's. Fixtures
bats built inside the job did not exist there, and Docker's answer to a
missing bind source is not an error - it invents an empty directory and
mounts that. Seven tests failed on it, and an eighth passed while just as
broken, since an empty read-only directory satisfies exactly what the
overlay-dirs test asserts.
Share one directory between host and job and point TMPDIR at it, so
mktemp -d and bats' own BATS_*_TMPDIR name the same thing on both sides.
The preflight now probes that end to end - write a file, read it back
through the host daemon - because a volume the runner has not been told
to allow does not arrive, and would otherwise surface as the same seven
failures forty-six minutes later.
Resolving the paths then exposes a second, separate problem: CI runs as
root while the container's node is uid 1000, so the fixtures are owned by
the wrong user. mktemp -d is mode 700, which node cannot even traverse,
and a unix socket binds at 755, which it cannot connect to - connect
needs write. Tests 3, 25 and 30 already widened their fixtures for this;
13, 14 and 24 did not. No effect where the owner is already the accessing
uid, which is why a dev machine never saw any of it.
Test 30 mounted its hook straight out of the checkout, which under CI
lives in act's workspace volume and has no host path at all. Mount a copy
from the shared tmpdir instead.
Only path findings had a namespaced id, so baseline_suppresses() -
consulted only in the path-diff loop - could never suppress a
COMMAND-EXECUTION git config:/git alias:, HOST-CODE-EXECUTION
statusline.sh changed, or TCB (sandbox self) modified finding, and
ack's ackable gate refused a/s on all three. A report whose only
CRITICAL finding was one of those types could never reach
crit_acked -eq crit_seen, so it could never lose its -CRITICAL.txt
suffix and stayed exempt from age-based rotation forever - the exact
alert-fatigue failure this plan exists to prevent, and it would have
made the upcoming startup warning fire on every run with no way to
clear it.
Give every acknowledgeable finding a namespaced id (gitconfig:<key>,
statusline:<name>, tcb:<relpath>, alongside the existing bare-path
ids), consult the baseline in the git-config, statusline and TCB
loops the same way the path loop already does, and teach ack to
derive the right id/hash pair for each finding type. Add
id_severity(), which dispatches CRITICAL/WARNING by namespace, and
switch baseline_suppresses()'s spam guard to use it instead of
path_severity - otherwise a spam entry for a gitconfig id would fall
through path_severity's default INFO case and silently be allowed to
suppress a CRITICAL command-execution finding, reopening the gap
Task 7 closed for paths.
Fix a critical found in review: ack's id-derivation case matched the
workspace-file arm with an unanchored substring pattern, checked
before the gitconfig/statusline/tcb arms. A crafted git config value
like `core.hooksPath = workspace file: /tmp/evil` hijacked that arm,
so ack derived the wrong id, baselined an entry that could never
match, and still incremented crit_acked - stripping -CRITICAL.txt
from a report whose real command-execution finding was still fully
live. Anchor every arm to the fixed label prefix compare itself
emits, matched only at the start of the body, and make crit_acked
conditional on a real id having been derived, as defence in depth.
Anchoring also exposed a latent whitespace bug (WARNING lines are
column-padded with two spaces, so the generic single-space strip left
a stray leading space ack's WARNING arm no longer tolerated) - fixed
by stripping the exact known prefix per tier.
Walks the newest report and records spam/safe entries. Accumulates in
memory and writes once: an interrupted ack leaving a critical surface
silently approved would be the worst failure this command has.
Recurring benign churn is what trains an operator to ignore the report.
'spam' silences a path that changes routinely; 'safe' pins an exact
hash, so a critical surface acknowledged today does not stay
acknowledged when its content changes.
Keeping only the newest report makes the evidence destructible by
inducing a restart. Keep a window, and never evict an unacknowledged
critical report on age alone.
Without a cap, 400 planted hook files push the finding that matters off
the top of its own block. Overflow is worded as an alarm rather than a
truncation notice: an ordinary session produces zero critical findings.
Also caps the WARNING block at 5. INFO was meant to absorb high-volume
churn, but its classification path is unreachable - every monitored path
resolves to CRITICAL or WARNING - so all volume actually lands in
WARNING, which was still uncapped. Overflow there is worded as a plain
count, not an alarm: warning-tier volume is ordinary, unlike CRITICAL
overflow.
Fix two review defects: the ordering test's fixture (.claude/CLAUDE.md
before .envrc) happened to pass under alphabetical comm ordering even
without real bucketing, and the INFO-collapse test never exercised INFO
since every monitored path matches an earlier CRITICAL/WARNING pattern.
Replace the ordering fixture with paths where alphabetical order would
defeat the assertion, drop the vacuous INFO test, add a real WARNING-tier
test, and comment the now-documented-dead INFO default branch.
Severity was computed and then thrown away at display time - one array,
one uniform printf, section order. The operator reads bottom-up from the
shell prompt, so ascending severity puts CRITICAL where it is seen.
.envrc, .mcp.json, .claude/settings*.json and .vscode/tasks.json all
execute on the host, and all three arrived through hash_paths as
'changed workspace file' - indistinguishable from a transcript. The
README names them as execution vectors; the audit did not.
Also retargets the "reports a single modified .mcp.json exactly once"
test's grep pattern from the old 'changed workspace file:' prefix to
the shared 'workspace file:' substring, since .mcp.json now reports
as HOST-CODE-EXECUTION instead. The test exists to assert dedup, not
the label, so this keeps it checking what it was written to check
without re-breaking as later tasks re-tier other paths.
runtime.bats builds the sandbox image inside setup_file with `docker build
-q`, which prints nothing. On this runner that step ran for over half an
hour before the first TAP line appeared, and an ordinary slow build is
indistinguishable from a wedged one while it does. Hoisting the build into
its own step gives it progress output and a duration of its own, so the
next person to watch this can tell which they are looking at.
SKIP_BUILD=1 then reuses that image rather than skipping a build - it is
still built fresh from this commit, so the invariant the test file defaults
to, never asserting against a stale image, is untouched.
The concurrency block goes: Gitea does not implement it. The word does not
appear in models/actions/{run,run_job,run_job_list}.go,
modules/actions/workflows.go, or services/actions/{notifier_helper,
job_emitter}.go as of 1.25, and the behaviour agrees - a second push left
the superseded run running and queued the new one behind it. A block
claiming otherwise is worse than none, so what is left is a note saying
what actually happens.
With tmux installed and the suite unprivileged, six failures were left,
all of them tests that drive a genuine pty through `script`: the picker's
colour test, and every test that attaches a client to a pane.
The job container has no TERM. bin/dejima-attach:44 suppresses colour on
TERM=dumb deliberately, and tmux will not start a client for a terminal
it has no terminfo entry for, so both halves failed on the terminal
rather than on the behaviour they exist to check. `env -u TERM bats
test/remote.bats` reproduces exactly those six locally.
xterm-256color ships in ncurses-base, which is Essential, so there is
nothing to install. It goes on the command rather than in the job's env:
block so that it survives su's environment handling.
The preflight guard reported "not in the host network namespace" on a job
that demonstrably was in it - act's own log line for the job container
reads network="host". What was missing was iproute2, which node:20-bookworm
does not ship. `ip -o addr show 2>/dev/null` sent "ip: command not found"
to /dev/null, grep found nothing in an empty stream and exited 1, and a
check whose entire purpose is to keep a misconfiguration from failing
obscurely failed obscurely itself, blaming the runner for the image.
/sys/class/net is per-netns and needs no tool to read, so test for the
interface directly. Same question, nothing to install, nothing to mask.
Two observations from review of #30, both about the texture of the constraints
rather than the benefits the section already lists.
The first is that the walls are mostly invisible from inside. The firewall is
not experienced as pressure; it is only met by walking into it - a domain that
doesn't resolve, a path outside the mount that isn't backed by anything. That
is worth saying because it is the opposite of what a reader would assume about
working inside a sandbox, and because it sets up the contrast the closing
paragraph needs.
The second is the driving analogy for why an unsupervised agent is more careful
rather than less, which was written during that review and cut when the section
was shortened.
Those two pull in opposite directions if stated plainly - one says the
constraints aren't felt, the other says a missing one changes behaviour - so
the closing paragraph now names which is which: what gets noticed is the absent
prompt, not the walls.
The closing paragraph also had to change on the facts. The README has grown
since #30 and now carries a "Guard rails inside the session" bullet describing
the hooks, which makes the old line - "Nothing in here catches my mistakes" -
untrue as written, and would have put a "no guardrail" analogy in open
disagreement with a bullet thirty lines above. The claim is now the accurate
one: the hooks catch the obvious cases, but nobody is reviewing each action
before it lands. The analogy says "barrier" so it keeps the image without
borrowing a term this README has already assigned to something else.
The section grows from 254 to 329 words, past the 300 it was trimmed to,
because both additions were asked for. Prose only - no code, config or
documented behaviour changed. bats test/static.bats passes 96/96.
The README explains what dejima does for the host and, in "The name", what
the arrangement is meant to be. It has never said what the arrangement is
actually like from the other side of the bridge, which is the half a reader
evaluating the tool cannot get anywhere else - every other account of an
agent sandbox is written from outside it.
Written from inside a container, working on this repo, and kept to what is
concretely different rather than to anything about inner life. The four
benefits it names are continuity across a long task; cheap failure, which is
the one worth the most (when a speculative action spends a human's attention,
an agent is quietly biased against exploring at all, and will write around an
uncertainty rather than resolve it); the ability to be decisive because the
blast radius is bounded by structure rather than by judgement; and the
allowlist running in both directions, which is the same point PR #23 put into
the session briefing - the firewall bounds where an injection could send the
user's code, and a session cannot audit itself for that from the inside.
The closing paragraph is deliberate. Without it this reads as a tool endorsed
by its own subject, which is worth nothing to someone deciding whether to
adopt it: nothing inside the sandbox catches the agent's mistakes, and that
produces more care rather than less. Same register as "Threat model, honestly
stated" further down.
Prose only, in the front matter between "The name" and "Prerequisites", where
the reflective section already sits and before the operational content starts.
No code, config or documented behaviour changed. bats test/static.bats passes
96/96, including the README content assertions and the documentation-example
checks for private addresses and hostnames.
The first run on a real runner failed 12 of 305 tests, neither reason
having anything to do with the code under test.
Eleven were `tmux: command not found`. remote.bats stands up a real tmux
server on a private TMUX_TMPDIR to exercise the attach path, so tmux is a
test dependency like bats and jq are.
The twelfth was "a session cannot rewrite Claude Code's exit code through
its marker directory", which leaves a directory mode 500 and asserts the
CLI's rm -rf refuses it and warns rather than swallowing the real exit
code. The job container runs as root, and root walks straight through
that on CAP_DAC_OVERRIDE, so no warning was ever emitted. Dropping to the
image's unprivileged node user is the honest fix - nobody runs these CLIs
as root - at the cost of a chown of the checkout.
socat goes the other way. Nothing executes it: static.bats only greps the
Dockerfile to assert it is never installed there, and runtime.bats does
not mention it at all. It was in both dependency lists on my assumption
rather than on evidence, so it comes back out.
Verified against gitea/act and gitea/act_runner rather than nektos/act,
which differs on both points this job depends on.
The docker socket needs no `volumes:` entry. act_runner's
GetBindsAndMounts appends /var/run/docker.sock to the job container's binds
unless container.docker_host is "-", and adds that same path to
valid_volumes on its way past - so the mount arrives without being asked
for, and the empty valid_volumes default never blocks it. Requesting it
again here only risked duplicating a bind that already exists.
`options: --network host` was worse than redundant: Gitea's act fork
ignores it. docker_run.go logs "--network and --net in the options will be
ignored" and then assigns NetworkMode from the runner's own value, and
run_context.go pins the job container to the network the runner created.
Upstream nektos/act does honour the option - the fork does not, and the
fork is what runs here.
So the host network namespace can only come from container.network: host in
act_runner's config.yaml, which is a runner-side change, not one this repo
can make. The preflight step still catches its absence and now names the
setting that fixes it, plus CONFIG_FILE - without that variable the runner
never reads a config file at all, so setting container.network alone is a
change that silently does nothing.
`pull_request` covers opened, reopened and synchronize, and synchronize is
the push-to-an-open-PR event, so both halves of "every new PR and every
change pushed to one" come from the one trigger. A concurrency group keyed
on the ref cancels a run the moment a newer push makes it obsolete, which
matters more here than usual because the runtime job builds an image.
Two jobs, because the halves cost wildly different amounts. Everything
except runtime.bats is seconds and needs nothing but a shell; runtime.bats
builds the image and boots real containers. Split, a typo in a doc test
doesn't wait on an image build to report itself.
The runtime job needs two things from the runner that an ordinary job does
not, and neither is something a change to this repo can grant:
- The host's Docker socket, to build and run at all. act_runner mounts it
into job containers by default, but only for paths listed in
container.valid_volumes, which defaults to empty and rejects every volume
a workflow asks for.
- The host network namespace. This one is specific: the positive control in
"a host service bound to loopback is unreachable from inside the
container, one bound to the bridge gateway is reachable" has bats itself
bind a listener to the test network's bridge gateway address. That address
is on a host interface, so a process in any other netns cannot bind it and
the control fails for reasons unrelated to what the test asserts. That
test is the containment argument for the ttyd front door - skipping it in
CI would leave the one property most worth checking unchecked.
A preflight step checks for both and prints the config.yaml change that
fixes each, rather than letting the suite fail thirty tests deep in a way
that reads like a code regression.
The image is node:20-bookworm: node because actions/checkout will not run
without it, bookworm because apt-get supplies bats/jq/socat/python3. Debian
has no docker-cli package on its own (docker.io drags in a daemon this job
would never start), so the runtime job takes the static CLI binary at a
pinned version.
static.bats now scans .gitea/ alongside the other paths. A workflow is
exactly the kind of file an internal registry address or a Loki URL ends up
pasted into, and this repo is public.
The briefing described the egress allowlist only as a limit on the agent's
reach - "requests to anything else will simply fail to connect". The README
has always framed the containment as running both ways ("keeps your host
safe from your agent, and your agent relatively safe from the public
internet"), but that second half never reached the thing that actually
reads the hook output.
It is the same mechanism in both directions, so leaving it out is an
accuracy gap rather than a matter of tone, and it has a behavioural cost.
An agent told only "you may not reach out" has compliance as its sole
reason not to route around the wall, and compliance is exactly what gives
way at the moment routing around it looks locally convenient. An agent told
that the allowlist also bounds where an injection or a compromised
dependency could send the user's code, or fetch its next instruction from,
has a reason that survives that moment - and the point generalises, because
a session cannot audit itself for compromise from the inside.
The persistence facts had the same shape of omission. Disposability was
stated five times over ("disposable", "go away when this run ends",
"silently disappears", "real work that just gets thrown away"), all of it
about what dies and none about what lives, which leaves a reader able to
conclude that everything ends with the container. It does not: ~/.claude is
bind-mounted, so the transcript and the project's memory outlive the run and
--resume picks the session back up on the host. That fact was already in the
briefing, but only at the bottom as an escape hatch for a blocked agent,
never as a plain property of the setup. It is now stated once, up front,
next to the other mounts.
Trimmed one re-derivation of the general storage rule from the worktree
bullet, now that the first bullet carries it. Deliberately kept "real work
that just gets thrown away" there: that is the sentence that prevents the
mistake, and the warning is worth more than the repetition costs.
No behavioural code changed - the edits are prose inside the heredoc.
bash -n clean, the hook renders valid JSON with the intended text, and
bats test/static.bats passes 96/96, including all eight sandbox-context
assertions (the structural ones for .worktrees/, --container and
/etc/hostname all still hold). runtime.bats and proxy.bats need Docker,
which is not usable from inside the sandbox, so they still want a host run.
The README's opening rundown listed a PR workflow and a set of in-session
guard rails, both written before this branch existed. It now adds an issue
tool to the first and a third hook to the second, so say so where a reader
decides whether this does what they need - the Files table and the sections
further down already covered them, but only after the point where someone
scanning the top has moved on.
Discovered by walking into it. Filing an issue about test cleanup, the
body mentioned restarting a host mid-run; passed as a heredoc, that put
the word into the Bash command string, where deny-dangerous-bash.sh read
it and blocked the call. The hook was right to - it matches its patterns
anywhere in the string and has no way to tell an instruction from prose
that merely describes one. Nothing was wrong except the delivery.
So --body-file has a second justification, and it points somewhere the
first one didn't. Shell quoting is solved by a heredoc just as well as by
a file; this isn't. A heredoc body is still part of the command the hook
reads. Only writing the file first gets the prose out of argv entirely.
Worth naming because the failure doesn't look like what it is: an agent
that hits it sees its own PR description rejected as a dangerous command,
and the obvious next move - rewording the prose until it gets through - is
the wrong one. The README says this now, and so does the sandbox context,
which is the copy that reaches the agent before it makes the mistake
rather than after.
The same context bullet also now states that merging is the operator's
call and that gitea-merge-guard.sh enforces it, which this PR adds and the
context had no mention of.
Tested: 307 pass. The context hook still emits valid JSON with the new
prose escaped correctly (rendered and piped through jq), and every added
line is under 80 columns. runtime.bats needs Docker and still wants a host
run.
Three changes to the Gitea tooling, all pointed at the same problem: the
sandbox can open a PR but has no way to record work it *isn't* doing, and
no brake on the one Gitea operation that isn't reversible by a human
reading a diff.
bin/gitea-issue is the issue-side counterpart to gitea-pr: list, view,
create, comment, close, reopen. A problem found mid-session becomes a
ticket instead of a footnote on an unrelated PR. It duplicates gitea-pr's
config/token/curl preamble rather than sharing a library, deliberately -
both are COPY'd into the image as single self-contained files and gitea-pr
also lives in a dotfiles checkout, so a shared library would be a third
thing to keep in step across three places. The duplication is pinned in
both files by static tests instead. `list` passes type=issues, which is
load-bearing: Gitea's issues endpoint returns pull requests too, since a
PR is an issue with a branch attached.
hooks/gitea-merge-guard.sh blocks `gitea-pr merge` inside the sandbox.
It's the API-side counterpart to git-push-guard.sh's no-direct-to-main
rule - a merge reaches main without ever performing a push, so that guard
never sees it. Scoped to DEJIMA rather than applied everywhere because a
hook is handed a command, not a conversation, and so cannot tell an
instructed merge from an unprompted one; the context that needs the line
is the headless unattended one. On the host it's a no-op.
--body-file FILE (or - for stdin) on both CLIs keeps prose out of argv. A
body composed on a command line has to survive shell quoting first, and
the things that break it - an apostrophe, a backtick, a $ - are exactly
what English and code fences are made of. Passing both --body and
--body-file is rejected rather than ranked: silently preferring one would
make a caller that passed both ship a body it didn't mean to.
Tested: six new/extended static tests, including a behavioural exercise of
the merge guard across six command shapes (bare, path-qualified, after a
`cd &&`, three pass-through cases, and the host no-op). Both directions
mutation-tested: dropping '/' from the leading character class fails the
path-qualified case, and disabling the DEJIMA gate fails the host case.
gitea-issue was exercised against a live Gitea with read-only verbs only.
307 tests pass; test/runtime.bats needs Docker and could not run here, so
it still wants a host run before merge.
The README's opening two paragraphs still described a tool that sandboxes
Claude Code. That was true when it was written; it has since grown an
egress allowlist, a credential proxy, per-project declaration, a PR
workflow, log shipping, opt-in remote access, in-session guard rails and
an exit-time audit, none of which the description mentioned. Replaced with
a rundown of what it actually does now, and kept the note about where it
came from.
Fixes issue 17. install.sh puts the CLIs on PATH, which the usage section
never said - it opened straight into commands, and every example spelled
them `bin/dejima`, as though the reader were sitting in this checkout.
Almost nobody runs them that way. Usage now points at install.sh first,
and 39 `bin/` prefixes are gone from README/security/remote-access. The
prefix stays exactly where it is still correct: dejima-audit and
dejima-anthropic-proxy are not linked onto PATH (bin/dejima invokes them
via $SCRIPT_DIR), the Files table names repo paths rather than commands,
and the authorized_keys/systemd lines need a real absolute path.
Fixes issue 19. The security notes claimed there is no automated test
suite re-checking these properties on every change. There is - 305 bats
tests, and runtime.bats asserts against a real running container. Replaced
the claim with what the tests actually cover, and kept the caveat that
matters: coverage is not completeness, the reasoning in that document is
judgement that nothing tests, and the ipset regression it already cited
still stands as the pattern to expect.
Fixes issue 16. ~/specs was bind-mounted by default and pre-created at
launch, because one operator's CLAUDE.md sends design docs there. That is
a personal convention, not a property of the tool. Removed the mount and
the mkdir; the shared scratch space section now explains how to point such
a convention at ~/shared instead.
Three more comments described files that live in that operator's dotfiles
rather than in this repo: config/nvim (twice, once naming plugins/ui.lua)
and config/tmux.conf. Reworded generically - no behaviour change, and the
packages those comments explain are still installed.
The README had grown to 1586 lines, and two sections were most of that:
"Remote access" (671 lines) and "Security notes" (297). Both are reference
material you read once while setting something up, sitting in the middle of a
file whose job is to get you launched - and the remote-access one in
particular is where the single most consequential mistake in this repo lives
(a bind address on the wrong interface exposes a
--dangerously-skip-permissions shell to your network), which deserves more
room than a section.
Both move to docs/ verbatim, with headings promoted one level. Anchors derive
from heading text rather than level, so every intra-document link survives the
promotion unchanged; the twelve links from the README's setup quick-start into
those sections were retargeted to docs/remote-access.md#anchor, and a check for
dangling anchors across all three files reports none.
The rest of the README stays where it is. Each moved section leaves a stub
that says what the doc covers and why it is separate, so the quick-start still
reads top to bottom.
Source references follow the files: the ten `See README "Remote access"` /
`"Security notes"` comments across bin/, hooks/ and install.sh now name
docs/remote-access.md and docs/security.md, which is a path a reader can open
rather than a heading they have to find. install.sh's two runtime messages say
the same thing. References to sections that did NOT move ("Headless git push",
"Throwaway ~/.claude.json", "Network request logging") are untouched.
Tests:
- The test that asserted the README heading exists under the name the scripts
use now asserts the file exists at the path they name, still reading that
path out of bin/dejima-ttyd rather than repeating it, and still checking all
seven pointers agree. It also checks the README's stub links there, so the
doc is reachable from both directions. Verified by mutation in both
directions: renaming the path in dejima-ttyd fails on the file check, and
breaking one pointer in hooks/dejima-notify.sh names that file.
- The setup-step and no-bad-advice tests read the doc file instead of awk-ing a
section out of the README. The section-carving existed because a string that
only has to appear somewhere in a 900-line README is satisfied by an
unrelated section; its own file gives that property for free.
- test/static.bats already scanned docs/ for network-detail leaks, so the
guards that keep the operator's own addresses and hostnames out of this
public repo cover the new files with no change.
302 tests pass. test/runtime.bats needs Docker and was not run.
The image build made six wget calls and two curl calls with no retry, no
timeout and no backoff on any of them. One transient network failure
anywhere in that set threw away the entire build.
That set gets exercised far less often than it looks. Every one of those
fetches sits below the SANDBOX_* ARGs, so an ordinary rebuild reuses cached
layers and touches the network not at all. A build that overrides one of
those args invalidates the cache from that point and genuinely re-runs all
eight - which is why test/runtime.bats' first test is the only one of its
thirty-three that can fail this way, and did, twice.
Twice on two different lines is the useful part: git-delta on one run, nvim
on the next. A fetch that dies somewhere different each time is a blip, not
a blocked host or a bad URL, and a blip is exactly what a retry is for.
wget's exit 4 means "network failure" specifically, which is how we know it
was wget failing and not the curl sharing that line.
The zsh-in-docker step turned out to have a worse problem than missing
retries, found on the way past. Upstream documents `sh -c "$(wget -O- ...)"`,
and in that form a failed download substitutes to an empty string, so
`sh -c ""` exits 0. The build goes green having installed no zsh
configuration at all, and the failure surfaces much later as a shell that is
inexplicably unconfigured. It now downloads to a file and runs the file, so
the download's exit status is the build's exit status. Argument handling is
unchanged - getopts reads $1 onward in either form, checked against the real
script rather than assumed.
Two static tests hold both properties, because eight copies of one flag
string is precisely the thing that drifts. A single awk pass fails on any
wget without --tries or any curl without --retry, and a second bans the
substituted-command form outright. Both were mutation-tested five ways:
dropping the flags from a wget, from both curls, reverting the zsh step,
adding a fresh unretried fetch, and running a filename other than the one
downloaded. Each mutation is caught by exactly one of the two.
The first draft of the retry test was a grep chain, which was wrong in a way
worth recording: a filtering grep exits non-zero when it discards every line,
and discarding every line is the case where nothing is wrong. Hence one awk
pass, which reports findings through its output rather than its status.
Not covered here: the rustup fetch that dejima-project-lib.sh writes into a
generated per-project layer has the same single-shot shape. Different file,
different build, and its own blast radius - worth its own change.
Tested: 305 pass (static, audit, project, proxy, remote). Every RUN body
still parses under sh -n. runtime.bats needs Docker and still wants a host
run - and it is the suite that actually exercises this, since its first test
is the one that rebuilds.
Review feedback, both points about the same six lines.
The colours were justified by reference to the operator's own Claude Code
theme and its dotfiles path. That is no use to anyone else reading this -
dejima is a general-purpose tool - and it was also the reason the values
were worth doubting in the first place: a private file is not something a
reader can check against. They are now tagged with the name of the
gruvbox-contrib color.table row each one comes from, with the URL, so the
provenance is verifiable by anyone in about ten seconds.
Cross-referenced all nine against that table. Every hex and every 38;2 RGB
triple already matched upstream exactly - bright_orange, light1, gray_245,
dark4, dark2, bright_green, bright_yellow, bright_red, bright_blue - so no
value changed here, only what stands behind them.
A test now pins that rather than leaving it to be re-checked by hand. It
transcribes the name->hex half from upstream, and *derives* the other half:
each escape is decoded back to a hex and compared against the one written
beside it, which is the mistake a hand-edited colour actually makes.
Mutation-tested four ways - an escape drifting one unit from its label, a
non-gruvbox colour shipped under a gruvbox name, a colour dropped
entirely, and a name kept with the hex swapped for a different real
gruvbox colour - each caught by the assertion meant to catch it. 303 tests
pass; runtime.bats needs Docker and still wants a host run.
The picker is the first thing a phone sees through either front door, and it
was five raw tab-separated fields under a plain heading - columns that only
lined up when every project name happened to be the same length.
Colour comes from the operator's own gruvbox theme
(dotfiles config/claude/themes/gruvbox.json), reusing its semantic names
rather than inventing a second palette: badge for the wordmark, success /
warning / error for the three session states, muted and subtle for the
fields that are context rather than answer. Truecolor only, deliberately
unpaired with a 256-colour table - a terminal without it, or tmux without
`Tc`, approximates 38;2 to its own nearest colour instead of printing the
escape, so the fallback already exists one layer down.
Columns are measured on the way in and padded to the widest value, because a
project name is a directory name: a fixed width either truncates a long one
or pushes every short one off the right edge of a phone. The rule under the
heading is deliberately the width of the heading rather than of the table,
for the same reason - a rule that chased the longest project name would wrap
exactly when the rows did.
The container column stays, and stays last. It reads `starting` for a
session bin/dejima is still building and `?` when docker could not be asked
at all, so dropping it as decoration would make either one look like an
ordinary attachable session. I removed it first and put it back.
Everything is gated on stdout being a terminal that says it can show colour,
which is what keeps `--list` clean. That is not cosmetic: the picker splits
those rows on tabs to build its own columns, ttyd feeds a run id from them
back through --url-arg, and the README documents them as scriptable. The new
test pins it with a pty, since bats never gives the script a terminal and so
could not otherwise see the difference - verified by leaking $C_OK into
list_sessions and watching it fail.
Typing into the web front door from a phone replays text you already
typed, a batch at a time. Two things place it in the browser rather than
in anything here: the duplicates are real input you can backspace over,
not a redraw artifact, and the transport is TCP, which cannot deliver the
same bytes twice. It also reproduces on demand - a long run of digits
never duplicates, while ordinary words duplicate on every correction the
keyboard makes.
The mechanism is xterm.js re-sending the whole contents of its hidden
input element when a soft keyboard *replaces* a span it has already
committed, rather than sending the difference. Nothing on this side
reaches it: CoreBrowserTerminal.ts already sets autocorrect,
autocapitalize and spellcheck to off on that element as it creates it,
and Android ignores all three - so injecting those same attributes via
sub_filter, the obvious-looking fix, would have been a no-op that looked
like one.
So document it and recommend a keyboard with nothing to correct against.
Unexpected Keyboard earns the recommendation on the modifier keys more
than on the bug - without a real Ctrl there is no tmux prefix, and Esc
and the arrows are hard to do without in anything interactive. Its
suggestions are opt-in behind a dictionary you download yourself, which
is why the wording says that and not "no prediction": the feature does
ship.
ttyd sends its page with no Cache-Control, no ETag and no Last-Modified -
src/http.c sets status, content-type, content-encoding, content-length and
nothing else - so a browser may cache it for as long as it likes. That is
harmless while the body is served verbatim, and a trap once sub_filter
rewrites it: a phone holding the un-injected copy renders it forever, so
the viewport fix reads as broken long after it works, and no later change
to the injection ever reaches a browser that has already been there.
Nothing is given up by refusing it. With no validator upstream a
revalidation could never come back 304, and the page is still gzipped to
the client - clearing Accept-Encoding applies only to the request made to
ttyd, not to the response nginx sends on.
Coupled in the test alongside Accept-Encoding, for the same reason: the
filter and the things that keep it observable are only correct together.
Verified by removing the directive and watching the test fail.
Debian and Ubuntu install nginx to /usr/sbin, which is on root's PATH and
not on an ordinary user's, so the bare 'nginx -V' and 'nginx -t' these
docs told the operator to run answer 'command not found' on a box that is
serving happily - pointing at a missing package rather than a missing
path, which is the wrong thing to go and check.
Both halves of the mobile fix can be applied correctly and still have no
visible effect, for reasons nothing reports:
- remote.conf is read once, at process start. dejima-ttyd reads
TTYD_FONT_SIZE and execs ttyd with it in argv, so editing the file
without restarting the unit leaves the old size serving. The README
never said to restart, and there is no warning anywhere in the path.
- an nginx reload whose config does not parse keeps the OLD config
running while systemctl reload still reports success. An unknown
sub_filter directive - nginx-light is built without the sub module -
is therefore indistinguishable from a directive that ran and did
nothing. The docs claimed the opposite; point at nginx -t instead.
Verified against ttyd 1.7.7's committed src/html.h: the served page
contains exactly one literal lowercase <head> and zero viewport metas,
so the sub_filter pattern itself is correct.
Five links - registration, hook, marker, push, subscription - and a break
in any of them presents identically from the phone, which is to say as
silence. Nothing in the path is wrong; what was missing is any documented
way to find out which link is.
The marker test is the useful one, so it leads: writing a marker by hand
from inside the sandbox and watching whether it disappears splits the
chain in half in two seconds, and says which half to keep looking in.
Every command here was run against a live session rather than written
from reading the code.
Also records two things the code knows and the README did not say: that
nothing sends an ntfy auth header, so an instance that wants credentials
needs them in NTFY_URL as userinfo, and that a repeat within
DEJIMA_DEBOUNCE_SECONDS is consumed without a push - which otherwise
turns a second diagnostic attempt into a diagnosis of the debounce.
Three things surfaced by running this for real.
The mobile view was unusable: text too small to read, and pinch-zoom
panned a giant canvas instead of reflowing. The cause is not a font
setting. ttyd serves no <meta name="viewport"> - not in 1.7.7, not on
main - so a mobile browser assumes a desktop-authored page, lays it out
at ~980 virtual CSS px and scales the result down. Raising the font just
gives a bigger font scaled down by the same factor, and a terminal is a
fixed grid on a canvas, so zoom magnifies pixels rather than re-wrapping.
The proxy can supply the tag the page is missing, so the nginx example
now does, together with the Accept-Encoding clearing that makes it work:
sub_filter cannot rewrite a compressed body and passes it through in
silence, which looks exactly like the injection not working. The two are
only correct as a pair, and the test couples them.
TTYD_FONT_SIZE is the smaller half, for when an honest layout still is
not big enough. Bounded 8-72 rather than merely numeric: a glyph wider
than the viewport renders a terminal showing nothing, which reads as a
broken front door rather than a typo three files back. It applies to
every client - ttyd 1.7.7 takes client options only at launch, and the
URL-query override that would let a deep link carry its own size landed
upstream after 1.7.7 was cut, so a per-client size is not available from
here. Unset sends no -t at all, which the argv whitelist now asserts.
Also documents the two-hop case, now that the cause is confirmed rather
than guessed at: proxy_ssl_server_name is off by default so the outer hop
sends no SNI and lands on the reject block; proxy_set_header is additive
within a level, so declaring Host twice - the easy way being Debian's
proxy_params plus your own line - sends two Host headers and the inner
nginx answers 400. Plus the upgrade headers and read timeout the outer
hop needs on its own account, that nginx logs 400 reasons only at info
level, and a curl --resolve that says which hop is at fault. It also
notes that fronting this with an internet-facing proxy leaves auth_basic
as the only thing in front of a host shell.
Finally, says outright that attaching hands over the whole window list.
Grouped sessions share windows by definition, so every other window on
the tmux server is one tab away. Left as it is deliberately: whoever
reaches this terminal is one prefix key away from a fresh host shell
regardless, and tmux has no per-client way to take the prefix away, so
restricting the view would be tidier without making a reachable front
door any less of a host shell.
Root cause of the host login shell reported against the last commit. The
distro ttyd package ships a system service that systemd enables and starts
on install, serving a login prompt with no --interface of its own - so it
binds every address, including the bridge address a sandbox routes to, on
ttyd's default port, which is this feature's default port too.
Two consequences. The package alone puts a host login prompt in front of
every container, which is the escape this feature is built to avoid, opened
by the setup step that the README itself hands out. And it takes the port,
so dejima-ttyd fails to bind at every start while the reverse proxy in front
of it serves the distro's ttyd instead: the operator gets the password
prompt they configured, then a host login shell, with nothing in the path
looking broken. That is precisely what happened.
Documenting it is the fix; the rest is making it hard to miss. dejima-ttyd
now checks for a listener on its port before exec'ing and refuses with the
likely cause and the disable/mask commands, rather than leaving ttyd's
one-line bind error in a unit nobody is watching. It is a diagnostic, not a
remedy - the listener exists from the moment the package lands, whether or
not dejima-ttyd ever runs - and the README says so.
The README's own verification step already probed this exact port on the
bridge gateway from inside a container, so it would have caught it; say so
where the step is, since "I'm confident about the proxy" is the reason to
skip it and the proxy is not what fails here.
Tests: dejima-ttyd's refusal is executed, not grepped - python binds a real
port, runs the script, releases it, runs again, and asserts refuse-then-run
against one port so an unconditional refusal cannot pass. The README test
now couples an apt install line to BOTH the disable and the mask, asserted
separately, since an alternation passes on a file carrying only one.
Mutation-tested: removing the check, downgrading it to a warning that execs
anyway, stripping every ttyd.service mention from the message, and dropping
either the disable or the mask line each fail a correctly-named test.
Test 79 also caught this commit's first draft naming a wildcard address in a
comment in dejima-ttyd, whose only permitted address literal is the loopback
one it binds. Reworded rather than exempted.
Suite: 280 ok, 0 not ok.
Reported from a real setup: browsing to the box's own name, not the one in
server_name, reached the front door's password prompt.
That is nginx working as documented, and the example not accounting for it.
nginx selects a server block by SNI/Host, and when nothing matches it serves
the DEFAULT server for that listen address - which, when the front door is
the only block on that address, is the front door. So `server_name
<hostname>;` isolated nothing: the vhost answered to every name that
resolved to that address, the host's own included. Behind it is a host
shell, so "answers to more names than you think" is not a cosmetic bug.
Add a first block that is default_server and serves nothing -
ssl_reject_handshake on, which refuses at the TLS layer and so needs no
certificate of its own (nginx 1.19.4+; the older `return 444;` form is noted
for anyone on an earlier release). Note the duplicate-default-server case,
since an operator who already has one must not add a second.
The two nginx tests had to learn about blocks that legitimately carry no
auth_basic, and rather than special-casing the new one they now parse the
file per server block - counting braces, so the nested location block
doesn't end a server early - and assert what each block SERVES: anything
reaching ttyd needs authentication, a block without it must serve nothing,
every block must listen on the placeholder address with ssl, and exactly one
block must be the default and must be the refusing one. That is what the
old file-wide greps were approximating; a second block was always the case
they were weakest against, which is exactly the case that just happened.
Mutation-tested: deleting the reject block, dropping default_server from it,
making it proxy instead of refuse, dropping the auth realm, appending a
second unauthenticated proxy block, and appending a wildcard `listen 443
ssl;` block each fail a correctly-named test.
Suite: 279 ok, 0 not ok (278 before, +1 new test).
Three things, all in the README.
PR feedback on the bind address: the section said "one specific address of
your LAN interface" without saying whose interface, and pointed at `ip -br
addr` without saying which of its lines to take. Both matter, because the
wrong pick here is the sandbox escape the section exists to prevent.
So state the requirement that was only ever implied: the reverse proxy has
to run on the dejima box itself. ttyd is bound to loopback, so proxy_pass
only reaches it from that machine - and pointing a proxy on some other box
at it would mean binding ttyd somewhere routable, which is exactly the
escape. An operator who already runs a proxy elsewhere needs to know to add
a second one here rather than reuse it.
Then say how to read `ip -br addr` on a Docker host, where docker0 and every
br-<hash> sit in the output next to the real interface. Only the physical
LAN interface qualifies; `ip -br addr show type bridge` names the
disqualified ones exactly, and the container's own default route names the
one address that must not be bound. Interface names rather than example
subnets, since bridge subnets vary per host.
ttyd on Debian: sid packages 1.7.7, which clears the 1.7.4 floor without an
upstream binary, but sid is not a configured source and adding it unpinned
walks the whole system to unstable. Document the pin alongside the source,
and the caveat that if apt proposes libc6 you are no longer taking one
package.
That last one needed the section's negative test loosened, so loosen it
precisely rather than deleting it: a bare `apt install ttyd` is still
forbidden, because a distro's default suite is where the too-old builds are,
but an explicitly release-targeted install is allowed since it says which
build you get. A recipe that adds unstable must also pin it. Mutation-tested
all three - dropping the -t, dropping the pin, and a non-public mirror URL
each fail the test by name, and the $host guard still catches a copyable
nginx block.
Finally, the requested split: a Setup section between Prerequisites and
Usage, walking every feature's configuration in order, each step linking to
its existing section for the reasoning. Existing prose is unmoved - this is
an index over it, for the case where you already know why and just need the
steps. It also fills a gap nobody had noticed: the README never told anyone
to run install.sh.
Full suite: 278 ok, 0 not ok, unchanged from before. All 17 intra-README
anchors resolve.
The fixture was /home/brad/my-proj in a public repo, which reads as a
requirement about one machine rather than about the slug rule. The rule
being pinned - every non-alphanumeric run collapsing to a dash, leading
one included - is unchanged by the path used to state it.
Three things the README said were not true of the code it describes, plus
the setup step it never mentioned:
- "every remote-access script" validates remote.conf. Only dejima-ttyd and
dejima-notify-watch read a value out of it and so validate it;
dejima-attach and bin/dejima treat the file as a switch and never parse
it. Say which is which, and why the front door deliberately does not
refuse over a malformed line - it is the terminal you would fix the file
from.
- the inotify-less fallback is a ~6s poll, not 5s: a 1s floor plus the 5s
sleep. README and the comment above the loop now both say what the loop
does. (systemd/dejima-notify-watch.service names no interval; its 5 is
RestartSec.)
- loginctl enable-linger was undocumented, beside an XDG_RUNTIME_DIR gotcha
that is about the same directory. Without it both user units stop at
logout and /run/user/<uid> is destroyed with the session registry in it.
Also document the marker mount as what it is - unbounded write access to a
RAM-backed tmpfs shared with D-Bus and the user manager - rather than
adding a quota, and read TTYD_PORT before checking for the ttyd binary so
an operator missing both is not sent to install a package only to meet the
identical failure.
Each of these has a sibling in this feature that gets it right, and this
is the site that was missed.
The picker validated the choice in decimal and indexed it in octal: `[` is
POSIX test, `$(( ))` reads a leading zero as base 8. "010" therefore
passed a 1-11 range check as 10 and subscripted as 8, attaching the caller
to a session they did not pick with nothing printed to say so - and a
leading zero is one stray keypress on the phone keyboard this picker
exists for. "09" aborted the script as an invalid octal constant, and a
number past int64 made both comparisons exit 2, skipping "Out of range."
to die on an unbound array element instead. One 10# fixes all three; it is
the last arithmetic site on this feature that did not have it.
bin/dejima's `rm -rf "$DEJIMA_NOTIFY_DIR"` is the last command of an EXIT
trap, and the marker directory it removes is bind-mounted read-write into
the sandbox. Under set -e a failing command in an EXIT trap replaces the
shell's exit status, so a session that left that directory undeletable
could report 1 in place of Claude Code's real exit code. Guarded like
dejima-attach's prune and dejima-notify-watch's marker consumption, which
are the same hazard on the same directory.
dejima-attach's has-session branch attached to whatever carried the group
name; the branch below it creates its session detached precisely so it can
verify the result holds the sandbox pane. Both now use one helper. Only
reachable as debris, and anyone who can create that name already has a
host shell - so this is about landing in the right window, not
containment.
And the prune now takes the run's debounce stamps with it: they are keyed
by run id, nothing else removes them, and two files per session otherwise
accumulate for the life of the login session.
bin/dejima writes the registry entry before it builds anything: an
unconditional `docker compose build claude`, then up to two derived-image
builds, and only then `docker compose run`. For that whole span - seconds
warm, minutes on a cold build - `docker ps` reports no container for a
session that is perfectly alive, and container_for correctly returns
"no such container" (1) for it.
list_sessions treated that 1 as authoritative and pruned: the registry
entry deleted and the marker directory rm -rf'd, silently. bin/dejima
never rewrites the entry, so the session was then permanently absent from
both front doors - and the deleted marker directory is the source of a
bind mount the imminent `docker compose run` declares. An operator opening
ttyd (whose landing page is the picker, i.e. list_sessions) while a second
session builds was enough to trigger it.
"Not started yet" is a third state, alongside the "gone" the prune is for
and the "couldn't ask" (2) that already fails open. It is now judged
against the entry's own started=, and the entry is listed with the
container column reading `starting` instead of being removed.
The Files table and the Tests section are where a reader goes to find out
what is in this repo, and neither mentioned the feature: six new files were
absent from the table, and the Tests section still described a suite of two
files when there are five.
The runtime containment assertion gets its own paragraph. Its positive
control is the reason a pass means anything - without it, a green result is
equally consistent with a container that reaches nothing at all - and it is
the assertion an operator should actually run before trusting a front door
they just configured.
Adds the "Remote access" section three shipped files already point at
(bin/dejima-ttyd, install.sh, and the notify hook among them), so those
references stop dangling: opt-in first, remote.conf's three keys and their
defaults, the bind address as a step to perform and verify from inside a
live sandbox, both front doors, the notification's deliberate emptiness and
where its guarantee actually lives, and attaching.
Documents what shipped rather than what was planned: dejima-attach sets no
window-size (tmux's own default already does the job, and the option's
scopes would overwrite the operator's choice), the proxy passes
$http_host rather than $host, and ttyd must be 1.7.4 or newer for
--writable - which no apt line on bookworm or 22.04 gives you.
Three tests, all scoped to the section body rather than the whole file. One
reads the section name out of bin/dejima-ttyd and asserts the heading and
every other pointer agree with it. One checks the setup steps, deriving the
config keys from valid_remote_key, the default port from remote_ttyd_port
and the unit names from systemd/, so a fourth of any of them cannot be
added undocumented. One forbids, in the section's fenced blocks only, the
two things that would ship a broken front door: $host, and an apt install
line for ttyd.
This repo is public; three of its examples were real details of one
operator's network - a .localdomain Gitea SSH host and that operator's
actual domain in bin/gitea-pr, and a LAN address as the Loki push endpoint
in docker-compose.yml. None of them is useful to anyone else reading the
repo, and publishing an internal hostname and address is not something a
reader should have to notice on the operator's behalf.
Documentation addresses now come from RFC 5737 (192.0.2.0/24) and names
from RFC 2606 (example.com), the ranges reserved so an example can never
name something real.
Two static checks pin the shapes that leaked, since the fix alone would
not stop the next one. test/ is out of scope: its fixtures stub resolver
answers, which are inputs to a test rather than addresses an operator is
told to use. Neither check can catch a personal domain that looks like any
other public one - that still rests on review.
proxy_set_header Host $host would have broken the front door on any listen
other than 443/80 - fail-closed, total and near-silent. nginx strips the
port from $host (ngx_http_validate_host truncates at the colon) while ttyd
keeps it unless it is 80 or 443, so a vhost on 8443 - what an operator ends
up with when 443 is already taken - would have had ttyd compare "name:8443"
against "name" and refuse every WebSocket. The page loads, the terminal
never connects, and the only diagnostic is one line in ttyd's log.
$http_host is the raw header, port included. It is correct at 443 too:
browsers omit the default port from Host and ttyd strips 443 from the
Origin, so both sides read "name". It is no weaker against cross-site
WebSocket hijacking either - a non-browser client controls $host exactly as
much as $http_host, and neither is what the check rests on.
Test changes, each closing a mutation that survived:
- auth_basic off; counted as authentication, so a second server block could
disable auth and still satisfy the count. It now needs a quoted realm, and
auth_basic off is banned outright.
- A server block with no listen line was invisible to the listen check.
nginx defaults those to *:80, the wildcard plaintext bind the check exists
to forbid, so listen lines are now counted against server blocks.
- install.sh had its own copy of the config-root default, and no test could
see it: they all export DEJIMA_CONFIG_DIR. It now sources the library and
calls remote_conf_path, and the install test runs once with the variable
unset.
- The ttyd version floor was matched anywhere in the message, which names it
twice, so the headline could say 1.6.0. Anchored.
- Restart=on-failure is pinned by value: these units are meant to be given
up on when remote.conf is missing, which Restart=always would undo.
Without --check-origin ttyd does no origin validation at all - its
protocol.c only calls check_host_origin() when the flag is set - and
browsers do not apply the same-origin policy to WebSockets. An operator who
has authenticated once to the front door leaves the browser holding a
credential for that origin, and any page it later loads can open
wss://<front-door>/ws and have that credential attached to the handshake.
The proxy's auth_basic is satisfied by the victim's own browser, ttyd
accepts, and dejima-attach hands the page a host shell. auth_request or a
cookie-based gate is worse, not better, here.
The check compares the Origin authority against the Host header, so the
nginx example's `proxy_set_header Host $host` is now load-bearing and both
files say so. --check-origin has existed since 1.6.3, so there is no
compatibility cost. Two comments that claimed proxy authentication was the
whole gate are corrected rather than left true-sounding.
Also from review:
- The missing-ttyd message names the version floor. -W/--writable does not
exist before 1.7.4 (1.7.3 and earlier are writable by default and take
--readonly), so an older package turns every start into a getopt usage
error, and "install ttyd" is what an operator would otherwise do.
- install.sh no longer tells an operator who has configured remote access
that nothing is listening. It branches on remote.conf, and still starts,
stops and enables nothing.
- The --url-arg comment was still not exact: `?arg=` yields an empty argv
element, which dejima-attach treats as the picker without validating it.
Benign, but it is the claim, not the code, that was wrong.
- Three surviving mutations from the review are closed: a second nginx
server block with no TLS and no auth (listen lines and auth_basic are now
checked per block, not per file), a deleted Host header, and an arbitrary
[Service] directive - the unit test now whitelists directive names instead
of denylisting the ones someone thought of.
bin/dejima-ttyd launches ttyd with dejima-attach as its command, bound to
127.0.0.1 and taking its port from remote.conf. The loopback bind is the
security guard, not a style choice: docker/init-firewall.sh:187 accepts
container OUTPUT to the default-route address (the Docker bridge gateway,
i.e. the host), and attaching tmux gives a host shell, so a listener on any
address a sandbox can route to would hand a compromised session its own way
out. ttyd is given no credential of its own, which makes the reverse proxy
the only authentication there is - stated unmistakably in both the script
header and docs/nginx-dejima.conf.example.
The two user units are shipped disabled and are never touched by install.sh:
standing up a front door is an operator decision. Both are deliberately
unhardened, and say so with reasons - they exist to hand out a host shell,
so PrivateTmp=, ProtectHome= and NoNewPrivileges= would break them rather
than contain them. The watch unit documents the load-bearing part of its
environment: both it and bin/dejima resolve ${XDG_RUNTIME_DIR:-/tmp}/dejima,
so a context without XDG_RUNTIME_DIR silently splits the two.
docs/nginx-dejima.conf.example will not load as written - every address,
name and path is a placeholder - so it cannot become a live config by being
copied and forgotten.
install.sh now links dejima-attach, dejima-ttyd, dejima-notify-watch and
hooks/dejima-notify.sh. The hook link fixes a real exit 127: bin/dejima
registers it per session by ~/.claude/hooks path, into a throwaway settings
copy, so it has to exist there - but it is still not passed to register_hook,
which would fire it for unsandboxed host sessions too. Everything stays inert
without ~/.config/dejima/remote.conf, and install.sh runs no systemctl.
test/remote.bats gains 14 tests (66 -> 80): the ttyd argv whitelist, the
opt-in and missing-ttyd and port-range refusals, an end-to-end install.sh run
into a throwaway HOME, and two tests that execute each unit's ExecStart under
env -i rather than asserting about it. A 19-mutation matrix over every guard
was caught in full.
The project_of comment's third revision still carried two claims that are
not true of this call path: that errexit's condition-context suppression
also covers it (scan runs from an if body and a while body, and project_of
is reached only from push), and that without the [ -r ] check a chmod'd
entry warns on every scan. Measured: push runs only for a marker that was
consumed and not debounced, so it is one line per push - a scan with no
marker, and a debounced scan, print nothing.
assert_argv compared two command substitutions, which strip trailing
newlines, so a vector ending in an empty argument compared equal to one
without it - carrying no bytes, but the comment claimed exactness. A
sentinel makes the comparison whole.
The fallback sleep and the warning dedupe were both unpinned. t64's
threshold is now per-arm, so the floor alone cannot satisfy the arm that
exists to test the fallback, and a new test runs the watch loop against an
undeletable marker and asserts one warning rather than one per scan.
The push tests asserted that secrets were absent from curl's argument
vector, which curl expands: -H @file, -K and -T all send bytes that never
appear in argv. Verified against a live listener - a marker copied and
passed as -H @copy delivered its X-Actions header verbatim with the suite
green. The push tests now compare the whole vector against a whitelist,
so an added argument of any kind fails regardless of what it names.
The watch loop had no floor. inotifywait wakes on the sandbox's own file
creations, and a session can also make it fail instantly by exhausting
this uid's inotify watches, so either way it could pin a host CPU
indefinitely; a warning for an undeletable marker compounded that into
unbounded journal growth. Adds a floor, a fallback sleep on failure, and
one warning per marker per process.
Also: the --once exit is now its own top-level command, since an
arithmetic error unwinds to the top level and would otherwise skip it and
run the watch loop; unknown arguments are refused instead of silently
watching; the state directory's mode is set even when another process
created it; and coverage for the guards that had none - the ownership and
non-directory arms of the state-dir check, the workspace-name reduction,
and the debounce-window validation.
The comment claiming errexit made project_of's guard load-bearing was
wrong in both halves and is corrected: bash neither inherits errexit into
a command substitution nor applies it through a condition-context call,
so `|| raw=""` is what does the work.
The previous commit added tests for the notify hook's stdin property and,
in the same breath, described the mechanism wrongly: two comments claimed
bats' `run` "closes stdin" / hands the hook "an EOF stdin". Neither is true.
`run` is a command substitution with no stdin redirection, so the command
inherits whatever stdin the suite was invoked with - demonstrated by piping
bytes into a scratch suite whose `run cat` captured them.
The practical difference matters: the other hook tests are not
stdin-independent, they are stdin-ambient. They pass because the suite is
normally invoked with stdin at /dev/null, not because the hook is insulated
from stdin - which is the whole reason the property needs a test that
supplies its own. Corrected at both comment sites and in the ledger.
Also corrects the hook's header, which said the operator's real settings.json
is shared with every non-remote sandbox: each sandbox gets a jq-derived
mktemp copy, not the file. The consequence the sentence exists to state is
unchanged, only the mechanism was wrong. And drops "real bytes in a real
pipe" from the stdin test, which contradicted the paragraph below it - the
payload sits in a regular file precisely because a pipe cannot support the
file-offset check the assertion depends on.
Remaining ledger fixes: two citations of a dangling pre-amend commit hash,
a line-number reference that pointed mid-comment, and a correction that was
accurate for the wrong revision.
Review of 1a172cf found three Important gaps, all reproduced before fixing.
The hook's "never reads stdin" property was untested, and it is the most
load-bearing one here: stdin is exactly where sandbox-controlled bytes
arrive (Claude Code passes a JSON payload carrying the Notification message
the session itself wrote). Mutating `: >` to `cat >` survived all 228 tests,
because bats' `run` hands the hook an EOF stdin. The new test feeds it from
a shared fd, so it asserts the stronger property: the bytes were never
consumed, not merely that they did not reach the marker. The comment that
claimed the assertion above it proved this is corrected.
Appending to .hooks.Notification/.hooks.Stop rather than replacing them was
untested too - the launch stub seeded settings.json with {}, so there was
never a pre-existing hook to lose. An operator running any Notification or
Stop hook of their own would have silently lost it in every remote-enabled
sandbox. The seed now carries both, and each half of the jq was mutated
independently to confirm coverage.
SANDBOX_NVIM_LOCKFILE, SANDBOX_DERIVED_DOCKERFILE and SANDBOX_DERIVED_CONTEXT
had the same inherited-value hole already fixed for SANDBOX_REMOTE_SETTINGS_TMP,
and it is reachable on the ordinary success path: with no nvim config and no
approved dependencies none of the three is ever assigned, so cleanup() deletes
whatever came in from the operator's environment - the last of them via rm -rf
of an entire tree. Pre-initialized in the same pre-trap block, covered by
canaries on the existing inherited-variable test.
Also rewrites the hook's header comment, which repeated the untruth about
install.sh that had already been rewritten out of bin/dejima's: install.sh
does not touch this hook at all.
Creating the session detached means it outlives every later failure, and
a session leaked that way never gets destroy-unattached set on it, so it
sits on the host forever - and every later attach to that run id joins
it rather than creating one, inheriting the missing option. Reachable
without trying: `restrict,pty` permits a pty rather than forcing one, so
`ssh -T` runs the forced command with no terminal, and a client whose
TERM the host has no usable entry for fails the same way.
An EXIT trap covers both, and the explicit kill on a pane mismatch. The
group name is baked into the trap body rather than read at exit, since
$group is local and the body runs after the function's locals are gone,
where set -u would abort the cleanup entirely. The initial attach is no
longer exec'd: after an exec there is no shell left to remove the
session it just made, and tmux failing to open the terminal happens
after it. Detaching normally returns 0 and disarms the trap.
Also set destroy-unattached when joining a group that already exists.
The option is per-session state, so a group that ever came into being
without it would otherwise keep outliving its client.
The session name taken from the live pane is not a stable handle. tmux
reports whichever session holding the window is most recent, which is
routinely another remote client's grouped throwaway - and those carry
destroy-unattached, so the name stops resolving the moment that client
detaches. `new-session -t` does not fail on a name that matches nothing:
it silently creates a fresh UNGROUPED session, which for a remote client
is a plain host shell with no sandbox in it, left running after they go.
Create the group detached, check it really contains the recorded pane,
and kill it and refuse otherwise; destroy-unattached still goes on the
attaching command, where it has to be. Replace the static "every target
is =-anchored" grep, which missed a continuation line and would have
false-positived on a pane-id target, with a behavioral test: a decoy
session whose name merely starts with the group name must not be the one
attached to.
Also corrects the TMUX_PANE test's comment to the real write order.
The grouped session's base came from the recorded label while the window
index came from the live pane - two sources of truth for one attach. A
rename was enough to point the base at an unrelated session that had since
taken the old name, with the client attached to a clone of that instead of
the sandbox. One display-message now yields both, so they cannot disagree,
and the "a name matching nothing makes tmux conjure a fresh ungrouped
session" hole is closed by construction: a pane that is alive always has a
session. That also drops the has-session guard the old shape needed, and
fixes session names containing ':' (base is now everything before the LAST
colon). entry_label is display-only from here - the --list/picker column is
its only remaining reader.
Tests for the three fixes that shipped unpinned last round (a stale name
conjuring its own session, the rename case, base-10 arithmetic), for the
anchoring that no behavioral test can observe now that names always resolve
exactly, and for the vanished-pane guard - the last via a tmux stub, since
display-message printing nothing and still exiting 0 is otherwise only
reachable by racing a dying pane.
container_for piped docker inspect into grep, so the pipeline reported only
grep's status and "couldn't ask about this container" still read as "not
this one" - one unexaminable container anywhere in docker ps pruned an
unrelated live session and rm -rf'd its notify dir. Capture inspect's output
and treat a failure as unverified, like the ps half already did.
The prune's rm -rf was fatal under set -e, and the marker directory it
removes is bind-mounted read-write into the sandbox: a session can chmod it
on the way out so the rm fails and the whole listing dies with it, leaving
the picker empty. Warn and carry on instead.
Also: force base 10 in the uptime arithmetic (started=09 is not octal);
anchor the grouped session's base target with "=" and require it to exist,
since tmux prefix-matches an unanchored target and silently creates a fresh
ungrouped session for one that matches nothing; drop set-option -w
window-size, which permanently overwrote the operator's own window option
(grouped sessions share windows) to re-assert what is already tmux's
default; and correct bin/dejima's comment about where $TMUX_PANE lands in
the entry.
The attach path never worked: set-option's -t is a target-pane, so
"=$group" doesn't resolve there and set -e aborted before attach-session
ran; setting destroy-unattached on a still-detached session instead makes
the server reap it. Both options now go in the same command sequence that
attaches, with window-size (a window option) after select-window.
--list could not tell "container is gone" from "couldn't ask docker" and
pruned every entry on a failed query, permanently unregistering live
sandboxes and rm -rf'ing notify dirs under running containers.
started= reached $(( )), where a non-numeric value is evaluated as a name
and its subscript executes - validated in read_entry now, and bin/dejima
newline-checks $TMUX_PANE, the env-supplied field that could inject one.
A bad entry no longer aborts the whole listing, and an empty window index
no longer silently selects the group's current window.
Tests cover the positive direction: a live pane listed as attachable and a
real attach against a private tmux server.
Now that the licensing pass rewrote the near-verbatim blocks, 'Based on'
overstates how much of the current code traces directly to Anthropic's
reference devcontainer.
Adds the canonical GPLv3 LICENSE text plus a README note. Also rewrites
the near-verbatim blocks in docker/Dockerfile and docker/init-firewall.sh
that were copied from Anthropic's proprietary reference devcontainer, so
none of that proprietary source remains under this repo's new license.
Behavior is unchanged; test/static.bats still passes 94/94.
The old name baked a single vendor into a tool that is otherwise
model-agnostic in every respect that matters: it sandboxes a process
against a bind-mounted project behind an egress firewall. Nothing about
the container, firewall, credential proxy, or PR workflow is
Claude-specific. The name was the only thing holding it to one agent.
Dejima was an artificial island in Nagasaki harbour, the only Japanese
territory open to Westerners for over two centuries, joined to the
mainland by one guarded bridge that nothing crossed in either direction
without permission. It fits better than the alternatives because the
containment runs both ways - the firewall and credential proxy keep the
world out, and the island keeps the agent in - and because the constraint
was generative rather than merely restrictive.
Mechanical surface: 7 bin/ scripts renamed, CLAUDE_SANDBOX* -> DEJIMA*
(including the bare in-container marker set by the Dockerfile), compose
project name, image tags, dejima-net/dejima-dns, ~/.config/dejima,
~/.local/share/dejima/shared, and the Loki conversation job label.
Deliberately unchanged: SANDBOX_* env vars carry no vendor name and
renaming them would break every user's shell profile for no gain -
"sandbox" stays the generic noun, "dejima" is the product name.
claude-wrapper.sh genuinely wraps the claude binary. The default entry
point is still claude; real model-agnosticism is future work, this only
removes the naming obstacle to it.
install.sh now removes dangling ~/.local/bin/claude-sandbox* symlinks -
it created them, so it retires them. It only deletes symlinks that are
actually broken, so an unrelated install that still resolves is left
alone. README documents the approval-store and share-dir moves, the
orphaned Docker volumes, and the changed Loki label.
static/project/audit/proxy: 204 passed, 0 failed. runtime.bats needs
Docker and could not run in the sandbox.
Documenting the removed env input, even to say it's ignored, is the only
thing keeping it visible: nobody outside this repo ever used it, so there
is no one to warn. Treat it as though it never existed.
The launcher still refuses to merge an exported ALLOWED_DOMAINS, and
test/project.bats still pins that - the behaviour is unchanged, it just
isn't advertised.
The derived-image test copied the base image's hardening assertions but
not the idiom the sudo one needs. `sudo -n true` exits 127, not a
permission error, because sudo isn't installed in the image at all, so
`run` + `[ "$status" -ne 0 ]` passes while emitting a BW01 warning
telling us to check the return code properly.
"node has no working sudo" already settled this 300 lines above: use
`run !`, which accepts any nonzero exit, rather than pinning the test to
one specific way sudo stays unavailable. The iptables assertion beside it
keeps the plain form - iptables is installed, so it fails with a
permission error and never trips BW01.
- ensure_global_store's mkdir -p had no discriminating test: setup()
pre-creates CONFIG_DIR, so [ -d ] was true either way. Start from a
removed config root, and add a CLI-level test for the first --global
approval on a host that has no config root yet.
- claude-sandbox-allow --global with no container running had no test at
all; the three existing --global tests either skip the session block or
report a running container.
- the launcher's abort on a corrupt allowed-domains in either store was
verified by hand twice and never tested.
remove --global against a config root that does not exist printed
"Removed <spec> from the global dependencies" and exited 0, while the
project path prints "No approved config for <path>." and exits 1. Match
the project path: a typo'd or premature remove should not be confirmed.
The exported-ALLOWED_DOMAINS test asserted only that evil.example was absent
from the container env, which a launcher that set no domains at all would
satisfy just as well. Seeding an approved global domain and asserting it
survives makes the test fail for both regressions rather than one.
ensure_project_store used to write projects/<slug>/path unconditionally.
Since it started attaching to whatever resolve_project_store finds, a store
that already exists without a path file is attached to silently and stays
that way - and path is the only thing resolve_project_store checks to catch
two projects whose absolute paths sanitize to the same slug, so such a store
would be permanently unguarded and could hand one project another's
approvals. Only reachable from a degraded state (an interrupted write, a
hand-restored directory), but it is a real strength reduction.
Write the file when the resolved store lacks one. The owner recorded is the
ancestor whose slug produced that directory, not the path being approved:
for a worktree those differ, and recording the worktree would make every
later read from the real owner fail as a collision. Healing also restores
the inheritance Note, which an empty owner used to suppress.
Also drop the teardown line for the runtime test removed in 44ee1fb, and
correct the hook's claim that it reports on exactly what `approve` acts on -
within the shared cap it is a strict subset, since a dotless domain and one
longer than MAX_ENTRY_LEN are approvable host-side but never reported. Both
skips are fail-closed; the comment was the only thing wrong.
It named itself after the approval property but never touched the approval
store or bin/claude-sandbox - it set ALLOWED_DOMAINS directly on a
container it started itself, so "approved" and "declared" were just two
domains, one in the env var and one not. What it actually asserted is
already asserted, on the shared container, by "a non-allowlisted domain is
refused by the DNS proxy" and "an allowlisted domain resolves via the DNS
proxy"; the cost was a second container boot per run.
The real property - an approved domain reaches ALLOWED_DOMAINS and a merely
declared one never does - is now covered against the actual launcher in
test/project.bats.
The dnsmasq servers file is an absolute path only the container has, so
the hook's BRE escaping of a declared domain can't be driven from a
host-side test - anchor the assertion to the matching line itself instead
of grepping the whole file, so the same expression elsewhere can't satisfy
it. Without the escaping a project declaring "aexample.com" matches the
server= line of an approved "a.example.com" and the hook reports an
unapproved domain as already allowed.
The approval-store mount check is design section 8's invariant, and had no
test at all: the store is the only thing that grants anything, so it must
appear in no compose volume and no dynamic -v either.
Everything covering bin/claude-sandbox was a grep over its source, which a
deleted line satisfies as readily as a working one - the merge of approved
project domains into ALLOWED_DOMAINS, the switch to the derived image, and
the store-inside-the-workspace warning all had no behavioral test at all.
Run the launcher for real against a stub `docker` that records the
environment of the `compose run` it would have made, in a throwaway $HOME
with the credential proxy off, and assert on what the container would have
been handed. Verified by mutation: dropping the ALLOWED_DOMAINS merge, or
the SANDBOX_IMAGE="$derived" assignment, or the warning block each fails
exactly one of the three new tests.
--project persistence sat after both the live-session update and the
reachability probe, so it never ran on the documented path:
- Approving dependencies requires leaving the sandbox and relaunching, so the
normal moment to approve is when nothing is running - and with no running
container claude-sandbox-allow exited 1 before persisting anything. Under
claude-sandbox-project approve's set -e that killed the whole loop after the
dependencies, silently skipping every domain.
- A declared domain that legitimately doesn't serve HTTPS on 443 (an internal
service, an SSH-only host, an apex that only serves a subdomain) exited 2 at
the probe, with the same effect from the first such entry onward.
Recording an approval is the user's decision and takes effect at the next
launch; patching a running session and verifying reachability are claims about
*this* run. Persist first, then treat both as best-effort and say when they
didn't happen. With --project and no container there is nothing left to do and
nothing wrong, so that exits 0.
--global semantics are unchanged and now stated as the deliberate asymmetry
they are: it stays gated on the probe, so one run cannot add a demonstrably
broken domain to every project's allowlist, and it still needs a container.
claude-sandbox-project approve now collects domains that could not be applied
to a running session and reports them at the end instead of aborting at the
first one. README prose and the probe comment updated to match.
Every reader (bin/claude-sandbox, claude-sandbox-project, dep list) resolves
a store by walking ancestors, so a git worktree under the project inherits
the project's approvals - which is the workflow README promises and
sandbox-context.sh tells agents to use. Every writer created a store at the
exact path instead. The first approval made from inside a worktree therefore
created a store that shadowed the project's for every later read, and the
worktree silently lost every approval it had been inheriting: the derived
image lost its packages, the firewall lost its domains, and nothing said so.
ensure_project_store now returns the resolved store when one exists and only
creates a new one when nothing above covers the path. Recording into the
ancestor widens that ancestor's scope, which is the lesser surprise but still
a surprise, so it prints where the approval landed and what it now covers.
Slug-collision detection is unchanged - resolve_project_store checks the path
file at every level and fails, which now short-circuits the writer too.
.claude-sandbox/{dependencies,allowed-domains} live on the read-write
workspace mount, so their contents are attacker-controlled text on its way
into the next session's context window. Nothing bounded that:
- 700 long declared domains made the assembled context exceed Linux's
128 KiB MAX_ARG_STRLEN on the `jq --arg` call, so jq failed and the hook
emitted *nothing* - every later session in the project silently lost the
sandbox notice, the worktree rule and the never-push-to-main rule, along
with the report that would have named the junk.
- 5000 declared domains stayed under that limit but injected 72 KB of
attacker-chosen tokens verbatim.
- 400 declared npm: entries forked `npm ls -g` 400 times, over two minutes,
past Claude Code's hook timeout - which drops the context the same way.
Three bounds, plus one structural change:
- MAX_DECLARED=200 entries considered, the same cap read_declared() applies
host-side, so the hook reports on exactly the window
`claude-sandbox-project approve` will act on. Reading stops there.
- MAX_LISTED=20 entries named in the report; the rest are counted only.
- one cached listing per ecosystem instead of one probe per entry, so probe
cost is O(ecosystems) rather than O(entries). Matching is now grep -qxF
against that list, which also removes the per-name BRE escaping the cargo
probe needed.
- the context is fed to jq on stdin (-R -s) rather than as an argv element,
removing the MAX_ARG_STRLEN failure mode rather than staying just inside it.
Also require at least one dot in a declared domain. valid_domain()'s label
group is optional, so a bare word like IGNORE-PREVIOUS-INSTRUCTIONS validated
and was echoed verbatim under a heading that reads as the project speaking.
jq escapes it correctly, so this was never a JSON break - it was the
prompt-injection surface the missing cap made worth using.
- valid_dep_spec now requires the package name to start with an
alphanumeric, so a leading-dash spec (e.g. apt:--allow-unauthenticated)
can no longer validate as a name and land in a generated RUN line as an
option.
- static.bats gains a test pinning the explicit base-image build to the
base tag on that exact line, so nothing can silently drop the build step
or its SANDBOX_IMAGE pin while the suite stays green.
- project.bats' corrupt-deps test now uses a valid line followed by a
corrupt one and asserts zero stdout bytes, so it actually catches a
partial-Dockerfile emission rather than just a nonzero exit status.
- runtime.bats' derived-image test now asserts the built image's own
Config.Entrypoint via docker inspect, since the test's own
--entrypoint override would otherwise mask a missing ENTRYPOINT line.
- Quote $rejected through sed instead of an unquoted printf, so a rejected
entry's literal text survives word-splitting and globbing when reported.
- Surface the 200-entry truncation warning in approve (status already did),
so a declaration over the cap doesn't look fully reviewed.
- Treat EOF on the approve confirmation prompt as a decline instead of
letting set -e kill the script silently.
Adds 3 regression tests to test/project.bats for the above.
Two sources of noise on the host terminal, which this proxy shares with the
session it serves.
A container-side client that goes away mid-response - the session
interrupting a streaming reply, exiting with requests in flight, or dropping
a request it no longer wants - leaves the proxy writing into a closed
socket. The forwarder makes this immediate: it destroys the unix-socket
connection the moment its TCP client errors. socketserver's default
handle_error dumps a fifteen-line traceback for that, so a handful of
interrupts scrolls the user's UI away. It is an expected condition with
nothing left to deliver, so it is now silent - and only it: any other
exception still gets its full traceback.
Separately, HEAD and OPTIONS had no do_* handler, so BaseHTTPRequestHandler
answered 501 and logged one line per request. The container reads that as
the API rejecting the method, when in fact the proxy never routed it. The
accepted method set should be the upstream API's, not an artifact of which
handlers were defined here.
Reproduced both before fixing: one interrupted SSE stream plus an OPTIONS
and a HEAD produced 1140 bytes of stderr; the same sequence now produces 0.
Passing the token as `-H "Authorization: token $GITEA_TOKEN"` puts it in
curl's argv, and /proc makes argv world-readable: for the life of every
request, any other process could read the credential straight out of `ps`.
That matters more here than in most repos, since this tool ships inside a
sandbox image whose whole purpose is running agents that read process
listings and are not fully trusted with host credentials.
curl reads the header from a config on stdin instead. printf is a bash
builtin, so the token is never an argument to anything.
Verified by polling `ps` during a real `gitea-pr comments` run: four
sightings before, zero after.
Long sessions were dying with "Cannot fork" once the pids cgroup filled
up: 19027 of the 19109 available PIDs were unreapable <defunct> zombies.
PID 1 in the container is the session's own command - claude, or nvim
under SANDBOX_NVIM_CONFIG - and neither reaps orphans. Any child a
process leaves behind therefore survives as a zombie for the container's
whole life rather than being cleaned up by an init. The scripts do leak
some: `claude-sandbox-audit compare` leaves ten unreaped
process-substitution children per invocation and claude-sandbox-project
about two, so one full test cycle costs ~370 PIDs and roughly fifty
cycles exhausts the limit.
Fixing the leaks one at a time would not close the class - it is normal
for a shell to leave process-substitution children for init to reap, and
any tool the sandbox runs can do the same. Giving the container a real
init fixes all of them, present and future, which is what init: true is
for.
Every container start fetches https://api.github.com/meta to build the
github-domains ipset, unauthenticated. That draws on GitHub's limit of 60
requests per hour per source IP - shared across every sandbox launch and
anything else on the host - so a session that starts containers in a loop
exhausts it, and from then on every launch dies during firewall setup.
GH_TOKEN is already in the container's environment at that point
(entrypoint.sh uses it a few lines later to set up the git credential
helper); it was simply never sent here. Authenticated the limit is 5000/hr,
which this cannot realistically reach.
The failure was also misreported. A rate-limited response is a JSON body
with a `message` and no ranges - non-empty, so it passes the emptiness
check and lands on the field check, which announced "GitHub API response
missing required fields". That points at GitHub returning malformed data
rather than at a quota that resets on its own in under an hour. Surface
the API's own message when there is one, and keep the malformed-response
wording for responses that genuinely lack both.
Ten of the eleven containers runtime.bats starts were launched on Docker's
default bridge, which has no embedded resolver at 127.0.0.11. dns-proxy.sh
forwards every allowlisted name there, so dnsmasq had nothing to forward to,
the readiness probe burned its ~24s budget, and set -e killed the container.
That didn't show up as a clean failure, because the shared readiness check
(`pgrep -x sleep`) also matches dns-proxy.sh's own `sleep 0.2` retry. Tests
broke out of the wait ~9s in and asserted against a container that was still
mid-startup, with `-P OUTPUT ACCEPT` and no ipsets created yet - measured
directly. "ALLOW_GITHUB_PUSH=1 opens port 22 to GitHub" was therefore passing
because *everything* was still reachable, not because the rule worked; the
container then died 15s later. Only "credential proxy chain", which had a
stricter secondary wait on the forwarder's port, failed honestly - which is
why this looked like one isolated pre-existing bug rather than a suite-wide
race.
Fixes:
- Attach every ad-hoc `docker run` to the suite's user-defined network, the
same one setup_file already uses and the same shape compose gives the real
sandbox.
- Replace ten copies of the readiness loop with a single wait_ready() that
matches the *final* `sleep infinity` exactly (pgrep -x -f), so the whole
startup sequence - DNS proxy, firewall, ipsets, logging, forwarder bind -
is guaranteed complete before any assertion runs. It dumps docker logs and
fails loudly if readiness never arrives.
- Drop the credential-proxy test's secondary port-18300 wait: entrypoint.sh
waits for that bind itself before reaching the sleep wait_ready matches on.
dns-proxy.sh now refuses to start when 127.0.0.11 isn't the container's
nameserver, naming the actual cause instead of timing out 24s later with a
generic dnsmasq message. Deliberately not a fallback to whatever resolver
resolv.conf lists: init-firewall.sh has no egress rule for UDP/53 to an
arbitrary destination on purpose (DNS is meant to stay on loopback), so a
fallback would either fail anyway or open an unrestricted-destination DNS
channel.
Two static guards keep this from returning: every `docker run` in
runtime.bats must carry --network, and the ambiguous `pgrep -x sleep`
readiness pattern is banned. Both were verified to fail when their invariant
is broken.
Tested: static 67/67, audit 25/25, runtime 30/30 (1 skip, RUN_LIVE_API).
"credential proxy chain" now passes - it was never a separate bug.
test/runtime.bats's live-container run confirmed the symlink case:
replacement succeeds despite the read-only mount, because runc resolves
the bind-mount destination through the symlink rather than binding to
the symlink dentry itself. Update the comment, the test, and the README
to state this plainly instead of deferring to "the open question" -
regular file is prevented, symlink is detected (not prevented) via
bin/claude-sandbox-audit.
Also fixes the plain-regular-file runtime test, which was vacuous:
`rm -f X && echo pwned > X` fails at the rm (ordinary mount-busy
semantics, independent of :ro) before the write it meant to test ever
runs. Replaced with direct in-place overwrite attempts (echo redirect,
truncate) that actually depend on the read-only flag.
The read-only mount bin/claude-sandbox puts onto statusline.sh does not
protect it when it's a symlink (confirmed live-container gap - runc
resolves the bind-mount destination through the symlink, leaving the
symlink dentry itself on the writable ~/.claude mount). There's no
mount-based fix for that case, so add detection instead: a new
STATUSLINE_PATH-driven snapshot/compare section hashing statusline.sh
(hash_entry already hashes a symlink's target string, so a repointed
or replaced one is caught), reported as a HOST-CODE-EXECUTION finding
on session exit.
Fixes the tcb section's end-of-file sed extraction, which would have
swallowed the new trailing section otherwise.
Four gaps left by earlier tasks on this branch:
- Variables table was missing SANDBOX_LOG_CONVERSATION (previously
documented only in its own "Conversation logging" section).
- Security notes enumerated the read-only ~/.claude overlays but not the
newer statusline.sh self-mount - added, including the same reliably-
regular-file/unverified-symlink caveat now in bin/claude-sandbox's own
comment on that mount.
- The log-shipper.sh Files-table entry still described only dnsmasq/ulogd2
log shipping, not the conversation-transcript shipping added since.
- The launcher description said it discovers mounts "for ~/.claude/hooks
symlinks" - the scan now covers five overlay dirs plus top-level
~/.claude entries like statusline.sh; reworded to match.
entrypoint.sh starts log-shipper.sh as root, right after a comment
justifying running ulogd2 unprivileged specifically because it parses
attacker-influenceable packet data - log-shipper.sh now also tails session
transcripts the sandboxed session itself writes, the same class of
untrusted input, with no comment explaining why it's still root. Add one:
the tradeoff is deliberate, not an oversight - root buys tamper-resistance
for what is itself a log of that same session, which an unprivileged
shipper owned by the thing being surveilled couldn't offer. Privilege
model unchanged.
bin/claude-sandbox-allow's final reachability check used
--connect-timeout 5 with no cap on the overall request, so a server that
completes the TCP connect and then stalls (never sends bytes) hangs the
script indefinitely. Add --max-time 10; curl maps that timeout to the same
exit 28 the existing case statement already treats as "unreachable," so no
new branch is needed - confirmed by reading the existing case block before
relying on it. Updated the comment above to describe both timeouts
accurately.
The ~/.claude symlink-scan loop called dirname on readlink -f's output
without checking it actually succeeded - a symlink whose target's parent
directory (not just the target itself) is missing makes readlink -f fail
with empty output, which dirname silently turns into ".", appending a
bogus/dangerous -v .:.:ro mount. Skip the entry instead when readlink -f
fails or yields nothing.
Also add a live-container test (test/runtime.bats) that builds the exact
mount shape bin/claude-sandbox produces for the statusline.sh read-only
self-mount and actually attempts, as the sandbox user, to replace it - for
both a plain regular file and a symlink into a separately-mounted
directory. The regular-file case is expected to reliably hold (a bind
mount point can't be unlinked from inside the container); the symlink case
is the open question a code review raised - whether bind-mount destination
resolution walks through the parent ~/.claude mount's already-present
symlink at that path before this mount is set up, in which case the :ro
mount would land on the (already-protected) resolved target instead of the
symlink dentry itself, leaving that dentry replaceable. Update the
launcher's own comment on the statusline.sh mount to state this as intent
with the caveat named, pointing at the new test as the source of truth,
rather than asserting the protection flatly.
Docker can't bind-mount a dangling symlink - it aborts container
creation with an opaque "mkdir ...: file exists" (the symlink already
occupies that path), unconnected to the statusline for the user. Detect
that case ourselves and exit with a clear, actionable message instead
of letting Docker's error surface three layers down, matching the
SANDBOX_EXTRA_MOUNTS precedent in this same file. Silently skipping the
mount (plain -e) is not an option either - that reopens the read-write
gap this mount exists to close.
Plain -e follows symlinks and returns false for a dangling one (e.g. a
dotfiles checkout that isn't present), so the read-only mount added in
the previous commit was silently skipped in that case, leaving the
symlink entry itself unprotected on the read-write ~/.claude mount.
Add an -L arm so the mount also applies for a dangling symlink.
statusline.sh is a top-level file directly on the read-write ~/.claude
mount, unlike settings.json/CLAUDE.md/plugins/hooks/skills/commands/
agents/output-styles, which all have a read-only compose overlay.
settings.json's statusLine.command executes it on the host, so a
compromised session could unlink it and drop its own script in its
place. The existing symlink-target scan only protects the case where
statusline.sh points elsewhere; it never covers the symlink/file itself.
Mount it read-only, conditional on -e so hosts without a statusline
aren't affected and Docker never auto-creates a root-owned directory.
Day-to-day configuration once dejima is installed and running: what a
project declares versus what you approve, config parity with your host agent
harness setup, a different editor, writable host directories, shared
scratch space, frontend testing, 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.
| 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 |
| `[core] forge` | `none` | forge integration: `gitea` (`gitea-pr`/`gitea-issue`), `github` (`gh`, 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` | `claude` | container's main process (e.g. `nvim`), also settable per launch with `--cmd` |
| `[core] firewall` | `on` | egress firewall; `off` disables it (debugging only), also settable per launch with `--no-firewall` |
| `[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](docs/security.md)) |
| `[logging] loki_url` | unset | ship DNS/firewall logs to a Loki endpoint |
| `[epitaph] enabled` | `on` | keep the summary Claude Code writes when a session compacts |
| `[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 |
| `[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] strict` | `off` | refuse a headless (non-tty) launch over unacknowledged CRITICAL findings |
| `[remote-access] enabled` | `off` | the switch: nothing in "Remote access" below runs until this is `on` |
| `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/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/dejima-notify.sh` | `Notification`/`Stop` hook: drops an empty `waiting`/`done` marker for a remote-enabled session - 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 |
| `bin/dejima-dep` | Review and approve project dependencies declared in `.dejima/dependencies` |
| `bin/dejima-provider-lib.sh` | Parses forge/workflow/harness manifests and composes the session briefing; not on PATH: see [docs/providers.md](providers.md) |
| `forges/`, `workflows/`, `harnesses/` | Provider directories for the three axes selected by `[core] forge`/`workflow`/`harness` (data, never sourced or executed); see [docs/providers.md](providers.md) |
| `forges/gitea/bin/` | The gitea forge's own CLIs, baked into the image so the sandbox can act on them: `gitea-pr` (create a PR, read its comments, merge it), `gitea-issue` (list, view, create, comment on, close and reopen issues) and `gitea-actions` (read CI runs and job logs) |
| `bin/dejima-notify-watch` | Host-side watcher: turns markers into ntfy pushes composed entirely from host data |
| `bin/dejima-project` | Review and approve project domains and dependencies from the host |
| `bin/dejima-project-lib.sh` | Shared library sourced by `bin/dejima-dep`, `bin/dejima-project`, `bin/dejima` and the remote-access scripts; not on PATH |
| `bin/dejima-conversation-ship` | Host-side: ships session transcripts to Loki when `[logging] conversation` is on. Reads them where they already are, so the session is not in the path of its own oversight log |
| `bin/dejima-epitaph` | Host-side: keeps the summary a session leaves when its context is compacted away, in a store nothing mounts. Run from `bin/dejima`'s exit trap |
| `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-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 |
| `docs/providers.md` | The manifest contract for the forge/workflow/harness axes, key by key, with the shipped manifests as worked examples |
| `docs/ci.md` | What push access grants a session on your CI host, and how to bound it - runner hardening, and an egress allowlist read from `main` |
| `docs/nginx-dejima.conf.example` | Reverse-proxy template for the web front door; deliberately not loadable as written |
`bin/dejima` is the entry point: it resolves the project path,
checks for a live `ssh-agent`, reads `GITEA_TOKEN`/`GH_TOKEN` from the token
files named in the config, discovers which host directories need mounting for symlinks under
`~/.claude` (`hooks`, `skills`, `commands`, `agents`, `output-styles`, and
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`.
## Tests
`test/` has a [bats](https://github.com/bats-core/bats-core) suite covering
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.
`project.bats`, `remote.bats` and `audit.bats` need no Docker either.
`project.bats` covers the
declaration/approval split - what `.dejima/` may ask for, what only the host
can grant, and that the approval store is never mounted. `remote.bats`
covers everything in [docs/remote-access.md](remote-access.md):
the `[remote-access]` config section, the session registry, and the properties the
feature's security rests on - that the
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.
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.
Every mutation in `runtime.bats` (firewall rules, `ipset` 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
likely to collide with a real container you have running.
`ci-runner.bats` is different in kind from the others: it doesn't test
this repository at all, it asserts on the CI runner executing it (the Docker
socket withheld from the confined job, granted to the privileged job, and
the egress each is allowed) - see [docs/ci.md](ci.md). It skips outright
unless `DEJIMA_CI_TIER` is set, so it has nothing to say when run locally.
### Branches
Pull requests target `development`, which merges to `main` periodically, so
`main` stays releasable while work lands quickly. `gitea-pr` defaults its
base to the repository's default branch, so pass `--base development`.
CI needs no change for this: `.gitea/workflows/tests.yml` triggers on
`pull_request_target` with no branch filter, so it already runs whatever the
base is.
Nothing *enforces* the target. `git-push-guard.sh` blocks direct pushes to
the branches a workflow names, and that list is workflow-wide rather than
per-project - adding `development` would impose it on every dejima user,
most of whom have no such branch. So this is a convention with a check
attached: read what `gitea-pr` printed.
### Running the tests
```sh
bats test/ # everything
bats test/audit.bats # one suite - the suites are independent, see .dejima.md
SKIP_BUILD=1 bats test/runtime.bats # the one suite needing Docker; reuse the current image
```
`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.
`.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
by shebang rather than enumerated, so a script added under `bin/`, `hooks/`,
`docker/` or a forge's `bin/` is covered the day it lands. The severity floor
and the two disabled checks are in `.shellcheckrc`, each with the reason
beside it.
`.gitea/workflows/tests.yml` runs all of it on every pull request and on every
push to one, `runtime.bats` included. That job needs two things from the
runner that an ordinary one doesn't (the host's Docker socket and the host
network namespace), and it checks for both up front rather than failing
obscurely partway through; the file says why each is needed.
## Adding a new harness
`[core] harness` picks a directory under `harnesses/`, and a harness manifest
declares four things. Everything else dejima does for Claude Code is written
against Claude Code, and this is the inventory of it: what a second harness
has to supply, and where dejima has to stop assuming. Taken from a pass over
the tree, so each row names the file to change rather than describing a
sketch.
### 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.
These are the requirements. A harness that fails one of the first five
cannot be supported, and that is a decision about the harness rather than a
gap in dejima.
| | Requirement | Why it is load-bearing |
| --- | --- | --- |
| **R1** | **Unattended execution.** A documented mode that runs tool calls without confirming each one. | The premise the project rests on. A harness that stops to ask is one an operator sits in front of, and the argument for accepting that mode is that the container makes being unattended survivable. |
| **R2** | **Tool-call interception.** A supported mechanism to inspect a command before it runs and refuse it, without patching the harness. | The push guard, the merge guard and the dangerous-command denylist all live here. They are guards against an eager agent rather than a boundary against a hostile one, but a harness without them ships three protections short and the operator cannot add them back. |
| **R3** | **Host-controlled context at session start.** A way to place text into the session's context at start, from a file the operator controls and the session cannot rewrite for the run in progress. | How the briefing and the project's `.dejima.md` arrive. It is also how the agent learns it is sandboxed, which is what makes it ask for a domain instead of working around the firewall. |
| **R4** | **State dejima can locate or discard.** Per-project state must be computable on the host before launch, so it can be scoped per project - or safely discardable, so it can stay container-local. | #104. A store that is neither computable nor discardable cannot be scoped, and mounting it whole reintroduces cross-project context poisoning. |
| **R5** | **A withholdable credential.** The model credential must live somewhere dejima can decline to mount, and the harness must accept a substitute: a staged fake, or an environment variable pointed at the host-side proxy. | The credential never entering the container is a headline property, not a nicety. |
Three more are required, but may be declared absent in the manifest, in
which case dejima says at launch what is not in force:
| | Requirement | What is lost without it |
| --- | --- | --- |
| **D1** | Session lifecycle signals - something that fires when the agent wants attention and when it stops. | Remote access notifications. The session still runs and is still attachable. |
| **D2** | A parseable record of the session. | Epitaph, and conversation logging. |
| **D3** | An enumerable config directory whose extension points can be mounted read-only. | Config parity, and the read-only mounts that stop a session installing a persistent hook for the next one. |
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
guard set rather than shipped with three protections quietly missing.
**The guard rails are declared in the manifest and reported at launch.**
`guards` lists which of `bash-deny`, `push-guard`, `merge-guard` and
`notify` the harness can host. A name outside that set is refused, because a
typo would otherwise turn a guard off in silence. The ones a harness does
not declare are named once at launch, with what each costs - and nothing is
printed at all when it hosts them all, so the message stays a signal rather
than a line to scroll past.
Core gates the work on the declaration rather than on whether a plugin
happens to define the hook: a harness that forgot to declare a guard but
implemented it would otherwise get a protection the launch message has just
told the operator it does not have.
### Worked example: why aider is not supported
aider clears R1 (`--yes-always`), R3 (`--read` places a file in context),
R4 (its state is `.aider.*` inside the workspace, which is already mounted)
and R5 (it takes an API key from the environment).
It fails **R2**. aider has no hook or event system; the only occurrence of
"hook" in its options is `--no-verify`, which controls git's own pre-commit
hooks. There is no supported point at which a command can be inspected and
refused, so `git-push-guard.sh`, `merge-guard.sh` and
`deny-dangerous-bash.sh` have nowhere to live and no equivalent to be
rebuilt from. It also fails D1.
The container would still bound an aider session. But three guards an
operator gets everywhere else would be missing with no way to restore them,
and "supported" would mean something different for that one harness than
for every other. So it is not supported, and this is the reason.
### Where a harness's support lives: three tiers
| Tier | What | Rule |
| --- | --- | --- |
| **Core** | the mechanism, and every enforcement point | harness-agnostic, always |
| **Manifest** | `harnesses/<name>/harness.conf` | data. Never sourced, never executed. Selects among named core mechanisms and supplies paths |
| **Plugin** | `bin/harness/<name>.sh` | the harness-specific remainder, as code, in this repository |
**A feature belongs in the manifest if it can be stated as a value.** It only
moves to a plugin when it cannot. `projects_dir` and `file_history_dir` are
both paths, so both are manifest keys; the rule that a *name* in the first
selects an *entry* in the second is Claude Code's own and could not be
written as a value, so that is the plugin. "Add a manifest key" is the
answer until it demonstrably is not.
**Plugins live under `bin/`, not beside the manifest.** `dejima-audit`'s
`tcb_snapshot` covers `install.sh`, `bin/`, `hooks/`, `docker/` and `test/`,
and not the provider directories. Host-side plugin code under `harnesses/`
would be code that runs on the host and that a session could edit in
dejima's own checkout without raising a CRITICAL - the exact class the audit
exists to catch. Under `bin/` it is covered for free, and "manifests are
data, never executed" stays true word for word.
**Plugins are sourced, not invoked**, and are part of the trusted computing
base. The risk worth designing against is not a careless plugin but three
plugins implementing the security model three ways, so a plugin uses core's
own primitives rather than re-deriving them. An invoked protocol would also
put a new text parser on the launch path, and every parsing bug this
repository has had is in that family.
**The manifest may carry values that are only used as paths or compared as
data. It must not carry text that becomes part of a parser's grammar, or the
identity of a security surface.** `dejima-audit`'s `ack` recovers a finding's
id by stripping a fixed label from the front of the finding text, anchored
deliberately (#45). Those labels are grammar. Provider directories are not in
`tcb_snapshot`'s list, so in the self-mount case a session can edit a
manifest without raising a CRITICAL - which is why host-execution surfaces
are a plugin hook (`harness_host_exec_surfaces`) rather than a manifest key.
The label stays a literal in code the audit watches; it just lives with the
harness that owns it.
**A plugin computes; core enforces.** A plugin declares through `emit_mount`
and `emit_env`, which validate at the point of declaration - `emit_mount`
runs the guarded-store refusal there, so a harness state directory that
overlapped the approval, audit or epitaph store is refused without each
plugin having to remember. A plugin never touches `extra_mounts` itself, and
a static test holds that. A plugin may *classify* an audit path; it may never
suppress a finding.
Every shipped harness has both a manifest and a plugin - a static test
refuses one without the other, because a manifest alone would present as a
supported harness while none of the work had been done.
### What the manifest already covers
`harnesses/<name>/harness.conf`, and the full contract is in
[providers.md](providers.md):
| Key | What it decides |
| --- | --- |
| `command` | the container's main process |
| `keys` | this harness's own `[harness.<name>]` config schema |
| `resume_command` | what the briefing tells a session to hand the operator |
| `briefing` | this harness's fragment of the session briefing |
names. Use it; do not spell a `[harness.<name>]` section out at a call site.
`keys` is declared per harness, so a literal section name makes it a
declaration only one harness can ever be read through - a second harness
gets selected, gets validated, and is then handed the first one's paths with
nothing erroring anywhere. Three call sites did exactly that, in
`bin/dejima-audit` (twice) and `bin/dejima-epitaph`. A static test now holds
the rule.
It returns empty when no harness is selected. `bin/dejima` refuses to start
in that state, so that is reachable only from the standalone tools, where a
harness-specific path resolving to nothing is the honest answer.
### The inventory
| Area | What is Claude Code specific | Where |
| --- | --- | --- |
| Install | `npm install -g @anthropic-ai/claude-code`, and a pinned `CLAUDE_CODE_VERSION` build arg | `docker/Dockerfile` |
| Permission bypass | the flag itself, now `no_confirm_args` in the manifest; the wrapper is generic | `harnesses/<name>/harness.conf` |
| Settings layering | that overrides arrive as an extra layer rather than a mount over the operator's file (#47) is core, as is the throwaway file itself; what the layer SAYS is the plugin's, written by `harness_prepare_config` - a settings schema is a parser's grammar, so a manifest cannot carry it. The flag that carries them is `settings_args` in the manifest | `bin/harness/claude-code.sh`, `harnesses/<name>/harness.conf` |
| Config preconditions | which config files must exist before staging, and which unusable one refuses the launch (Claude Code ignores an unparseable `settings.json` wholesale, with only a warning). Declared and enforced by the plugin, in the same hook | `bin/harness/claude-code.sh` |
| Egress allowlist | `platform.claude.com` always, `api.anthropic.com` only when no credential proxy is in front of it. Now `api_domains` and `proxied_api_domain` in the manifest | `harnesses/<name>/harness.conf` |
| Credentials | the whole proxy exists because Claude Code authenticates from `~/.claude/.credentials.json` rather than an env var; the entrypoint seeds a well-formed fake so it has something to read | `bin/dejima-anthropic-proxy`, `docker/anthropic-proxy-forwarder.js`, `docker/entrypoint.sh` |
| 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 | the briefing and the project's `.dejima.md` reach the session *through* the `SessionStart` hook. No hooks, no briefing | `hooks/sandbox-context.sh` |
| Notifications | `Notification`/`Stop` hooks, registered per session into the settings layer, are what drop the markers remote access watches | `hooks/dejima-notify.sh`, `bin/dejima` |
| Config parity | which directories carry over and at what mode. Declared by the plugin, since `docker-compose.yml` is static YAML and could only name one harness's | `bin/harness/claude-code.sh` |
| Staged config | which files are copied rather than mounted. Declared by the plugin; the staging tree mirrors the destination, so `entrypoint.sh` names no harness's paths | `bin/harness/claude-code.sh` |
| App state | `~/.claude.json` is copied rather than mounted, because it mixes live state with `mcpServers` config | `bin/dejima` |
| State layout | the project key - cwd with every non-alphanumeric replaced by `-` - is Claude Code's scheme, and dejima reproduces it in four places | `bin/dejima-slug-lib.sh`, `bin/dejima-project-lib.sh`, `docker/log-shipper.sh` |
| Per-project scoping | `projects/` is mounted one slug at a time and `file-history/` one session at a time, the second resolved by pairing `projects/<slug>/<id>.jsonl` with `file-history/<id>` (#104). In the plugin | `bin/harness/claude-code.sh` |
| Transcript format | the transform from transcript lines to log lines. In the plugin; the shipping, batching and Loki labels stay core's | `bin/harness/claude-code.sh` |
| Compaction format | what a compaction looks like in a transcript. In the plugin; the store, its modes and the provenance note stay core's | `bin/harness/claude-code.sh` |
| Audit surfaces | `.claude/settings.json` is CRITICAL and the rest of `.claude/` is a WARNING, because that is where this harness keeps things that run | `bin/dejima-audit` |
| Host code execution | `statusline.sh` is audited as its own CRITICAL surface because `settings.json`'s `statusLine.command` runs it **unsandboxed on the host**. Declared by the plugin, since a finding's label is a parser's grammar rather than data | `bin/harness/claude-code.sh` |
### Two things worth deciding early
**What runs on the host when the harness is configured.** `statusLine.command`
is the one that exists today, and it is not a quirk: a harness that will
execute an operator-configured command outside the container hands a session
that writes that file a way onto the host. Find that surface in the new
harness before supporting it, because it belongs in the audit from the first
release rather than after someone finds it.
**What the harness keeps, and who else can read it.** The per-project
scoping of `projects/` and `file-history/` exists because a session that can
write another project's memory can plant text a later session there reads as
established background (#104). A new harness's state directory needs the same
question asked of it, one directory at a time. Anything mounted host-wide
should be there because it is keyed by session rather than by project, and
because nothing loads it into a session's context at startup - which is the
reasoning that leaves `tasks`, `plans`, `sessions` and `backups` mounted
whole today.
### Testing a new harness
`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.
See [Shipped providers](#shipped-providers) below for what each one is for.
**A provider is data.** Nothing in dejima sources, `eval`s or executes a
manifest or a briefing fragment - a manifest is parsed with exactly the
discipline the config file itself gets (unknown key is fatal, every value is
type-checked, a line count cap), and a briefing fragment is prose that gets
substituted into and handed to the agent, never interpreted as code. The one
thing a provider ships that *does* run is a forge's `bin/` - copied into the
Docker image at build time (`docker/Dockerfile`: `COPY forges/`, then
`docker/install-forge-bins.sh` installs exactly what the manifests declare)
as an ordinary program the agent invokes
on `PATH`, the same as `git` or `curl`. That program never feeds back into a
decision dejima itself makes; it's just a CLI the image happens to carry.
This matters because a provider directory is ordinary repo content, and the
repo itself can be the mounted workspace (dejima sandboxing its own
checkout). Parsing rather than sourcing a manifest is what keeps "a session
edited a manifest" from meaning "a session ran code on the host at the next
launch."
**The point of the whole arrangement: adding a provider never means editing
core.** A forge or harness declares its own `[forge.<name>]`/
`[harness.<name>]` config schema *in its own manifest* (the `keys` field,
below) - core holds no forge-specific or harness-specific schema at all.
Adding a forge is adding a directory whose manifest describes it; nothing
elsewhere needs to change.
## The manifest file
Each provider directory has exactly one manifest file, named for its axis:
`forge.conf`, `workflow.conf`, `harness.conf`. Format is the same
`key = value` shape as the main config file:
- One `key = value` pair per line; surrounding whitespace is trimmed.
- Blank lines and lines starting with `#` or `;` are comments.
- Any other line without an `=` is a malformed-line error.
- A key not in that axis's schema (below) is an **unknown key** error, not a
silent pass-through.
- A value that fails its key's type check is a **hard** error, not a warning.
- Capped at 200 lines; a longer file is refused outright.
A missing key is not an error - it resolves to that key's schema default
(empty string for a key with none), the same way an absent key in the main
config file does.
## Types
Every key's type is one of the main config file's own types (`bool`,
`enum:a|b|...`, `path`, `hostname`, `url`, `identifier`, `int:min:max`), plus
a handful that only a manifest uses, because they land somewhere a config
value never does:
| Type | Meaning |
| --- | --- |
| `command` | Space-separated words (a bare name or a multi-word one like `gh pr`) - lands in prose an agent runs, so nothing shell-active is legal. |
| `relpath` | A path relative to the provider's own directory, no leading `/` and no `..` - a manifest may only name a file it ships. |
| `export` | `NAME=key` or `NAME=@key` for the `env_export` forge key - see [Forge](#forge) below. |
| `template` | Prose that may contain `{{placeholder}}` markers; the placeholder names are checked against the briefing whitelist at composition time, not here. |
| `keyspec` | One element of a `keys` list - see [The `keys` field](#the-keys-field-a-providers-own-config-schema) below. |
| `list:TYPE` | Space-separated (not colon-separated, unlike a config-file list) elements, each checked against `TYPE`. |
A leading `?` on any type (`?hostname`, `?list:identifier`, ...) makes the
key optional: empty/absent is legal. Without it, a non-empty value is
required whenever the key itself has no schema default.
## The `keys` field: a provider's own config schema
A forge or harness manifest can declare `keys = ...`: a space-separated list
of `name:type` or `name:type=default` elements, each parsed by the same
`keyspec` grammar the config file's own three-column schema table uses
(name, type, default) - just packed into one manifest line instead of a
row hardcoded in core. This is what a `[forge.<name>]`/`[harness.<name>]`
config section's own keys, types and defaults come from - **core never
- `ssh_host:?hostname` - optional (`?`), type `hostname`, no default (so
unset resolves to empty).
- `api:?url` - optional, type `url`, no default.
- `token_file:path=~/.config/gitea/token` - **not** optional (no `?`), type
`path`, default `~/.config/gitea/token`. A non-optional element must carry
a default - the same invariant every hardcoded core schema row already
holds: "always has a value" only means something if there is one.
Once gitea is the selected forge, `[forge.gitea] ssh_host`,
`[forge.gitea] api` and `[forge.gitea] token_file` become real config keys an
operator can set, resolved the same way as any other config key
(`config_get`, `--print-config`), purely from this one manifest line.
## Forge
`forges/<name>/forge.conf`. Every key:
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `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 literal command prefix `hooks/merge-guard.sh` refuses inside the sandbox (e.g. `gitea-pr merge`) - only when the workflow's `block_merge = on`. |
| `keys` | `?list:keyspec` | (empty) | This forge's own `[forge.<name>]` config schema - see above. |
| `requires` | `?list:identifier` | (empty) | Which of this forge's own config keys an operator is expected to set for it to actually work - checked at launch by two refusal rules (partly-configured, and none-configured-with-a-fixed-host) when the workflow pushes; see [Validation](#validation). A key with a non-empty schema default never counts as missing for either check. |
| `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. |
| `install` | `?list:relpath` | (empty) | Files under this forge's own directory that ship into `/usr/local/bin` of the image - the only manifest key naming something that actually gets copied and executed. The list is the gate, not a description of one: `docker/install-forge-bins.sh` installs exactly what is declared, and fails the build both when a declared file is missing and when a forge ships a file under its `bin/` that it does not declare. A forge with `install =` empty therefore puts nothing executable in the image. |
| `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. |
Reading it: gitea offers the `pr` and `issues` capabilities; its briefing
prose refers to `gitea-pr`/`gitea-issue`; `hooks/merge-guard.sh` blocks
`gitea-pr merge` when the workflow asks for that; `[forge.gitea]` gets three
config keys (`ssh_host`, `api`, `token_file`) from `keys`; an operator is
expected to set all three (`requires`); `ssh_host` is a literal hostname and
`api` is a URL, both of which end up in the firewall allowlist and the push
guard's host list; `install` names the executables under its own `bin/`
subdirectory (`gitea-pr`, `gitea-issue` and `gitea-actions`), and those three
are exactly what the image build installs; and the container gets `GITEA_API`, `GITEA_SSH_HOST`,
and `GITEA_TOKEN` (the *contents* of the file at `token_file`, not its path)
as environment variables.
## Workflow
`workflows/<name>/workflow.conf`. Every key:
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `requires` | `?list:identifier` | (empty) | Forge capabilities this workflow needs (checked against the selected forge's `capabilities` at launch). |
| `push` | `bool` | `on` | Whether this workflow pushes at all. When `off`, `DEJIMA_PUSH_HOSTS` is empty regardless of the forge, so `git-push-guard.sh` refuses every push. |
| `blocked_branches` | `?list:identifier` | (empty) | Branch names `git-push-guard.sh` refuses a direct push to, even on an allowed host. |
| `block_merge` | `bool` | `off` | When `on`, resolves the selected forge's `merge_block` command into `DEJIMA_MERGE_BLOCK` for `hooks/merge-guard.sh`. |
| `requires_worktree` | `bool` | `off` | When `on`, this workflow's briefing fragment must contain a `<!-- core: worktree -->` marker, which the core worktree-containment paragraph is spliced into - see below. |
| `autonomy` | `enum:commit\|push-branch\|open-pr` | `commit` | Gates `<!-- requires: autonomy=VALUE -->` spans in this workflow's own briefing fragment (see below); also read by the forge/harness resolvers where autonomy-conditioned prose applies. |
| `briefing` | `?relpath` | (empty) | This workflow's briefing fragment file, relative to its own directory. |
Worked example, `workflows/worktree-pr/workflow.conf`:
```
requires = pr
push = on
blocked_branches = main master
block_merge = on
requires_worktree = on
autonomy = open-pr
briefing = briefing.md
```
Reading it: needs a forge with the `pr` capability; pushes are allowed;
direct pushes to `main` or `master` are blocked; merging is blocked in the
sandbox (using whichever `merge_block` command the selected forge names);
its briefing fragment must (and does) carry the worktree marker; its
autonomy is `open-pr` (push the branch and open the PR without stopping to
ask, per its briefing prose); and its prose lives in `briefing.md` next to
the manifest.
### Briefing fragment markers
A workflow's `briefing.md` is plain Markdown plus two marker forms that
`briefing_compose` resolves before the agent ever sees the text:
- `<!-- core: worktree -->` - replaced with the core worktree-containment
paragraph (only when `requires_worktree = on`; present but unused
otherwise). This text is core's, not the provider's, because getting it
wrong silently discards real work - a workflow can only opt in or out via
`requires_worktree`, never edit or omit the paragraph itself.
- `<!-- requires: CONDITION --> ... <!-- end -->` - the enclosed prose
survives only when `CONDITION` holds; both marker lines are always
dropped. `CONDITION` is either a bare forge capability (checked against
the selected forge's `capabilities`, e.g. `<!-- requires: issues -->`) or
`autonomy=VALUE` (checked against this workflow's own `autonomy`).
## Harness
`harnesses/<name>/harness.conf`. Every key:
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `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. |
| `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). |
| `no_confirm_args` | `?template` | (empty) | The flags that make the harness run without confirming each tool call. Required in practice: dejima refuses to launch a harness that names none, because unattended operation is the premise the container is built on (R1 in [development.md](development.md)). |
| `settings_args` | `?template` | (empty) | How this harness is told to read dejima's own settings layer, mounted at `/etc/dejima-settings.json`. Empty for a harness that takes its overrides some other way. |
| `keys` | `?list:keyspec` | (empty) | This harness's own `[harness.<name>]` config schema - same grammar as a forge's `keys`. |
| `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. |
| `briefing` | `?relpath` | (empty) | This harness's briefing fragment file, relative to its own directory. |
Worked example, `harnesses/claude-code/harness.conf`:
`session_id`, `resume_command`, `writable_mounts`, `network_allowlist`) - an unrecognized one is a
hard refusal, not a literal pass-through. `{{session_id}}` and
`{{container_id}}` are deliberately left as `@@SESSION_ID@@`/
`@@CONTAINER_ID@@` markers instead of being resolved here, since neither is
known until the container is actually running; `hooks/sandbox-context.sh`
fills those two in at `SessionStart`.
The result is mounted **read-only** into the container - so a session can
never rewrite what the *next* session is told, even a session running
against dejima's own checkout. `dejima --print-briefing` composes and prints
it without launching anything, for inspecting exactly what a session would
see.
## What bin/dejima passes into the container
Only resolved values, never an unresolved manifest or a provider directory
itself:
- `DEJIMA_FORGE_HOSTS` - every hostname the selected forge needs *added to
the firewall's allowlist* (`host_keys`, the host part of `url_keys`, plus
`fixed_hosts`), unioned by `forge_hosts`. Empty for a forge declaring
`hosts_allowlisted_by_core` - core already allows those, and adding them
here would widen them to every port and drop their DNS-rebind protection.
- `DEJIMA_PUSH_HOSTS` - every hostname `forge_hosts` resolves (including a
`hosts_allowlisted_by_core` forge's, which the entry above omits) when the
workflow's `push = on`, otherwise empty.
- `DEJIMA_BLOCKED_BRANCHES` - the workflow's `blocked_branches`.
- `DEJIMA_MERGE_BLOCK` - the forge's `merge_block`, but only when the
workflow's `block_merge = on`; otherwise empty.
- Whatever the forge's own `env_export` names (e.g. `GITEA_API`,
`GITEA_SSH_HOST`, `GITEA_TOKEN`).
`hooks/git-push-guard.sh` reads `DEJIMA_PUSH_HOSTS` and
`DEJIMA_BLOCKED_BRANCHES`; `hooks/merge-guard.sh` reads `DEJIMA_MERGE_BLOCK` as a
regex-escaped literal command prefix. Two different failure directions,
both deliberate:
- `DEJIMA_PUSH_HOSTS` fails **closed** - empty means no host is allowed, so
every push is refused. A no-forge or non-pushing workflow becomes
*enforced*, not merely described.
- `DEJIMA_BLOCKED_BRANCHES` fails **open** - empty means no branch is
protected, which is correct for a workflow with genuinely no branch
policy.
- `DEJIMA_MERGE_BLOCK` fails **open** the same way - empty makes
`merge-guard.sh` inert rather than conditionally installed.
## Validation
`provider_validate_selection` checks the selected forge/workflow/harness at
every launch:
- Each manifest must validate.
- A workflow's `requires` must be a subset of the forge's `capabilities` - a
workflow asking for a capability the forge doesn't have is refused
outright.
- The workflow's `autonomy` must be something the forge can actually back up:
`autonomy = open-pr` needs a forge offering the `pr` capability; `autonomy
= push-branch` needs `push = on` (it can't stop at a push it never makes).
A mismatch is refused at launch rather than discovered by the agent
mid-task.
- A pushing workflow (`push = on`) needs a forge that resolves to somewhere
to push to. A wholly unconfigured forge with nothing to configure at all
(`forge = none`) is refused, pointing at a forge that needs no host
configured (`forge = github`) or a workflow that does not push; a forge
that has config keys but none of them resolve to a host is refused with
"configure it, or select `forge = github`, or `forge = none` with a
workflow that does not push".
- A forge whose `requires` keys are **partly** set is refused as a likely
typo, but only when the workflow pushes. A key written but left empty
counts as not set - it resolves to nothing, and every call made with it
would fail inside the container. A non-pushing workflow never invokes the
forge, so gitea's `ssh_host` alone (set only so the firewall allows
fetches) is legitimate. A required key with a non-empty schema default -
gitea's `token_file` - never counts as missing, since its resolved value is
correct even unwritten. A wholly unconfigured forge is the case above, not
this one.
- A pushing workflow whose forge has **none** of its required settings is
refused too, even where `fixed_hosts` stops the "resolves to no host" rule
firing. This catches `github`: with a single `requires` key there is
nothing to be partial between, so without this rule a pushing workflow
could select it with no `push_token_file` and launch anyway, `fixed_hosts`
masking that nothing was configured.
## Shipped providers
| axis | provider | notes |
| --- | --- | --- |
| forge | `gitea` | PRs and issues via `gitea-pr`/`gitea-issue`, shipped into the image |
| forge | `github` | PRs and issues via `gh`; needs its own write-capable `push_token_file`; opens `ssh.github.com:22` when the selected workflow pushes |
| forge | `none` | **default** - no capabilities, no host; pair with `local-only` |
| workflow | `worktree-pr` | worktree → push → PR, stops at the PR |
| workflow | `branch-push` | worktree → push, stops at the branch |
`ttyd` binds `127.0.0.1` and your proxy must bind one specific LAN
address - never the wildcard, never a Docker bridge.
- **Authentication on the proxy.**`ttyd` carries no credential of its own,
so a `server` block without `auth_basic` is an unauthenticated host shell
for everyone on that network.
What it does **not** add is a way *out* of a sandbox. Configured as
described below, a compromised session cannot reach `ttyd`, cannot reach
your proxy, and gains nothing from the fact that your phone can: it has a
route to exactly one host address, and neither front door is on it. That
property needs stating exactly, because it is the one a plausible-looking
mistake inverts - a proxy bound to `0.0.0.0` or to a bridge address is a
front door opening *inward*, and that is a full sandbox escape rather than a
degradation. [Verifying it](#verifying-it) is two commands, and worth
running.
## `[remote-access]`
A section of the single config file, at `~/.config/dejima/config`
(`$DEJIMA_CONFIG_DIR` relocates the whole config tree, same as it does for
approvals):
| Key | Default | Purpose |
| --- | --- | --- |
| `enabled` | `off` | the switch - nothing below runs until this is `on` |
| `ntfy_url` | unset | ntfy topic to push notifications to. `dejima-notify-watch` refuses to start without it; nothing else reads it |
| `base_url` | unset | public base URL of the web front door, used to build the notification's deep link. Absent, pushes still go out - just with no link on them |
| `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 - see "Reading it on a phone". Unset passes no option at all and leaves `ttyd` on its own default |
| `ttyd_tmux_prefix` | unset | the tmux prefix the terminal page's `PREFIX`, `◀W`, `W▶` and `ZOOM` buttons send, written as a control chord: `C-a`, `C-b`, `C-Space`, `C-@`, `C-_`, `C-?`. Unset leaves the page on stock tmux's `C-b`. See "Reading it on a phone" |
`ntfy_url` and `base_url` must carry an explicit `http://` or `https://`
scheme; a value without one is refused rather than guessed at, because
`curl` reads a leading `-K/path` as an *option* that takes the request's URL
from that file instead. Values are restricted to
`A-Za-z0-9:/._~?=%@+-`: no whitespace and nothing shell-active, since these
end up in an HTTP header and in a URL handed to a phone.
**A malformed config is a hard failure, not a warning.** The whole file is
validated on every read, not just the key being asked for: an unknown key, a
value outside that character set, or a line that doesn't parse makes
`dejima-ttyd` and `dejima-notify-watch` refuse to start and say which line
was wrong. A `ttyd_port` that isn't a number in 1-65535 is refused the same
way, as is a `ttyd_font_size` outside 8-72 - a size big enough to make one
glyph wider than the viewport renders a terminal that shows nothing, which
reads as a broken front door rather than as a typo three sections away. A
`ttyd_tmux_prefix` that is not a control chord is refused here too, rather
than on the phone, hours later, by whoever presses `PREFIX` and gets nothing.
`dejima-ttyd` and `dejima-notify-watch` are the two scripts that read a
*value* out of the config, so a typo anywhere in the file refuses their
start. `dejima` reads values too (`audit dir` among them) and refuses the
same way. `dejima-attach` alone only asks `remote_enabled` - deliberately
bypassing the whole-file validation, so a typo elsewhere in the config can't
lock you out of the terminal you'd fix it from.
```
# ~/.config/dejima/config - substitute your own topic and hostname
echo"Blocked by git-push-guard.sh: this sandbox may only push to the Gitea host (${GITEA_SSH_HOST:-<GITEA_SSH_HOST not set>})$(["${ALLOW_GITHUB_PUSH:-0}"="1"]&&echo" or github.com/ssh.github.com (ALLOW_GITHUB_PUSH=1)"), not '$host'." >&2
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
@@ -89,9 +92,11 @@ else
fi
branch="${branch#refs/heads/}"
if["$branch"="main"]||["$branch"="master"];then
echo"Blocked by git-push-guard.sh: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and run 'gitea-pr create' instead." >&2
exit2
fi
for blocked_branch in ${DEJIMA_BLOCKED_BRANCHES:-};do
if["$branch"="$blocked_branch"];then
echo"Blocked by git-push-guard.sh: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and open a PR/MR instead." >&2
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
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.